diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae25075c..ecb3720b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,11 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + # Repo scripts kill node processes and prompt unless they run headless. + HELIO_HEADLESS: "1" + NUXT_TELEMETRY_DISABLED: "1" + jobs: validate: name: Run ${{ matrix.command }} @@ -36,7 +41,7 @@ jobs: run: yarn ${{ matrix.command }} test-builds: - name: Test Builds + name: Build and test ${{ matrix.app.name }} runs-on: ubuntu-latest strategy: @@ -44,8 +49,23 @@ jobs: matrix: app: - name: docs - command: "yarn run nuxt:generate" - path: "./apps/docs" + path: ./apps/docs + build: yarn run nuxt:generate + artifact-test: tests/artifacts/normal.test.ts + e2e: true + - name: docs (static) + path: ./apps/docs + build: DOCS_ENV_STATIC=1 yarn run nuxt:generate + artifact-test: tests/artifacts/static.test.ts + e2e: false + - name: learn + path: ./apps/learn + build: yarn run nuxt:build + e2e: true + - name: nettango + path: ./apps/nettango + build: yarn run nuxt:build + e2e: true steps: - name: Checkout code @@ -61,6 +81,22 @@ jobs: run: | yarn run init - - name: Build ${{ matrix.app.name }} + - name: Build + working-directory: ${{ matrix.app.path }} + run: ${{ matrix.app.build }} + + - name: Test build output + if: ${{ matrix.app.artifact-test }} + working-directory: ${{ matrix.app.path }} + run: yarn run test:artifacts ${{ matrix.app.artifact-test }} + + - name: Install Chromium + if: ${{ matrix.app.e2e }} + run: npx playwright-core install --with-deps chromium + + - name: Smoke test against the built site + if: ${{ matrix.app.e2e }} working-directory: ${{ matrix.app.path }} - run: ${{ matrix.app.command }} + # serve-static resolves pages like the production host (nuxt preview 404s on same-named directories), + # runs the tests once it is listening, and exits with their status. + run: node ../../packages/vitest-config/src/serve-static.mjs .output/public 4100 -- yarn run test:e2e diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..38131b04 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,131 @@ +name: Nightly + +# Slow checks that are too heavy for every push: the PDF manual and a smoke test of every route in each sitemap. +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +env: + HELIO_HEADLESS: "1" + NUXT_TELEMETRY_DISABLED: "1" + +permissions: + actions: read + contents: read + +jobs: + changes: + name: Check for new commits since the last nightly + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.compare.outputs.changed }} + steps: + - name: Compare HEAD with the last successful nightly + id: compare + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + HEAD_SHA: ${{ github.sha }} + REPO: ${{ github.repository }} + run: | + last=$(gh run list --repo "$REPO" --workflow nightly.yml --status success --limit 1 --json headSha --jq ".[0].headSha // empty") + echo "Last successful nightly: ${last:-none}, HEAD: $HEAD_SHA" + if [ "$EVENT" = "workflow_dispatch" ] || [ "$last" != "$HEAD_SHA" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No new commits since the last successful nightly; skipping." >> "$GITHUB_STEP_SUMMARY" + fi + + pdf-manual: + name: Build and test the PDF manual + needs: changes + if: ${{ needs.changes.outputs.changed == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.22.1' + cache: "yarn" + + - name: Initialize Repository + run: | + yarn run init + + - name: Build the PDF manual + working-directory: ./apps/docs + env: + PORT: "3099" + run: yarn run docs:build-and-generate-manual + + - name: Test the PDF manual + working-directory: ./apps/docs + run: yarn run test:artifacts tests/artifacts/pdf.test.ts + + - name: Upload the PDF manual + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: NetLogo_User_Manual + path: apps/docs/.build/NetLogo_User_Manual.pdf + if-no-files-found: error + + - name: Summarize the artifact + if: ${{ always() }} + working-directory: ./apps/docs + run: | + echo "### PDF manual" >> "$GITHUB_STEP_SUMMARY" + ls -la .build/NetLogo_User_Manual.pdf >> "$GITHUB_STEP_SUMMARY" 2>&1 || echo "PDF missing" >> "$GITHUB_STEP_SUMMARY" + + all-routes: + name: Smoke test every route of ${{ matrix.app.name }} + needs: changes + if: ${{ needs.changes.outputs.changed == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + app: + - name: docs + path: ./apps/docs + build: yarn run nuxt:generate + - name: learn + path: ./apps/learn + build: yarn run nuxt:build + - name: nettango + path: ./apps/nettango + build: yarn run nuxt:build + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.22.1' + cache: "yarn" + + - name: Initialize Repository + run: | + yarn run init + + - name: Build + working-directory: ${{ matrix.app.path }} + run: ${{ matrix.app.build }} + + - name: Install Chromium + run: npx playwright-core install --with-deps chromium + + - name: Smoke test every sitemap route + working-directory: ${{ matrix.app.path }} + env: + SMOKE_ALL_ROUTES: "1" + # serve-static resolves pages like the production host (nuxt preview 404s on same-named directories), + # runs the tests once it is listening, and exits with their status. + run: node ../../packages/vitest-config/src/serve-static.mjs .output/public 4100 -- yarn run test:e2e diff --git a/apps/docs/app/components/Search/Search.vue b/apps/docs/app/components/Search/Search.vue index b4b72de8..479cdc78 100644 --- a/apps/docs/app/components/Search/Search.vue +++ b/apps/docs/app/components/Search/Search.vue @@ -26,6 +26,8 @@ const { data: files } = await useLazyAsyncData( ); const fuseOptions: IFuseOptions = { + // @nuxt/ui 4.12 turns on token search by default, which matches a page if any single word matches. + useTokenSearch: false, ignoreDiacritics: true, shouldSort: true, threshold: 0, diff --git a/apps/docs/app/error.vue b/apps/docs/app/error.vue index db2ba018..eb62122d 100644 --- a/apps/docs/app/error.vue +++ b/apps/docs/app/error.vue @@ -11,6 +11,6 @@ defineProps({ diff --git a/apps/docs/app/pages/[...slug].vue b/apps/docs/app/pages/[...slug].vue index 9475f367..5f280091 100644 --- a/apps/docs/app/pages/[...slug].vue +++ b/apps/docs/app/pages/[...slug].vue @@ -49,7 +49,7 @@ const layoutComponent = computed(() => { } }); -defineOgImageComponent('DocsSeo', { +defineOgImage('DocsSeo', { title, description, theme: '#f31500', @@ -66,8 +66,8 @@ useSeoMeta({ ogType: 'article', title: `${title}`, description: description, - keywords: keywords, }); +useHead({ meta: [{ name: 'keywords', content: keywords }] }); function isPageContent(item: ContentCollectionItem | null | undefined): item is ContentCollectionItem { return item !== null && item !== undefined; @@ -103,7 +103,7 @@ function resolveMeta(page: ContentCollectionItem): ResolvedMeta { const title = [meta.title, seo.title, 'NetLogo Docs'].filter(Boolean).at(0)!; const description = [meta.description, seo.description, 'NetLogo Documentation'].filter(Boolean).at(0)!; - const keywords = [meta.keywords, seo.keywords, 'NetLogo', 'Documentation'].filter(Boolean).flat().join(', '); + const keywords = [...new Set([meta.keywords, seo.keywords, 'NetLogo', 'Documentation'].filter(Boolean).flat())].join(', '); const layout = meta.layout ?? 'default'; @@ -115,10 +115,10 @@ function resolveMeta(page: ContentCollectionItem): ResolvedMeta { diff --git a/apps/docs/app/pages/index.vue b/apps/docs/app/pages/index.vue index 66c22cf6..de154155 100644 --- a/apps/docs/app/pages/index.vue +++ b/apps/docs/app/pages/index.vue @@ -82,10 +82,10 @@ const beginnersLinks = ref(beginnersLinksData); useSeoMeta({ title: productName, description: productDescription, - keywords: productKeywords.join(', '), }); +useHead({ meta: [{ name: 'keywords', content: productKeywords.join(', ') }] }); -defineOgImageComponent('DocsSeo', { +defineOgImage('DocsSeo', { title: productName, theme: '#f31500', siteLogo: '/turtles.png', diff --git a/apps/docs/nuxt.config.static.ts b/apps/docs/nuxt.config.static.ts index 988851c0..2805a9d8 100644 --- a/apps/docs/nuxt.config.static.ts +++ b/apps/docs/nuxt.config.static.ts @@ -45,6 +45,11 @@ const staticOverrides: Parameters[0] = { }, nitro: { + // The static link rewrite turns hrefs into relative .html paths, which the crawler does not follow, + // so pages reachable only through such a link have to be listed. + prerender: { + routes: ['/es/behaviorspace'], + }, hooks: { async close() { const cwd = '.output/public' diff --git a/apps/docs/package.json b/apps/docs/package.json index 8ec9b640..4c639e86 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -31,14 +31,16 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "node-version": "node -v && npm -v", - "turbo:postinstall": "nuxt prepare" + "turbo:postinstall": "nuxt prepare", + "test:e2e": "vitest run --project e2e", + "test:artifacts": "vitest run --project artifacts" }, "dependencies": { - "@nuxt/content": "^3.7.1", + "@nuxt/content": "^3.16.2", "@nuxt/eslint": "1.10.0", - "@nuxt/icon": "2.0.0", - "@nuxt/ui": "4.2.1", - "@nuxtjs/sitemap": "7.4.7", + "@nuxt/icon": "2.5.1", + "@nuxt/ui": "^4.12.0", + "@nuxtjs/sitemap": "9.1.0", "@repo/eslint-config": "0.0.0", "@repo/netlogo-docs": "0.0.0", "@repo/nuxt-core": "0.0.0", @@ -47,25 +49,25 @@ "@repo/typescript-config": "0.0.0", "@repo/utils": "0.0.0", "@repo/vue-ui": "1.0.0", - "@tailwindcss/postcss": "^4.1.13", - "@tailwindcss/vite": "^4.1.18", - "@vueuse/core": "^14.1.0", - "better-sqlite3": "^12.4.1", + "@tailwindcss/postcss": "^4.3.3", + "@tailwindcss/vite": "^4.3.3", + "@vueuse/core": "^14.4.0", + "better-sqlite3": "^12.11.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint": "^9.31.0", "minimatch": "^10.0.3", - "nuxt": "^4.1.2", - "nuxt-gtag": "4.1.0", - "nuxt-link-checker": "4.3.6", - "nuxt-og-image": "^5.1.12", - "nuxt-svgo": "4.2.6", - "reka-ui": "^2.6.0", + "nuxt": "4.6.2", + "nuxt-gtag": "5.0.0", + "nuxt-link-checker": "6.0.1", + "nuxt-og-image": "^7.0.7", + "nuxt-svgo": "5.3.0", + "reka-ui": "2.11.0", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "vue": "^3.5.24", - "vue-router": "^4.6.3", + "vue": "3.5.44", + "vue-router": "^5.4.0", "yaml": "^2.8.1" }, "devDependencies": { @@ -82,24 +84,27 @@ "@iconify-json/si": "^1.2.11", "@iconify-json/simple-icons": "^1.2.54", "@iconify-json/streamline": "^1.2.5", - "@nuxt/kit": "^4.2.1", + "@nuxt/kit": "4.6.2", "@repo/markdown": "0.0.0", "@repo/nuxt-content-assets": "1.5.0", "@types/html-minifier-terser": "^7.0.2", "eslint-plugin-perfectionist": "^4.15.1", "extend": "^3.0.2", "fs-extra": "^11.3.0", - "fuse.js": "^7.1.0", + "fuse.js": "^7.5.0", "html-minifier-terser": "^7.2.0", "mupdf": "^1.27.0", - "pdf-creator-node": "^2.3.5", - "puppeteer": "^24.28.0", + "puppeteer": "^25.16.0", "rehype-autolink-headings": "^7.1.0", "remark-toc": "^9.0.0", "sass": "^1.93.3", - "sharp": "^0.34.4", + "sharp": "^0.35.5", "turbo-meta-utilities": "^1.0.1", "typescript": "^5.4.0", - "zod": "^4.1.12" + "zod": "^4.1.12", + "@nuxt/test-utils": "^4.3.4", + "@repo/vitest-config": "0.0.0", + "playwright-core": "^1.60.0", + "vitest": "^4.0.16" } } diff --git a/apps/docs/runtime.config.ts b/apps/docs/runtime.config.ts index d0649336..8fc9b47d 100644 --- a/apps/docs/runtime.config.ts +++ b/apps/docs/runtime.config.ts @@ -9,9 +9,9 @@ const extraWebsiteConfig = z versionsSrc: data.VERSIONS_SRC, })); +// Only the docs-specific fields: nuxt-core already sets the shared ones, and layer merging concatenates arrays. const websiteConfigSchema = rootWebsiteConfigSchema .transform((data) => ({ - ...data, productIsBeta: data.productVersion.toLowerCase().includes('beta'), })) .and(extraWebsiteConfig); diff --git a/apps/docs/scripts/generate-manual/index.cjs b/apps/docs/scripts/generate-manual/index.cjs index be90366b..adc9a8f8 100644 --- a/apps/docs/scripts/generate-manual/index.cjs +++ b/apps/docs/scripts/generate-manual/index.cjs @@ -240,9 +240,10 @@ async function main() { fs.writeFileSync(filePath, fileContent); const fileBaseName = path.basename(file); - const fileUrl = 'http://' + serverHost + ':' + serverPort + '/' + urlPrefix + fileBaseName; - console.log(`Handling: ${fileBaseName} (=> ${fileUrl})`); - await myPage.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 120000 }); + console.log(`Handling: ${fileBaseName}`); + // Parse the rewritten HTML directly instead of fetching it back from the preview server: the server resolves + // a chapter such as view2.5d to its same-named directory listing, which left that chapter out of the TOC. + await myPage.setContent(fileContent, { waitUntil: 'domcontentloaded', timeout: 120000 }); /** * Extract the table of contents entries from the page. diff --git a/apps/docs/scripts/generate-manual/pdf-content b/apps/docs/scripts/generate-manual/pdf-content index a6910c6a..e4eca01d 100755 --- a/apps/docs/scripts/generate-manual/pdf-content +++ b/apps/docs/scripts/generate-manual/pdf-content @@ -36,9 +36,6 @@ files=( "hpc" "extensions" "extension-authoring" - "colorpicker" - "netlogo7intro" - "netlogopreferences" ) for ext in $extensions; do files+=("$ext") diff --git a/apps/docs/scripts/generate-manual/run.sh b/apps/docs/scripts/generate-manual/run.sh index 78c1e73e..db9d10dc 100755 --- a/apps/docs/scripts/generate-manual/run.sh +++ b/apps/docs/scripts/generate-manual/run.sh @@ -50,7 +50,7 @@ title=$(cat < - - Title image -

The NetLogo ${PRODUCT_VERSION} User Manual

+ + +
+ Title image +

The NetLogo ${PRODUCT_VERSION} User Manual

+
EOF ) diff --git a/apps/docs/shared/versions.ts b/apps/docs/shared/versions.ts index 7abcacfe..e2033d3a 100644 --- a/apps/docs/shared/versions.ts +++ b/apps/docs/shared/versions.ts @@ -26,6 +26,8 @@ const pullVersionsFromSource = async ( ): Promise> => { try { const data = await $fetch>(url, { "baseURL": "/" }); + // Local dev has no versions.json, and the catch-all page answers the request with HTML + if (!Array.isArray(data)) return currentVersions; const normalizeLiveVersionsToProps = (version: LiveVersionData): VersionProps => ({ displayName: version.name, diff --git a/apps/docs/tests/artifacts/normal.test.ts b/apps/docs/tests/artifacts/normal.test.ts new file mode 100644 index 00000000..765e5ec6 --- /dev/null +++ b/apps/docs/tests/artifacts/normal.test.ts @@ -0,0 +1,55 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +// Run after `yarn nuxt:generate` (the regular hosted site). +const OUT = process.env["DOCS_OUTPUT_DIR"] ?? ".output/public"; +const KEY_PAGES = ["index.html", "matrix.html", "dictionary.html", "programming.html"]; + +function htmlFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf8" }) + .filter((f) => f.endsWith(".html")) + .map((f) => join(dir, f)); +} + +describe("docs build", () => { + let pages: { file: string; html: string }[]; + + beforeAll(() => { + expect(existsSync(OUT), `${OUT} missing: run yarn nuxt:generate first`).toBe(true); + // Guard against testing the static build by mistake, which strips all scripts. + expect(readdirSync(join(OUT, "_nuxt")).some((f) => f.endsWith(".js")), `${OUT} looks like a static build`).toBe(true); + pages = htmlFiles(OUT).map((file) => ({ file, html: readFileSync(file, "utf8") })); + }); + + // Catches prerendering that silently drops pages (a crawl that stops early, a route that fails to render). + it("prerenders the key pages and the sitemap", () => { + for (const page of KEY_PAGES) expect(existsSync(join(OUT, page)), page).toBe(true); + expect(existsSync(join(OUT, "sitemap.xml"))).toBe(true); + expect(pages.length).toBeGreaterThan(100); + }); + + // Catches keywords duplicated by layered runtime config, on any page. + it("emits at most one keywords meta per page, without repeated keywords", () => { + const bad = pages.flatMap(({ file, html }) => { + const metas = [...html.matchAll(/ m[1] ?? ""); + const terms = (metas[0] ?? "").split(",").map((k) => k.trim()).filter(Boolean); + return metas.length > 1 || new Set(terms).size !== terms.length ? [relative(OUT, file)] : []; + }); + expect(bad.slice(0, 20)).toEqual([]); + }); + + // Catches og:image references that point at images the build never rendered. + it("renders the og:image each key page references", () => { + for (const page of KEY_PAGES) { + const html = readFileSync(join(OUT, page), "utf8"); + const og = / ${new URL(og!).pathname}`).toBe(true); + // JPEG files start with FF D8, PNG with 89 50. + const magic = readFileSync(file).subarray(0, 2).toString("hex"); + expect(["ffd8", "8950"]).toContain(magic); + } + }); +}); diff --git a/apps/docs/tests/artifacts/pdf.test.ts b/apps/docs/tests/artifacts/pdf.test.ts new file mode 100644 index 00000000..da447863 --- /dev/null +++ b/apps/docs/tests/artifacts/pdf.test.ts @@ -0,0 +1,107 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import * as mupdf from "mupdf"; +import { beforeAll, describe, expect, it } from "vitest"; + +// Run after `yarn docs:build-and-generate-manual`. Paths can be overridden for CI layouts. +const version = /^PRODUCT_VERSION="?([^"\n]+)"?/m.exec(readFileSync(".env", "utf8"))?.[1] ?? ""; +const PDF_PATH = process.env["DOCS_PDF"] ?? ".build/NetLogo_User_Manual.pdf"; +const HTML_DIR = process.env["DOCS_BUILD_DIR"] ?? `.build/${version}`; + +type OutlineItem = { title?: string; page?: number; down?: OutlineItem[] }; + +// The chapter list is whatever pdf-content says, so the test follows changes to the manual's contents. +function manualChapters(): string[] { + const out = execFileSync("bash", ["-c", 'source scripts/generate-manual/pdf-content >/dev/null; printf "%s\\n" "${files[@]}"'], { + encoding: "utf8", + }); + return out.split("\n").filter((f) => f && f !== "title"); +} + +function chapterTitle(file: string): string { + const html = readFileSync(`${HTML_DIR}/${file}.html`, "utf8"); + const h1 = /]*>([\s\S]*?)<\/h1>/.exec(html)?.[1] ?? ""; + return h1.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/'|'/g, "'").replace(/\s+/g, " ").trim(); +} + +const normalize = (s: string) => s.replace(/\s+/g, " ").trim(); + +describe("PDF manual", () => { + let doc: mupdf.Document; + let chapters: { file: string; title: string }[]; + let tocText: string; + let outlineTop: string[]; + + beforeAll(() => { + expect(existsSync(PDF_PATH), `${PDF_PATH} missing: run yarn docs:build-and-generate-manual first`).toBe(true); + doc = mupdf.Document.openDocument(readFileSync(PDF_PATH), "application/pdf"); + chapters = manualChapters().map((file) => ({ file, title: chapterTitle(file) })); + const outline = (doc.loadOutline() ?? []) as OutlineItem[]; + outlineTop = outline.map((o) => normalize(o.title ?? "")); + // The outline gives the table of contents page range: from its own bookmark to the next one. + const tocIndex = outlineTop.indexOf("Table of Contents"); + const start = outline[tocIndex]?.page ?? 1; + const end = outline[tocIndex + 1]?.page ?? start + 1; + const pages: string[] = []; + for (let i = start; i < end; i++) pages.push(doc.loadPage(i).toStructuredText().asText()); + tocText = normalize(pages.join(" ")); + }); + + // Catches an empty or truncated artifact (a renderer failure that still writes a file). + it("is a substantial PDF with a page per chapter at least", () => { + expect(statSync(PDF_PATH).size).toBeGreaterThan(1_000_000); + expect(doc.countPages()).toBeGreaterThan(chapters.length); + }); + + // Catches a stale or mislabeled artifact: the title page must carry the version being built. + it("opens with the title page for this version", () => { + expect(normalize(doc.loadPage(0).toStructuredText().asText())).toContain(`NetLogo ${version} User Manual`); + }); + + // Catches chapters dropped from the generated table of contents. + it("lists every chapter in the table of contents", () => { + expect(tocText).toContain("Table of Contents"); + const missing = chapters.filter((c) => !tocText.includes(c.title)).map((c) => c.file); + expect(missing).toEqual([]); + }); + + // Catches table of contents entries that do not jump anywhere (named destinations Chrome dropped while printing). + it("links every table of contents entry to a page", () => { + const tocIndex = outlineTop.indexOf("Table of Contents"); + const outline = (doc.loadOutline() ?? []) as OutlineItem[]; + const start = outline[tocIndex]?.page ?? 1; + const end = outline[tocIndex + 1]?.page ?? start + 1; + const unlinked: string[] = []; + for (let i = start; i < end; i++) { + const page = doc.loadPage(i); + const links = page.getLinks(); + const text = JSON.parse(page.toStructuredText().asJSON()) as { + blocks?: { lines?: { text: string; bbox: { x: number; y: number; w: number; h: number } }[] }[]; + }; + for (const line of (text.blocks ?? []).flatMap((b) => b.lines ?? [])) { + if (!/^\d+(\.\d+)*\.?\s/.test(line.text)) continue; + const cy = line.bbox.y + line.bbox.h / 2; + const link = links.find((l) => { + const [x0, y0, x1, y1] = l.getBounds(); + return cy >= y0 - 1 && cy <= y1 + 1 && line.bbox.x < x1 && line.bbox.x + line.bbox.w > x0; + }); + const target = link ? doc.resolveLink(link) : -1; + if (target < 0) unlinked.push(line.text); + } + } + expect(unlinked).toEqual([]); + }); + + // Catches chapters missing from the PDF bookmarks, which readers use to navigate. + it("has a top-level bookmark for every chapter", () => { + const missing = chapters.filter((c) => !outlineTop.includes(c.title)).map((c) => `${c.file} (${c.title})`); + expect(missing).toEqual([]); + }); + + // Catches the same chapter being bundled twice. + it("includes each chapter once", () => { + const counts = new Map(); + for (const title of outlineTop) counts.set(title, (counts.get(title) ?? 0) + 1); + expect([...counts].filter(([, n]) => n > 1).map(([t]) => t)).toEqual([]); + }); +}); diff --git a/apps/docs/tests/artifacts/static.test.ts b/apps/docs/tests/artifacts/static.test.ts new file mode 100644 index 00000000..edfd0452 --- /dev/null +++ b/apps/docs/tests/artifacts/static.test.ts @@ -0,0 +1,57 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +// Run after `DOCS_ENV_STATIC=1 yarn nuxt:generate` (the offline, file:// bundle). +const OUT = process.env["DOCS_OUTPUT_DIR"] ?? ".output/public"; + +function htmlFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf8" }) + .filter((f) => f.endsWith(".html")) + .map((f) => join(dir, f)); +} + +describe("static (offline) docs build", () => { + let pages: { file: string; html: string }[]; + + beforeAll(() => { + expect(existsSync(OUT), `${OUT} missing: run DOCS_ENV_STATIC=1 yarn nuxt:generate first`).toBe(true); + // Guard against testing the normal build by mistake: the static build strips scripts and extracts the UI colors. + expect(existsSync(join(OUT, "_nuxt/ui-colors.css")), `${OUT} is not a static build`).toBe(true); + pages = htmlFiles(OUT).map((file) => ({ file, html: readFileSync(file, "utf8") })); + }); + + // Catches links that only work on a web server: under file:// a leading slash points at the filesystem root. + it("has no root-absolute src or href", () => { + const offenders = pages + .flatMap(({ file, html }) => [...html.matchAll(/\b(?:src|href)="(\/(?!\/)[^"]*)"/g)].map((m) => `${relative(OUT, file)}: ${m[1]}`)) + .slice(0, 20); + expect(offenders).toEqual([]); + }); + + // Catches the $CDN placeholder leaking into output when the rewrite hook misses an attribute. + it("has no unresolved $CDN placeholders", () => { + expect(pages.filter(({ html }) => html.includes("$CDN")).map(({ file }) => relative(OUT, file))).toEqual([]); + }); + + // Catches broken offline navigation: every relative link to a page must resolve to a file next to it. + // The offline bundle leaves out the extension catalog pages (/dictionary) on purpose, so links to them are expected. + it("has no relative page links that point at missing files", () => { + const broken: string[] = []; + for (const { file, html } of pages) { + for (const m of html.matchAll(/\bhref="((?!https?:|mailto:|#|\/\/)[^"#?]+\.html)/g)) { + if (/(^|\/)[^/]+\/dictionary\.html$/.test(m[1] ?? "")) continue; + const target = join(dirname(file), m[1] ?? ""); + if (!existsSync(target)) broken.push(`${relative(OUT, file)} -> ${m[1]}`); + } + } + expect(broken.slice(0, 20)).toEqual([]); + }); + + // Catches the offline bundle shipping JavaScript or the content database, which it is configured to drop. + it("ships no scripts or content database", () => { + const scripts = readdirSync(join(OUT, "_nuxt")).filter((f) => f.endsWith(".js")); + expect(scripts).toEqual([]); + expect(existsSync(join(OUT, "__nuxt_content"))).toBe(false); + }); +}); diff --git a/apps/docs/tests/e2e/content-rendering.test.ts b/apps/docs/tests/e2e/content-rendering.test.ts new file mode 100644 index 00000000..bb2e6285 --- /dev/null +++ b/apps/docs/tests/e2e/content-rendering.test.ts @@ -0,0 +1,19 @@ +import { createPage } from "@nuxt/test-utils/e2e"; +import { e2eSetup } from "@repo/vitest-config/helpers/smoke"; +import { describe, expect, it } from "vitest"; + +const HOST = process.env["E2E_HOST"] ?? "http://localhost:3001"; + +// Catches markdown being rendered by another library's Prose components (@nuxt/ui 4.12 replaced the plain mdc ones), +// which restyled every content page because the docs styles target this markup. +describe("content rendering", async () => { + await e2eSetup(HOST); + + it("renders inline code as plain elements", async () => { + const page = await createPage("/matrix"); + const styledInlineCode = await page.locator(".prose p code[class]:not(.netlogo-command)").count(); + expect(await page.locator(".prose p code").count()).toBeGreaterThan(0); + expect(styledInlineCode).toBe(0); + await page.close(); + }); +}); diff --git a/apps/docs/tests/e2e/smoke.test.ts b/apps/docs/tests/e2e/smoke.test.ts new file mode 100644 index 00000000..59bc6604 --- /dev/null +++ b/apps/docs/tests/e2e/smoke.test.ts @@ -0,0 +1,17 @@ +import { defineSmokeSuite } from "@repo/vitest-config/helpers/smoke"; +import { describe } from "vitest"; + +const HOST = process.env["E2E_HOST"] ?? "http://localhost:3001"; + +describe("smoke: docs", async () => { + await defineSmokeSuite( + HOST, + ["/", "/matrix", "/arduino", "/programming", "/dictionary", "/versions"], + // versions.json and announcements.json are published with the deployed site, so they are missing on dev and + // preview servers, and the version selector logs its handled fetch failure. + { + allowedFailedRequests: [/\/versions\.json$/, /\/announcements\.json$/], + allowedConsoleErrors: [/^Error fetching versions:/], + }, + ); +}); diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts new file mode 100644 index 00000000..4f5de231 --- /dev/null +++ b/apps/docs/vitest.config.ts @@ -0,0 +1,8 @@ +import { nuxtArtifactsVitestProject, nuxtE2EVitestProject } from "@repo/vitest-config/nuxt"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [await nuxtE2EVitestProject(), nuxtArtifactsVitestProject()], + }, +}); diff --git a/apps/learn/app/app.vue b/apps/learn/app/app.vue index a3a2eeda..62fce8ee 100644 --- a/apps/learn/app/app.vue +++ b/apps/learn/app/app.vue @@ -18,6 +18,8 @@ resultLimit: 42, fuseOptions: { threshold: 0, + // @nuxt/ui 4.12 turns on token search by default, which matches a page if any single word matches. + useTokenSearch: false, }, }" > @@ -65,17 +67,8 @@ useHead({ useLazyAsyncData( 'search', - () => { - return Promise.all([ - queryCollectionSearchSections('content'), - queryCollectionSearchSections('learningPaths'), - queryCollectionSearchSections('primitives'), - ]); - }, - { - server: false, - transform: (data) => data.flat(), - }, + () => queryCollectionSearchSections('content'), + { server: false }, ); if (import.meta.server) { diff --git a/apps/learn/app/components/Articles/ArticleBody.vue b/apps/learn/app/components/Articles/ArticleBody.vue index 88c70d0c..6968aed4 100644 --- a/apps/learn/app/components/Articles/ArticleBody.vue +++ b/apps/learn/app/components/Articles/ArticleBody.vue @@ -2,6 +2,7 @@
- +