diff --git a/README.md b/README.md index 60cef9a..cb32d94 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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://.github.io//`. -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9fbef76..686a78b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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//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. @@ -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. diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index 47de7bd..6369249 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -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: diff --git a/package.json b/package.json index 1dc9f82..54c5ad7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/engine/index.html b/packages/engine/index.html index 9889795..3a49149 100644 --- a/packages/engine/index.html +++ b/packages/engine/index.html @@ -7,7 +7,7 @@ - +

Loading…

diff --git a/packages/engine/public/og-image.png b/packages/engine/public/og-image.png new file mode 100644 index 0000000..16fc8ea Binary files /dev/null and b/packages/engine/public/og-image.png differ diff --git a/packages/engine/src/App.jsx b/packages/engine/src/App.jsx index 47de5fa..943eb18 100644 --- a/packages/engine/src/App.jsx +++ b/packages/engine/src/App.jsx @@ -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'; @@ -83,11 +83,13 @@ function ProgramGate() { return ; } -// 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 ( - + }> } /> @@ -125,6 +127,6 @@ export default function App() { } /> - + ); } diff --git a/packages/engine/src/components/prose/Heading.jsx b/packages/engine/src/components/prose/Heading.jsx index 5b285af..16691e9 100644 --- a/packages/engine/src/components/prose/Heading.jsx +++ b/packages/engine/src/components/prose/Heading.jsx @@ -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) { diff --git a/packages/engine/src/components/prose/elements.jsx b/packages/engine/src/components/prose/elements.jsx index 81e9c72..860a90c 100644 --- a/packages/engine/src/components/prose/elements.jsx +++ b/packages/engine/src/components/prose/elements.jsx @@ -9,9 +9,8 @@ export function Code({ children, ...props }) { return {fillInline(children, labValues)}; } -// 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('#/')) { @@ -22,7 +21,7 @@ export function Anchor({ href = '', onClick, ...props }) { e.preventDefault(); navigate(href.slice(1)); }; - return ; + return ; } if (href.startsWith('#') && !href.startsWith('#/')) { const go = (e) => { diff --git a/packages/engine/src/diagrams/ch04/DataExplorer.jsx b/packages/engine/src/diagrams/ch04/DataExplorer.jsx index 0fefc9f..db5cbdb 100644 --- a/packages/engine/src/diagrams/ch04/DataExplorer.jsx +++ b/packages/engine/src/diagrams/ch04/DataExplorer.jsx @@ -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']); diff --git a/packages/engine/src/diagrams/ch04/ch04.css b/packages/engine/src/diagrams/ch04/ch04.css index ed9e28d..6ed37e4 100644 --- a/packages/engine/src/diagrams/ch04/ch04.css +++ b/packages/engine/src/diagrams/ch04/ch04.css @@ -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; diff --git a/packages/engine/src/lib/content.js b/packages/engine/src/lib/content.js index 7d75cd2..83e6179 100644 --- a/packages/engine/src/lib/content.js +++ b/packages/engine/src/lib/content.js @@ -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; @@ -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; diff --git a/packages/engine/src/main.jsx b/packages/engine/src/main.jsx index 3e3016d..c7bc0b3 100644 --- a/packages/engine/src/main.jsx +++ b/packages/engine/src/main.jsx @@ -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( diff --git a/packages/engine/src/styles/base.css b/packages/engine/src/styles/base.css index 8a8800c..b4517d2 100644 --- a/packages/engine/src/styles/base.css +++ b/packages/engine/src/styles/base.css @@ -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; +} diff --git a/packages/engine/src/styles/landing.css b/packages/engine/src/styles/landing.css index 22a3001..c32fa4b 100644 --- a/packages/engine/src/styles/landing.css +++ b/packages/engine/src/styles/landing.css @@ -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; @@ -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; @@ -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; @@ -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 { @@ -886,6 +896,7 @@ .lh { grid-template-columns: minmax(0, 1fr); gap: 28px; + min-height: 0; padding-top: 24px; } .lh-art { @@ -900,9 +911,6 @@ } } @media (max-width: 600px) { - .landing { - padding: 8px 16px 64px; - } .landing > h2 { margin-top: 44px; } diff --git a/packages/engine/vite.config.js b/packages/engine/vite.config.js index 1f1c3f3..fbe5da2 100644 --- a/packages/engine/vite.config.js +++ b/packages/engine/vite.config.js @@ -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)), diff --git a/scripts/inject-meta.mjs b/scripts/inject-meta.mjs index 3834e28..3fbd4dc 100644 --- a/scripts/inject-meta.mjs +++ b/scripts/inject-meta.mjs @@ -58,6 +58,9 @@ export const policy = [ "require-trusted-types-for 'script'", ].join('; '); +// "/assets/x.js" or "./assets/x.js" → the file in dist, whatever base the site is served from. +const base = process.env.SITE_BASE ?? '/'; +const local = (href) => (href.startsWith(base) ? href.slice(base.length) : href.replace(/^\.?\//, '')); const integrity = (href) => `sha384-${crypto.createHash('sha384').update(fs.readFileSync(path.join(dist, href))).digest('base64')}`; const file = path.join(dist, 'index.html'); @@ -71,9 +74,9 @@ let html = fs // The policy goes first so it applies to everything after it. .replace(//, `\n \n `) // Scripts and stylesheets from this site get an integrity hash; the browser refuses a changed file. - .replace(/<(script|link)\b([^>]*?)\b(src|href)="(\.\/[^"]+\.(?:js|css))"([^>]*)>/g, (tag, name, before, attribute, href, after) => { + .replace(/<(script|link)\b([^>]*?)\b(src|href)="((?:\.\/|\/)[^"]+\.(?:js|css))"([^>]*)>/g, (tag, name, before, attribute, href, after) => { if (/\bintegrity=/.test(tag)) return tag; - return `<${name}${before}${attribute}="${href}" integrity="${integrity(href)}"${after}>`; + return `<${name}${before}${attribute}="${href}" integrity="${integrity(local(href))}"${after}>`; }); fs.writeFileSync(file, html); console.log(`index.html: ${title}`); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs new file mode 100644 index 0000000..0de7b86 --- /dev/null +++ b/scripts/prerender.mjs @@ -0,0 +1,342 @@ +// Writes a real HTML file for every page of the site, so that each page has its own address on a static +// host and search engines can read it without running the app: +// - dist////index.html (and the program and chapter pages): the built index.html +// with the page's title, description, canonical address, social-card tags, structured data (JSON-LD) +// and the page's text in
. The app replaces that text when it starts. +// - dist/404.html: the app, for addresses that have no file (it shows its own "not found" page) +// - dist/sitemap.xml and dist/robots.txt +// The site address comes from SITE_URL, or from the CNAME file of the build. +// node scripts/prerender.mjs +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const dist = path.resolve(process.argv[2] ?? 'dist'); +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const base = process.env.SITE_BASE ?? '/'; +const cname = fs.existsSync(path.join(dist, 'CNAME')) ? fs.readFileSync(path.join(dist, 'CNAME'), 'utf8').trim() : ''; +const origin = (process.env.SITE_URL ?? (cname ? `https://${cname}` : '')).replace(/\/$/, ''); +if (!origin) throw new Error('Set SITE_URL (or add a CNAME file) so pages can name their canonical address.'); + +const read = (file) => JSON.parse(fs.readFileSync(path.join(dist, 'content', file), 'utf8')); +const site = read('site.json'); +const template = fs.readFileSync(path.join(dist, 'index.html'), 'utf8'); +const siteName = site.site.name; + +const escape = (text) => + String(text ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); +const clip = (text, length = 158) => { + const flat = String(text ?? '').replace(/\s+/g, ' ').trim(); + return flat.length <= length ? flat : `${flat.slice(0, length - 1).replace(/\s+\S*$/, '')}…`; +}; +/** "/rhel9-sysadmin/ch02" → the public address, always with a trailing slash (the form the static host serves). */ +const address = (route) => `${origin}${base}${route.replace(/^\/|\/$/g, '')}${route === '/' ? '' : '/'}`.replace(/([^:])\/\/+/g, '$1/'); + +// ---------- Page trees to HTML ---------- + +const TEXT_TAGS = new Set(['p', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'strong', 'em', 'code', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'br', 'hr', 'del', 'kbd']); + +/** Content links: "#/ch02/x" is a page of the same program, "#heading" a heading on this page. */ +function link(href, programId) { + if (typeof href !== 'string') return null; + if (href.startsWith('#/')) return `${base}${programId ? `${programId}/` : ''}${href.slice(2)}`.replace(/\/\/+/g, '/'); + if (href.startsWith('#') || /^https:\/\//.test(href) || href.startsWith('mailto:')) return href; + return null; +} + +function text(nodes) { + if (!Array.isArray(nodes)) return ''; + return nodes.map((n) => (n.t === 'text' ? n.v : n.c ? text(n.c) : '')).join(''); +} + +function render(nodes, ctx) { + if (!Array.isArray(nodes)) return ''; + return nodes.map((node) => renderNode(node, ctx)).join(''); +} + +function renderNode(node, ctx) { + if (!node || typeof node !== 'object') return ''; + if (node.t === 'text') return escape(node.v); + if (node.t === 'code') return `
${escape((node.lines ?? []).map((line) => line.map((token) => token.v).join('')).join('\n'))}
`; + if (node.t === 'el') { + const tag = node.tag; + if (tag === 'a') { + const href = link(node.attrs?.href, ctx.programId); + return href ? `
${render(node.c, ctx)}` : render(node.c, ctx); + } + if (tag === 'pre') return `
${escape(text(node.c))}
`; + if (!TEXT_TAGS.has(tag)) return render(node.c, ctx); + const id = /^h[2-4]$/.test(tag) && node.attrs?.id ? ` id="${escape(node.attrs.id)}"` : ''; + return tag === 'br' || tag === 'hr' ? `<${tag}>` : `<${tag}${id}>${render(node.c, ctx)}`; + } + if (node.t === 'tag') { + const a = node.attrs ?? {}; + const data = a.ref ? ctx.data?.[a.ref] : undefined; + switch (node.name) { + case 'hero': + return `

${escape(a.title)}

${render(node.c, ctx)}`; + case 'lab': + return `

${escape(a.title ?? 'Exercise')}

${ + Array.isArray(a.outcomes) && a.outcomes.length ? `
    ${a.outcomes.map((o) => `
  • ${escape(o)}
  • `).join('')}
` : '' + }${render(node.c, ctx)}
`; + case 'task': + return `

${escape(a.title)}

${render(node.c, ctx)}`; + case 'tab': + return `

${escape(a.label)}

${render(node.c, ctx)}`; + case 'callout': + return ``; + case 'quiz': { + const questions = data?.questions ?? []; + return questions.length ? `
    ${questions.map((q) => `
  • ${escape(q.q)}
  • `).join('')}
` : ''; + } + case 'flashcards': { + const cards = data?.cards ?? []; + return cards.length ? `
${cards.map((c) => `
${escape(c.front)}
${escape(c.back)}
`).join('')}
` : ''; + } + case 'diagram': + return data?.title ? `

${escape(data.title)}${data.caption ? `: ${escape(data.caption)}` : ''}

` : ''; + case 'lab-finish': + return `

Check your work with lab grade ${escape(a.exercise)}, then put the exercise away with lab finish ${escape(a.exercise)}.

`; + default: + return render(node.c, ctx); + } + } + return ''; +} + +// ---------- The HTML file of one address ---------- + +const breadcrumb = (items) => ({ + '@type': 'BreadcrumbList', + itemListElement: items.map((item, i) => ({ '@type': 'ListItem', position: i + 1, name: item.name, item: item.url })), +}); +const organization = { '@type': 'Organization', name: siteName, url: address('/') }; + +function page({ route, title, description, body, graph = [], index = true, type = 'website' }) { + const url = address(route); + const image = `${origin}${base}og-image.png`; + const json = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/`, + index ? '' : '', + ``, + ``, + ``, + ``, + ``, + ``, + '', + '', + '', + ``, + ``, + ``, + ...(graph.length ? [``] : []), + ].join('\n '); + return template + .replace(/[^<]*<\/title>/, `<title>${escape(title)}`) + .replace(//, `\n ${head}`) + .replace(/
[\s\S]*?<\/div>\s*(?=)/, `
${body}
\n `); +} + +function write(route, html) { + const file = route === '/' ? path.join(dist, 'index.html') : path.join(dist, route.replace(/^\//, ''), 'index.html'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, html); +} + +/** When a content file last changed, from Git; the build time when Git cannot tell. */ +const fallbackDate = (site.generatedAt ?? new Date().toISOString()).slice(0, 10); +function lastChanged(files) { + if (!files.length) return fallbackDate; + try { + const out = execFileSync('git', ['log', '-1', '--format=%cs', '--', ...files], { cwd: root, encoding: 'utf8' }).trim(); + return out || fallbackDate; + } catch { + return fallbackDate; + } +} +const sourceOf = (programId, key) => { + const [chapter, slug] = key.split('/'); + const dir = path.join(root, 'content/programs', programId, 'chapters'); + const chapterDir = fs.existsSync(dir) ? fs.readdirSync(dir).find((d) => d.startsWith(`${chapter}-`)) : null; + if (!chapterDir) return []; + const found = fs.readdirSync(path.join(dir, chapterDir)).filter((f) => f.endsWith(`-${slug}.md`) || f.endsWith(`-${slug}.data.yml`)); + return found.map((f) => path.join(dir, chapterDir, f)); +}; + +// ---------- Every address ---------- + +const sitemap = []; +const add = (route, lastmod, priority) => sitemap.push({ url: address(route), lastmod, priority }); +let count = 0; + +// The site home +const home = site.site.home ? read(site.site.home) : null; +const homeTitle = `${siteName} · ${site.site.tagline}`; +const programList = `

Programs

`; +write( + '/', + page({ + route: '/', + title: homeTitle, + description: clip(site.site.description), + body: `${home ? render(home.tree, { data: home.data }) : `

${escape(site.site.tagline)}

`}${programList}`, + graph: [ + { '@type': 'WebSite', name: siteName, url: address('/'), description: site.site.description, inLanguage: 'en' }, + { ...organization, sameAs: site.site.repo ? [site.site.repo] : undefined }, + ], + }), +); +add('/', lastChanged([path.join(root, 'content/site')]), '1.0'); +count++; + +for (const entry of site.programs) { + if (!entry.manifest) continue; + const manifest = read(entry.manifest); + const program = manifest.program; + const programRoute = `/${program.id}`; + const course = { + '@type': 'Course', + '@id': `${address(programRoute)}#course`, + name: program.title, + description: program.summary, + url: address(programRoute), + provider: organization, + isAccessibleForFree: true, + inLanguage: 'en', + educationalLevel: 'Beginner to intermediate', + hasCourseInstance: { '@type': 'CourseInstance', courseMode: 'online', courseWorkload: `PT${Math.max(1, Math.round(manifest.chapters.reduce((s, c) => s + c.sections.reduce((m, x) => m + (x.minutes ?? 0), 0), 0) / 60))}H` }, + offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD', category: 'Free' }, + }; + const programCrumb = [ + { name: siteName, url: address('/') }, + { name: program.title, url: address(programRoute) }, + ]; + const chapters = manifest.chapters.filter((c) => c.status !== 'planned'); + + // Program landing + write( + programRoute, + page({ + route: programRoute, + title: `${program.title} · ${siteName}`, + description: clip(program.summary), + body: `

${escape(program.title)}

${escape(program.summary)}

Chapters

    ${chapters + .map((c) => `
  1. ${escape(c.title)}${c.goal ? `: ${escape(c.goal)}` : ''}
  2. `) + .join('')}
`, + graph: [course, breadcrumb(programCrumb)], + }), + ); + add(programRoute, lastChanged([path.join(root, 'content/programs', program.id)]), '0.9'); + count++; + + // Pages that only make sense with the learner's own data: reachable, but not indexed. + for (const extra of ['progress', 'platform']) { + write( + `${programRoute}/${extra}`, + page({ + route: `${programRoute}/${extra}`, + title: `${extra === 'progress' ? 'Your learning' : 'Reference'} · ${program.title}`, + description: clip(program.summary), + body: `

${escape(program.title)}

`, + index: false, + }), + ); + count++; + } + + for (const chapter of chapters) { + const chapterRoute = `${programRoute}/${chapter.id}`; + const chapterTitle = `Chapter ${chapter.number}: ${chapter.title}`; + const chapterCrumb = [...programCrumb, { name: chapterTitle, url: address(chapterRoute) }]; + write( + chapterRoute, + page({ + route: chapterRoute, + title: `${chapterTitle} · ${program.title}`, + description: clip(chapter.goal ?? program.summary), + body: `

${escape(chapterTitle)}

${chapter.goal ? `

${escape(chapter.goal)}

` : ''}${ + chapter.objectives?.length ? `

What you will learn

    ${chapter.objectives.map((o) => `
  • ${escape(o)}
  • `).join('')}
` : '' + }

Sections

    ${chapter.sections + .map((s) => `
  1. ${escape(s.title)}
  2. `) + .join('')}
`, + graph: [breadcrumb(chapterCrumb)], + }), + ); + add(chapterRoute, lastChanged(chapter.sections.flatMap((s) => sourceOf(program.id, `${chapter.id}/${s.slug}`))), '0.7'); + count++; + + for (const section of chapter.sections) { + const key = `${chapter.id}/${section.slug}`; + const file = manifest.pages[key]; + if (!file) continue; + const content = read(file); + const route = `${programRoute}/${key}`; + const lead = content.tree.find((n) => n.t === 'tag' && n.name === 'lead'); + const description = clip(lead ? text(lead.c) : (chapter.goal ?? program.summary)); + const title = `${content.title} · ${chapterTitle}`; + const kind = content.kind === 'lab' ? 'Exercise' : content.kind === 'quiz' ? 'Quiz' : content.kind === 'summary' ? 'Summary' : 'Lesson'; + write( + route, + page({ + route, + title: `${content.title} · ${program.title}`, + description, + type: 'article', + body: `

${escape(chapterTitle)}

${escape(content.title)}

${render(content.tree, { data: content.data, programId: program.id })}`, + graph: [ + { + '@type': 'LearningResource', + name: content.title, + headline: title, + description, + url: address(route), + learningResourceType: kind, + timeRequired: content.minutes ? `PT${content.minutes}M` : undefined, + isAccessibleForFree: true, + inLanguage: 'en', + isPartOf: { '@id': `${address(programRoute)}#course` }, + publisher: organization, + }, + breadcrumb([...chapterCrumb, { name: content.title, url: address(route) }]), + ], + }), + ); + add(route, lastChanged(sourceOf(program.id, key)), content.kind === 'lesson' ? '0.8' : '0.6'); + count++; + } + } +} + +// Addresses without a file: GitHub Pages serves 404.html, and the app shows its own "not found" page there. +fs.writeFileSync( + path.join(dist, '404.html'), + page({ route: '/', title: `Page not found · ${siteName}`, description: clip(site.site.description), body: '

Page not found

', index: false }).replace( + /]*>\n\s*/, + '', + ), +); + +fs.writeFileSync( + path.join(dist, 'sitemap.xml'), + `\n\n${sitemap + .map((u) => ` ${escape(u.url)}${u.lastmod}${u.priority}`) + .join('\n')}\n\n`, +); +fs.writeFileSync( + path.join(dist, 'robots.txt'), + [ + 'User-agent: *', + 'Allow: /', + // Starter files and schemas are for the lab tools, not for readers. + `Disallow: ${base}lab/`, + `Disallow: ${base}schema/`, + '', + `Sitemap: ${origin}${base}sitemap.xml`, + '', + ].join('\n'), +); +console.log(`prerendered ${count} pages; sitemap.xml lists ${sitemap.length} addresses (${origin}${base})`); diff --git a/tests/browser_checks.py b/tests/browser_checks.py index 37ea9f2..a4d6be1 100644 --- a/tests/browser_checks.py +++ b/tests/browser_checks.py @@ -14,6 +14,9 @@ from browser_server import preview_server from waiting import wait_until +# The app's own heading: the build also writes each page's text (with an

) for search engines, and the app replaces it. +APP_H1 = '.shell h1, #root > main:not(.prerendered) h1' + ROOT = Path(__file__).resolve().parents[1] PROGRAM = 'rhel9-ansible' BASE = os.environ.get('PLAYBOOK_TEST_URL', 'http://127.0.0.1:4173/') @@ -30,9 +33,11 @@ def site_with_content(origin, public_key=None): with tempfile.TemporaryDirectory() as temp: copy = Path(temp) / 'site' shutil.copytree(ROOT / 'dist', copy, ignore=shutil.ignore_patterns('content', 'lab')) - html = (copy / 'index.html').read_text() - assert 'connect-src 'self'' in html - (copy / 'index.html').write_text(html.replace('connect-src 'self'', 'connect-src 'self' ' + origin.rstrip('/'))) + assert 'connect-src 'self'' in (copy / 'index.html').read_text() + # Every page the build wrote carries the policy (404.html and the prerendered pages too). + for page_file in copy.rglob('*.html'): + html = page_file.read_text() + page_file.write_text(html.replace('connect-src 'self'', 'connect-src 'self' ' + origin.rstrip('/'))) config = {'contentBase': origin, **({'publicKey': public_key} if public_key else {})} (copy / 'kernel.config.json').write_text(json.dumps(config)) server = ThreadingHTTPServer(('127.0.0.1', 0), partial(QuietHandler, directory=str(copy))) @@ -46,7 +51,8 @@ def site_with_content(origin, public_key=None): def go(page, route, base=None): page.goto((base or BASE) + '#/' + PROGRAM + route) - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() + page.wait_for_load_state('networkidle') wait_until(page, "!document.querySelector('.skeleton')") assert not page.evaluate('window.__csp || []'), ('security policy violation', route, page.evaluate('window.__csp')) @@ -62,6 +68,14 @@ def engine_has_no_course_text(): class QuietHandler(SimpleHTTPRequestHandler): + """Serves like GitHub Pages: an address without a file gets 404.html, where the app takes over.""" + + def send_head(self): + target = Path(self.translate_path(self.path)) + if not target.exists() and not Path(self.path.split('?')[0]).suffix and (Path(self.directory) / '404.html').exists(): + self.path = '/404.html' + return super().send_head() + def log_message(self, *args): pass @@ -87,7 +101,7 @@ def content_from_another_origin(browser): requested = [] page.on('request', lambda r: requested.append(r.url)) page.goto(base + '#/' + PROGRAM + '/ch03/inventory') - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() assert page.locator('h1').first.inner_text().endswith('Building an Ansible inventory') wait_until(page, "document.querySelectorAll('pre.shiki').length > 3") assert any(url.startswith(origin + 'p/') for url in requested), 'pages came from the content origin' @@ -113,7 +127,7 @@ def slow_program_switch(browser, origin, base=None): held = [] page.route('**/p/second-program/manifest.*.json', lambda route: held.append(route)) page.goto(base + '#/rhel9-ansible/ch02/why-automate') - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() page.evaluate("location.hash = '#/second-program/ch01/hello'") for _ in range(100): if held: @@ -162,30 +176,30 @@ def programs_stay_separate(browser): page.evaluate("localStorage.setItem('rhce:rhel9-ansible@completed', JSON.stringify(['ch02/why-automate']))") # The second program opens beside it, with its own navigation and empty progress. page.goto(base + '#/second-program/ch01/hello'); page.reload() - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() assert page.locator('h1').first.inner_text().endswith('Saying hello') assert page.evaluate("JSON.parse(localStorage.getItem('rhce:second-program@completed') || '[]').length") == 0 page.get_by_role('button', name='Mark this section complete').click() assert page.evaluate("JSON.parse(localStorage.getItem('rhce:second-program@completed'))") == ['ch01/hello'] assert page.evaluate("JSON.parse(localStorage.getItem('rhce:rhel9-ansible@completed'))") == ['ch02/why-automate'] page.get_by_role('link', name='Looking around').first.click() - page.wait_for_url('**/#/second-program/ch01/next') + page.wait_for_url('**/second-program/ch01/next') assert not page.get_by_role('link', name='Building an Ansible inventory').count(), 'only this program is listed' # The program selector lists both programs and switches between them. page.get_by_role('button', name='Program: Linux basics (test fixture). Switch program').click() panel = page.get_by_role('dialog', name='Programs') assert panel.get_by_role('link', name='Ansible automation on RHEL 9').count() == 1 panel.get_by_role('link', name='Ansible automation on RHEL 9').click() - page.wait_for_url('**/#/rhel9-ansible') + page.wait_for_url('**/rhel9-ansible') page.get_by_role('button', name='Program: Ansible automation on RHEL 9. Switch program').click() page.get_by_role('dialog', name='Programs').get_by_role('link', name='Linux basics (test fixture)').click() - page.wait_for_url('**/#/second-program') + page.wait_for_url('**/second-program') page.get_by_role('heading', name='Linux basics (test fixture)', level=1).wait_for() - page.goto(base + '#/second-program/ch01/next'); page.locator('h1').first.wait_for() + page.goto(base + '#/second-program/ch01/next'); page.locator(APP_H1).first.wait_for() page.goto(base + '#/second-program/progress') page.get_by_role('heading', name='Your learning', level=1).wait_for() assert '0 of 2 sections complete' in page.locator('.reference-intro').inner_text() or '1 of 2 sections complete' in page.locator('.reference-intro').inner_text() - page.goto(base + '#/second-program/ch01/next'); page.locator('h1').first.wait_for() + page.goto(base + '#/second-program/ch01/next'); page.locator(APP_H1).first.wait_for() # Search covers this program only. page.get_by_role('button', name='Search the course').click() dialog = page.get_by_role('dialog', name='Search', exact=True); dialog.wait_for() @@ -203,7 +217,7 @@ def programs_stay_separate(browser): page.keyboard.press('Escape') # Addresses from before programs existed still work; unknown programs are a 404. page.goto(base + '#/ch03/inventory') - page.wait_for_url('**/#/rhel9-ansible/ch03/inventory') + page.wait_for_url('**/rhel9-ansible/ch03/inventory') page.goto(base + '#/no-such-program/ch01/x') page.get_by_role('heading', name="That page isn't here").wait_for() assert page.locator('.site-footer').count() == 1, 'the 404 page has the site frame' @@ -228,8 +242,8 @@ def planned_program_is_listed(browser): assert card.count() == 1, entry['id'] assert ('Growing' in card.inner_text()) == (entry['status'] == 'planned') card.click() - page.wait_for_url('**/#/' + entry['id']) - page.locator('h1').first.wait_for() + page.wait_for_url('**/' + entry['id']) + page.locator(APP_H1).first.wait_for() assert not page.locator('.load-error').count(), entry['id'] expected = sum(c['status'] == 'planned' or not c['sections'] for c in manifest['chapters']) wait_until(page, "document.querySelectorAll('.chapter-card').length === " + str(len(manifest['chapters']))) @@ -238,7 +252,7 @@ def planned_program_is_listed(browser): if authored: section = authored['sections'][0] page.goto(BASE + '#/' + entry['id'] + '/' + authored['id'] + '/' + section['slug']) - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() assert section['title'] in page.locator('h1').first.inner_text() context.close() @@ -266,7 +280,7 @@ def open_with(configured_key, source_origin=origin): page = context.new_page() page.goto(base + '#/' + PROGRAM + '/ch03/inventory') try: - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() return page.locator('h1').first.inner_text(), context except Exception: return None, context @@ -386,6 +400,7 @@ def fulfill(route): assert selected_command == command.text_content(), 'Soft wrapping must preserve copied commands' table = page.locator('.table-wrap').nth(1) row = table.locator('tbody tr').filter(has_text="VMs can't reach the internet") + page.wait_for_timeout(1500) # let the jump to #general-problems settle before scrolling the row row.evaluate("(e) => e.scrollIntoView({block: 'start', behavior: 'instant'})") pinned_header = table.locator('thead th').first.bounding_box() assert abs(pinned_header['y'] - page.locator('.header').bounding_box()['height']) <= 1 @@ -398,7 +413,7 @@ def fulfill(route): page.get_by_role('button', name='Program: Ansible automation on RHEL 9. Switch program').click() page.get_by_role('link', name='RHEL 9: Platform and versions', exact=True).click() page.get_by_role('heading', name='RHEL 9: Platform and versions', exact=True).wait_for() - assert page.url.endswith('#/rhel9-ansible/platform') + assert page.url.endswith('/rhel9-ansible/platform') assert not page.get_by_role('dialog').count() assert page.title() == 'RHEL 9: Platform and versions · Kernel Path' page.get_by_role('tab', name='Home lab', exact=True).click() diff --git a/tests/mobile_checks.py b/tests/mobile_checks.py index 0ceeb5c..7416cfd 100644 --- a/tests/mobile_checks.py +++ b/tests/mobile_checks.py @@ -10,6 +10,9 @@ from browser_server import preview_server from waiting import wait_until +# The app's own heading: the build also writes each page's text (with an

) for search engines, and the app replaces it. +APP_H1 = '.shell h1, #root > main:not(.prerendered) h1' + PROGRAM = 'rhel9-ansible' BASE = os.environ.get('KERNEL_TEST_URL', os.environ.get('PLAYBOOK_TEST_URL', 'http://127.0.0.1:4173/')) ROOT = Path(__file__).resolve().parents[1] @@ -17,7 +20,8 @@ def go(page, route): page.goto(BASE + '#/' + PROGRAM + route) - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() + page.wait_for_load_state('networkidle') wait_until(page, "!document.querySelector('.skeleton, .prose .widget[role=status]')") assert not page.locator('.load-error').count(), route assert 'This activity could not load' not in page.inner_text('body'), route diff --git a/tests/snapshots.py b/tests/snapshots.py index e2985ee..b7914cd 100644 --- a/tests/snapshots.py +++ b/tests/snapshots.py @@ -39,6 +39,9 @@ def capture(out): from browser_server import preview_server from waiting import wait_until +# The app's own heading: the build also writes each page's text (with an

) for search engines, and the app replaces it. +APP_H1 = '.shell h1, #root > main:not(.prerendered) h1' + routes = json.loads((ROOT / 'node_modules/.cache/kernel-path/routes.json').read_text()) snapshots = {} with preview_server(BASE, ROOT): @@ -47,7 +50,7 @@ def capture(out): page = browser.new_context(viewport={'width': 1280, 'height': 900}, reduced_motion='reduce').new_page() for route in routes: page.goto(BASE + '#/' + PROGRAM + route) - page.locator('h1').first.wait_for() + page.locator(APP_H1).first.wait_for() wait_until(page, "!document.querySelector('.skeleton, .prose .widget[role=status]')") height = page.evaluate('document.body.scrollHeight') for y in range(0, height, 900):