Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Kernel Path is being turned into a data-only learning platform (see [docs/PLATFO
- **The compiler** (`packages/compiler`, the `kernel` command): validates `content/` against the contract and compiles it into a static, content-hashed bundle. Code is highlighted at build time.
- **The engine** (`packages/engine`): a React 19 + Vite 8 player. At startup it fetches the bundle from the location in `kernel.config.json`, validates every file, and renders pages through its own components. It contains no course text and never renders raw HTML.
- **A site home and program selector.** `#/` lists every program with the reader's progress, from `content/site/home.md`; the header's program menu switches between programs. A program without authored landing or dashboard copy gets built-in ones, and a planned program shows its outline.
- **No backend.** Routing lives in the URL hash (`#/rhel9-ansible/ch03/inventory`: program, chapter, section). Progress is kept separately for each program. Progress, lab checklists, quiz answers and the last page read are stored in the reader's `localStorage`, sync across tabs, and can be exported or imported from the progress menu.
- **No backend.** Every page has its own address (`/rhel9-ansible/ch03/inventory/`: program, chapter, section), written as a real HTML file at build time. Progress is kept separately for each program. Progress, lab checklists, quiz answers and the last page read are stored in the reader's `localStorage`, sync across tabs, and can be exported or imported from the progress menu.

```
content/ the course (see docs/AUTHORING.md)
Expand Down Expand Up @@ -92,7 +92,7 @@ The workflow in `.github/workflows/deploy.yml` builds and publishes the site on
2. In **Settings → Pages**, set **Source** to **GitHub Actions**.
3. Push to `main` (or run the workflow manually). The site appears at `https://<user>.github.io/<repo>/`.

No configuration is needed for the repository name: assets use relative paths and routes live in the URL hash (`#/rhel9-ansible/ch03/inventory`), so deep links and page refreshes work from any sub-path without a 404 fallback. `packages/engine/public/.nojekyll` stops GitHub from running Jekyll over the output.
The site is built for the root of its domain (`CNAME`). To serve it from a project sub-path instead, build with `SITE_BASE=/repo-name/` and `SITE_URL=https://user.github.io`; deep links work because every page is written as its own HTML file, and `404.html` covers the rest. `packages/engine/public/.nojekyll` stops GitHub from running Jekyll over the output.

## Contributing

Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The public site is `https://kernelpath.dev/`; `packages/engine/public/CNAME` rec

The compiler (`packages/compiler`) turns `content/` into a static bundle of JSON files: a site index, a program manifest, one render tree per page, and a search index. At startup the engine (`packages/engine`) fetches the bundle from `contentBase` in `kernel.config.json`, validates every file with the generated validators from `@kernel-path/schema`, and `src/lib/course.js` exposes navigation and page lookup. Pages load on demand. `PageTree` renders only allowlisted elements and catalog tags; see [PLATFORM.md](PLATFORM.md) for the platform design and security model.

Published URLs use hash routing, for example `#/rhel9-ansible/ch03/inventory` (program, chapter, section). Addresses without a program, such as `#/ch03/inventory`, open the default program. The engine loads one program at a time; the program route remounts its pages when the reader moves to another. A second hash identifies a heading or activity. Keep published filenames, heading text, and stable activity IDs when editing. Links into optional reveals open their containing details.
Every page has its own address, for example `/rhel9-ansible/ch03/inventory/` (program, chapter, section). The build (`scripts/prerender.mjs`) writes a real HTML file for each one, with its title, description, canonical address, social-card tags, structured data and the page's text, so a static host serves deep links and search engines can index each page; `404.html` lets the app handle any other address. Old hash addresses (`#/rhel9-ansible/ch03/inventory`, and `#/ch03/inventory` without a program) move to the path form when they open. The engine loads one program at a time; the program route remounts its pages when the reader moves to another. A second hash identifies a heading or activity. Keep published filenames, heading text, and stable activity IDs when editing. Links into optional reveals open their containing details.

`content/programs/<id>/objectives.yml` maps stable skill IDs to lessons, challenges, and labs. Each chapter lists its objective IDs; quizzes and labs reference the skills they practise.

Expand Down Expand Up @@ -54,7 +54,7 @@ The lab tools live in `packages/lab-tools`: the `lab` command, the grader and th

## Build and checks

React 19, React Router 7, Vite 8 and CSS build the engine into `dist/`; the compiler (Markdoc, YAML, Shiki) builds the content into `dist/content/`. `base: './'` and hash routing support GitHub Pages.
React 19, React Router 7, Vite 8 and CSS build the engine into `dist/`; the compiler (Markdoc, YAML, Shiki) builds the content into `dist/content/`. `base` is `/` (or `SITE_BASE` for a sub-path), and the prerendered pages plus `404.html` support GitHub Pages. The build also writes `sitemap.xml` and `robots.txt` for the address in `SITE_URL` or the `CNAME` file.

PR CI runs formatting, content validation, JavaScript regressions, Python lab-tool tests, a production build, and Chromium learning-flow checks. Content validation covers routes and heading links, activity/objective IDs, quiz answers, starter manifests, setup syntax, and grader coverage. Browser checks cover every route plus mobile layouts, focus, persistence, and imports.

Expand Down
2 changes: 1 addition & 1 deletion docs/AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ content/
```

- **Order** comes from the numeric filename prefix (`01-`, `02-`…).
- **URL slug** is the filename without its prefix: `03-configuration.md` → `#/rhel9-ansible/ch03/configuration` (links inside content leave out the program: write `#/ch03/configuration`, and the site adds the current program). Renaming a file changes its URL and resets anyone's progress for that page, so avoid renaming published sections.
- **URL slug** is the filename without its prefix: `03-configuration.md` → `/rhel9-ansible/ch03/configuration/` (links inside content leave out the program: write `#/ch03/configuration`, and the site adds the current program). Renaming a file changes its URL and resets anyone's progress for that page, so avoid renaming published sections.
- **Section links** add the heading's id after a second `#`: `#/ch08/collections#where-collections-come-from`. Ids come from the heading text (lowercased, spaces to hyphens), so renaming an `##` or `###` heading breaks links people have shared to it. Every `##` and `###` heading gets a copy-link button automatically; link to another page's heading with `[text](#/ch01/page#heading-id)`.
- **Frontmatter** at the top of each page:

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"type": "module",
"scripts": {
"dev": "npm run dev -w @kernel-path/engine",
"build": "npm run build -w @kernel-path/engine && kernel build content --out dist/content && node scripts/publish-lab.mjs dist && node scripts/publish-schemas.mjs dist && node scripts/inject-meta.mjs dist",
"build": "npm run build -w @kernel-path/engine && kernel build content --out dist/content && node scripts/publish-lab.mjs dist && node scripts/publish-schemas.mjs dist && node scripts/inject-meta.mjs dist && node scripts/prerender.mjs dist",
"preview": "npm run preview -w @kernel-path/engine --",
"format": "prettier --write \"packages/*/{src,test,plugins,scripts}/**/*.{js,jsx,mjs,css}\" packages/engine/vite.config.js",
"format:check": "prettier --check \"packages/*/{src,test,plugins,scripts}/**/*.{js,jsx,mjs,css}\" packages/engine/vite.config.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="description" content="" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23EE0000'/%3E%3Cpath d='M9 23l5-6 4.5 3L23.5 10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='9' cy='23' r='2.2' fill='white'/%3E%3Ccircle cx='23.5' cy='10' r='2.6' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E" />
<!-- Resolves the theme before first paint to avoid a flash. An external file, so scripts can be limited to this site. -->
<script src="./theme-init.js"></script>
<script src="%BASE_URL%theme-init.js"></script>
</head>
<body>
<div id="root"><p class="boot-status" role="status">Loading…</p></div>
Expand Down
Binary file added packages/engine/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 7 additions & 5 deletions packages/engine/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useState } from 'react';
import { HashRouter, Link as RootLink, Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { BrowserRouter, Link as RootLink, Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom';
import AppShell from '@/components/layout/AppShell';
import PlatformShell from '@/components/layout/PlatformShell';
import PlatformHome from '@/pages/PlatformHome';
Expand Down Expand Up @@ -83,11 +83,13 @@ function ProgramGate() {
return <Outlet key={programId} />;
}

// Hash routing keeps deep links working on GitHub Pages without a
// server-side rewrite or a 404.html fallback.
// Every page has its own address. The build writes a real HTML file for each one (scripts/prerender.mjs),
// so deep links work on a static host and search engines can read the pages; 404.html covers the rest.
const basename = import.meta.env.BASE_URL.replace(/\/$/, '');

export default function App() {
return (
<HashRouter>
<BrowserRouter basename={basename}>
<Routes>
<Route element={<PlatformShell />}>
<Route index element={<PlatformHome />} />
Expand Down Expand Up @@ -125,6 +127,6 @@ export default function App() {
}
/>
</Routes>
</HashRouter>
</BrowserRouter>
);
}
9 changes: 3 additions & 6 deletions packages/engine/src/components/prose/Heading.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ import { useLocation } from 'react-router-dom';
import { useNavigate } from '@/lib/router';
import { Check, Link as LinkIcon } from 'lucide-react';

// The router owns the URL hash ("#/ch08/collections"), so a section's address
// adds the heading id after a second "#": "#/ch08/collections#the-role-layout".
// React Router reads that second part as location.hash; shared heading
// navigation scrolls to it once the MDX page has loaded.
// A heading's address is the page's address plus "#id"; shared heading navigation scrolls to it
// once the page has loaded.
export function sectionUrl(pathname, id) {
const { origin, pathname: base } = window.location;
return `${origin}${base}#${pathname}#${id}`;
return `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, '')}${pathname}#${id}`;
}

async function copyText(text) {
Expand Down
7 changes: 3 additions & 4 deletions packages/engine/src/components/prose/elements.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ export function Code({ children, ...props }) {
return <code {...props}>{fillInline(children, labValues)}</code>;
}

// The router owns the URL hash, so heading links carry the heading after a
// second "#": "#/ch01/page#heading" opens a page at a heading, "#heading"
// scrolls this one. Page renderers share heading navigation.
// Content writes links to its own pages as "#/ch01/page" (and "#/ch01/page#heading"); they open the
// page's real address. "#heading" scrolls this one. Page renderers share heading navigation.
export function Anchor({ href = '', onClick, ...props }) {
const navigate = useNavigate();
if (href.startsWith('#/')) {
Expand All @@ -22,7 +21,7 @@ export function Anchor({ href = '', onClick, ...props }) {
e.preventDefault();
navigate(href.slice(1));
};
return <a href={`#${programPath(href.slice(1))}`} onClick={go} {...props} />;
return <a href={`${import.meta.env.BASE_URL.replace(/\/$/, '')}${programPath(href.slice(1))}`} onClick={go} {...props} />;
}
if (href.startsWith('#') && !href.startsWith('#/')) {
const go = (e) => {
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/diagrams/ch04/DataExplorer.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { defineWidget, formatCopy } from '@/components/interactive/TeachingContent';
import './ch04.css';

export default defineWidget('DataExplorer', (copy) => {
const METHOD_NAMES = new Set(['add', 'append', 'clear', 'copy', 'discard', 'get', 'items', 'keys', 'pop', 'update', 'values']);
Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/diagrams/ch04/ch04.css
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@
border: 0;
}
.cfb-grid .dx-grid {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
}
.cfb-file .widget-label {
display: block;
Expand Down
6 changes: 4 additions & 2 deletions packages/engine/src/lib/content.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ const cache = new Map();
*/
async function configuration() {
if (settings) return settings;
// Relative to where the site is served from, not to the page's own address (pages have paths now).
const siteRoot = new URL(import.meta.env?.BASE_URL ?? './', document.baseURI);
let configured = './content/';
let publicKey = null;
try {
const response = await fetch('./kernel.config.json', { cache: 'no-cache' });
const response = await fetch(new URL('kernel.config.json', siteRoot), { cache: 'no-cache' });
if (response.ok) {
const config = await response.json();
if (typeof config.contentBase === 'string') configured = config.contentBase;
Expand All @@ -60,7 +62,7 @@ async function configuration() {
if (error instanceof ContentError) throw error;
/* no config: use the same-origin default */
}
const url = new URL(configured, document.baseURI);
const url = new URL(configured, siteRoot);
if (!['https:', 'http:'].includes(url.protocol)) throw new ContentError('The content location must be an http(s) URL.');
settings = { base: url.href.endsWith('/') ? url.href : `${url.href}/`, publicKey };
return settings;
Expand Down
14 changes: 14 additions & 0 deletions packages/engine/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ import App from './App';
import BootError from './pages/BootError';
import { bootContent } from './lib/course';

// Addresses from before pages had paths ("/#/rhel9-ansible/ch03/inventory#heading") move to the path form,
// on arrival and when an old link changes the hash later.
function moveHashAddress() {
if (!window.location.hash.startsWith('#/')) return false;
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
window.history.replaceState(null, '', `${base}${window.location.hash.slice(1)}`);
return true;
}
moveHashAddress();
window.addEventListener('hashchange', () => {
// The router listens for popstate, so it follows the moved address.
if (moveHashAddress()) window.dispatchEvent(new PopStateEvent('popstate'));
});

const root = createRoot(document.getElementById('root'));

bootContent().then(
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,11 @@ kbd {
font-size: 22px;
font-weight: 600;
}

/* The text the build writes into each page for search engines and readers without JavaScript; the app
replaces it when it starts. */
.prerendered {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 64px;
}
66 changes: 37 additions & 29 deletions packages/engine/src/styles/landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,39 @@

.landing {
--lt-50: var(--purple-50);
max-width: 1180px;
margin: 0 auto;
padding: 8px 32px 72px;
/* The page uses the whole window: the colour wash runs edge to edge, and the content sits in a
centred column that grows with the screen. */
--landing-w: min(1360px, 100%);
position: relative;
isolation: isolate;
width: 100%;
padding: clamp(8px, 2vw, 24px) clamp(16px, 4vw, 56px) 88px;
overflow-x: clip;
}
.landing::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: clamp(640px, 95vh, 1100px);
z-index: -1;
pointer-events: none;
background:
radial-gradient(48vw 36vw at 2% 6%, color-mix(in srgb, var(--purple-100) 70%, transparent), transparent 72%),
radial-gradient(42vw 34vw at 98% 2%, color-mix(in srgb, var(--teal-100) 62%, transparent), transparent 72%),
radial-gradient(38vw 26vw at 62% 62%, color-mix(in srgb, var(--coral-100) 46%, transparent), transparent 72%),
radial-gradient(30vw 24vw at 18% 78%, color-mix(in srgb, var(--amber-100) 34%, transparent), transparent 72%);
-webkit-mask-image: linear-gradient(#000 45%, transparent);
mask-image: linear-gradient(#000 45%, transparent);
}
:root[data-theme='dark'] .landing::before {
opacity: 0.34;
}
.landing > * {
max-width: var(--landing-w);
margin-inline: auto;
}
.landing > h2 {
margin: 64px 0 8px;
margin-block: clamp(48px, 7vw, 88px) 8px;
font-family: var(--font-reading);
font-size: clamp(28px, 3.4vw, 38px);
font-weight: 500;
Expand Down Expand Up @@ -47,25 +73,9 @@
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
align-items: center;
gap: 40px;
padding: 24px 0 0;
}
.lh::before {
content: '';
position: absolute;
inset: -40px -200px auto;
height: 520px;
z-index: -1;
background:
radial-gradient(520px 320px at 12% 20%, color-mix(in srgb, var(--purple-100) 55%, transparent), transparent 70%),
radial-gradient(480px 300px at 88% 10%, color-mix(in srgb, var(--teal-100) 50%, transparent), transparent 70%),
radial-gradient(420px 260px at 55% 90%, color-mix(in srgb, var(--coral-100) 40%, transparent), transparent 70%);
-webkit-mask-image: linear-gradient(#000 55%, transparent);
mask-image: linear-gradient(#000 55%, transparent);
opacity: 0.8;
}
:root[data-theme='dark'] .lh::before {
opacity: 0.22;
gap: clamp(32px, 5vw, 80px);
min-height: min(640px, calc(100vh - var(--header-h) - 24px));
padding: clamp(16px, 4vw, 48px) 0 0;
}
.lh-eyebrow {
display: inline-flex;
Expand Down Expand Up @@ -93,7 +103,7 @@
.lh-title {
margin: 0 0 20px;
font-family: var(--font-reading);
font-size: clamp(38px, 5.6vw, 64px);
font-size: clamp(38px, 4.8vw, 76px);
font-weight: 500;
line-height: 1.04;
letter-spacing: -0.03em;
Expand All @@ -118,9 +128,9 @@
animation: lh-underline 0.9s 0.5s var(--ease-out) both;
}
.lh-lead {
max-width: 520px;
max-width: 560px;
color: var(--text-2);
font-size: 18.5px;
font-size: clamp(17px, 1.25vw, 20px);
line-height: 1.6;
}
.lh-lead p {
Expand Down Expand Up @@ -886,6 +896,7 @@
.lh {
grid-template-columns: minmax(0, 1fr);
gap: 28px;
min-height: 0;
padding-top: 24px;
}
.lh-art {
Expand All @@ -900,9 +911,6 @@
}
}
@media (max-width: 600px) {
.landing {
padding: 8px 16px 64px;
}
.landing > h2 {
margin-top: 44px;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/engine/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ const require = createRequire(import.meta.url);
const fontPackage = (name) => path.dirname(require.resolve(`${name}/package.json`));

export default defineConfig({
// Relative asset paths + hash routing = works on any GitHub Pages sub-path.
base: './',
// Pages have real addresses ("/rhel9-sysadmin/ch02/linux-and-the-shell"), so search engines can index
// each one. SITE_BASE is the path the site is served from: "/" for a custom domain, "/repo/" for a
// project page on github.io.
base: process.env.SITE_BASE ?? '/',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
Expand Down
Loading