diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6f5479647b..bc38751e0d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -493,11 +493,16 @@ The cross-platform (xplat) documentation MDX source files live in this repositor If content originates from or must be synced with the upstream [`igniteui-xplat-docs`](https://github.com/IgniteUI/igniteui-xplat-docs) repository, use the merge scripts in `scripts/` (e.g. `merge-vnext-updates.mjs`, `migrate-vnext-new-files.mjs`) to pull in updates rather than editing generated files directly. -## These topics are generated into the Angular tree — don't edit or commit them there +## These topics are served to Angular from xplat — don't copy them into the Angular tree -For **Angular**, the xplat output is compiled and copied over the Angular content tree on every build by `docs/angular/scripts/sync-generated.mjs` (run via `sync:generated-from-xplat` before every `angular:dev`/`angular:build`). It overwrites everything under `docs/angular/src/content/{en,jp}/components/` **except** `grids/`, `changelog/`, and `toc.json`, which stay Angular-owned. +For **Angular**, the xplat output is compiled to `docs/xplat/generated/Angular/{en,jp}/components/` by `xplat:generate`, which runs before every `angular:dev`/`angular:build`. The Angular site then reads that directory **in place**, as a second content root overlaid on `docs/angular/src/content/{lang}/components/`. Nothing is copied between the two trees, so the tracked Angular content never accumulates build output. -As a result these Angular copies (charts, geo-map, gauges, spreadsheet, excel-library, `general-changelog-dv`, etc.) are **not committed** — editing them under `docs/angular/` has no effect, so edit the xplat source instead. They are kept out of git by the `xplat-generated topics` block at the bottom of `docs/angular/src/content/en/.gitignore` and `docs/angular/src/content/jp/.gitignore`. If you add a **new** cross-platform topic group under `docs/xplat/src/content/`, add a matching pattern to those two `.gitignore` blocks so the generated Angular copy is not accidentally committed. +Both roots share one slug namespace — `/charts/types/area-chart.mdx` is the page `/charts/types/area-chart` whichever root it came from — and **xplat always wins**: if a slug exists in both, the generated topic is served and the Angular file is ignored entirely. `grids/` and `changelog/` are excluded from the overlay and stay Angular-owned, as does `toc.json`, which drives the sidebar for both roots. + +Two consequences worth knowing: + +- Editing one of these topics under `docs/angular/` has no effect — edit `docs/xplat/src/content/` instead. Adding a new cross-platform topic needs no `.gitignore` change; it simply appears from the xplat root. +- A **committed** Angular topic that xplat also provides is dead weight: it is shadowed and never served, which almost always means the topic was moved to xplat without deleting the Angular copy. `node docs/angular/scripts/clean-synced.mjs` reports these (and deletes untracked leftovers from the old copy step with `--apply`). # Adding of images in the topic diff --git a/.github/workflows/check-relative-links.yml b/.github/workflows/check-relative-links.yml index a8779bf886..be77df6ddc 100644 --- a/.github/workflows/check-relative-links.yml +++ b/.github/workflows/check-relative-links.yml @@ -26,13 +26,15 @@ jobs: - run: npm ci - # Sync xplat-generated Angular content into docs/angular/src/content - # before scanning so the angular tree is complete (same as the angular build). - - name: Sync xplat → angular (en) - run: npm run sync:generated-from-xplat --prefix docs/angular + # Generate the xplat Angular overlay into docs/xplat/generated/Angular. + # The Angular site reads that tree in place (nothing is copied into + # docs/angular/src/content), and the link checker scans both roots, so + # the overlay has to exist before scanning — same as the angular build. + - name: Generate xplat Angular overlay (en) + run: npm run xplat:generate --prefix docs/angular - - name: Sync xplat → angular (jp) - run: npm run sync:generated-from-xplat:jp --prefix docs/angular + - name: Generate xplat Angular overlay (jp) + run: npm run xplat:generate:jp --prefix docs/angular - name: Generate angular content (en) run: npm run generate:en --prefix docs/angular diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5efe8d8f1..03334ac089 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,26 @@ on: branches: [ master, vnext ] jobs: + unit-tests: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test + build-and-verify: runs-on: ubuntu-latest diff --git a/API-LINK-WORKFLOW.md b/API-LINK-WORKFLOW.md index 66527e02f5..7616306ae2 100644 --- a/API-LINK-WORKFLOW.md +++ b/API-LINK-WORKFLOW.md @@ -145,11 +145,16 @@ Use `--no-sync` only for a quick local resolver check when generated content is Angular: ```text -npm run sync:generated-from-xplat --prefix docs/angular -npm run sync:generated-from-xplat:jp --prefix docs/angular -scan docs/angular/src/content +npm run xplat:generate --prefix docs/angular +npm run xplat:generate:jp --prefix docs/angular +scan docs/angular/src/content and docs/xplat/generated/Angular ``` +The Angular site serves both roots — its own authored topics plus the xplat +generator's Angular output, overlaid in place rather than copied in — so the +checker scans both. Files the site never serves are skipped: the overlay's +`grids/` and `changelog/`, and authored topics that xplat shadows. + React, Web Components, and Blazor: ```text diff --git a/README.md b/README.md index 0ffaf59cb1..572ab66eea 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,9 @@ The `.mdx` extension enables editor Go-to-Definition (Ctrl+Click). The `remarkMd The Angular documentation is assembled from three sources before being checked: -1. **xplat sync** — `docs/xplat/src/content/` is generated into platform-specific output and then copied into `docs/angular/src/content/` by the sync scripts. +1. **xplat generation** — `docs/xplat/src/content/` is generated into platform-specific output under `docs/xplat/generated/Angular/`. The Angular site reads that directory in place as a second content root; it is never copied into `docs/angular/src/content/`. 2. **Grid generation** — `docs/angular/src/content/en/grids_templates/` and `jp/grids_templates/` are template files shared across all four grid types (Grid, TreeGrid, HierarchicalGrid, PivotGrid). `generate.mjs` expands them into the individual component pages under `docs/angular/src/content/en/components/grid/`, `treegrid/`, `hierarchicalgrid/`, and `pivotGrid/`. These template directories are excluded from link checking (same as xplat `_shared/`). -3. **Link check** — the checker scans the fully assembled `docs/angular/src/content/` tree. +3. **Link check** — the checker scans both Angular content roots (`docs/angular/src/content/` and `docs/xplat/generated/Angular/`) and resolves links across them, since a topic in one root may link to a topic served from the other. The check must run **after** both steps above, otherwise it scans stale or incomplete files and misses links that only exist in generated output. @@ -184,7 +184,7 @@ The check is read-only and reports the source file and line for missing or malfo - Angular content lives under `docs/angular/src/content//`. - Shared xplat content lives under `docs/xplat/src/content//`. -- Cross-platform topics are also generated into the Angular tree at build time (by `docs/angular/scripts/sync-generated.mjs`) and are therefore **not committed** under `docs/angular/` — they are gitignored, and editing those Angular copies has no effect. Edit the xplat source instead. See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md#updating-of-data-visualization-related-topics). +- Cross-platform topics are generated to `docs/xplat/generated/Angular/` and overlaid on the Angular content at build time — they are never copied into `docs/angular/`, and xplat wins wherever both provide the same slug. Edit the xplat source; editing an Angular copy has no effect. See [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md#updating-of-data-visualization-related-topics). - Static images and assets are stored in the nearest product package when product-specific, or in the root `public/` directory when shared. ## Collaboration Docs diff --git a/docs/angular/astro.config.ts b/docs/angular/astro.config.ts index fcaef41806..70809007df 100644 --- a/docs/angular/astro.config.ts +++ b/docs/angular/astro.config.ts @@ -45,6 +45,20 @@ const site = mode === 'production' ? `${PROD_HOST}${base}` const docsDir = path.join(__dirname, 'src', 'content', docsLang); const componentsDocsDir = path.join(docsDir, 'components'); const templatesDir = path.join(docsDir, 'grids_templates'); +// Topics generated from the shared cross-platform source, read straight out of +// the xplat generator's output. They override the Angular tree for any slug +// they provide, and nothing is ever copied into src/content — so the tracked +// content tree never accumulates build output. Absent for languages the xplat +// generator does not emit (kr), in which case there is simply no overlay. +// +// changelog/ and grids/ stay Angular-owned: xplat emits them too, but the site +// never serves those copies. This is the single source of truth for the overlay +// — createDocsSite publishes it as DOCS_SOURCE_PATHS, and src/content.config.ts +// reads it back from there. +const xplatOverlay = { + dir: path.join(__dirname, '..', 'xplat', 'generated', 'Angular', docsLang, 'components'), + exclude: ['changelog/**', 'grids/**'], +}; const localizedDescription: Partial> = { jp: 'Ignite UI for Angular のコンポーネントと API リファレンス ドキュメントです。', kr: 'Ignite UI for Angular 컴포넌트 및 API 참조 문서입니다.', @@ -85,6 +99,7 @@ export default createDocsSite({ source: { tocPath: `${componentsDocsDir}/toc.json`, docsDir: componentsDocsDir, + overlayDirs: [xplatOverlay], }, head: [ { tag: 'link', attrs: { rel: 'icon', href: `${mode !== 'development' ? base : ''}/favicon.ico`, type: 'image/x-icon' } }, diff --git a/docs/angular/package.json b/docs/angular/package.json index c9b3014c82..6abf6efc54 100644 --- a/docs/angular/package.json +++ b/docs/angular/package.json @@ -7,23 +7,24 @@ "generate:en": "node scripts/generate.mjs --lang=en", "generate:jp": "node scripts/generate.mjs --lang=jp", "generate:kr": "node scripts/generate.mjs --lang=kr", - "sync:generated-from-xplat": "npm run generate:angular --prefix ../xplat && node scripts/sync-generated.mjs --lang=en", - "sync:generated-from-xplat:jp": "npm run generate:angular:jp --prefix ../xplat && node scripts/sync-generated.mjs --lang=jp", - "dev": "npm run sync:generated-from-xplat && npm run generate && cross-env PLATFORM=Angular astro dev --port 4321", - "dev:en": "npm run sync:generated-from-xplat && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=en astro dev --port 4321", - "dev:jp": "npm run sync:generated-from-xplat:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=jp astro dev --port 4321", + "xplat:generate": "npm run generate:angular --prefix ../xplat", + "clean:synced": "node scripts/clean-synced.mjs", + "xplat:generate:jp": "npm run generate:angular:jp --prefix ../xplat", + "dev": "npm run xplat:generate && npm run generate && cross-env PLATFORM=Angular astro dev --port 4321", + "dev:en": "npm run xplat:generate && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=en astro dev --port 4321", + "dev:jp": "npm run xplat:generate:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=jp astro dev --port 4321", "dev:kr": "npm run generate:kr && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=kr astro dev --port 4321", - "build": "npm run sync:generated-from-xplat && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 astro build --outDir=../../dist/angular", - "build:en": "npm run sync:generated-from-xplat && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=en astro build --outDir=../../dist/angular", - "build:jp": "npm run sync:generated-from-xplat:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", + "build": "npm run xplat:generate && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 astro build --outDir=../../dist/angular", + "build:en": "npm run xplat:generate && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=en astro build --outDir=../../dist/angular", + "build:jp": "npm run xplat:generate:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", "build:kr": "npm run generate:kr && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_LANG=kr astro build --outDir=../../dist/angular-kr", - "build-staging": "npm run sync:generated-from-xplat && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production astro build --outDir=../../dist/angular", - "build-staging:en": "npm run sync:generated-from-xplat && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production DOCS_LANG=en astro build --outDir=../../dist/angular", - "build-staging:jp": "npm run sync:generated-from-xplat:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", + "build-staging": "npm run xplat:generate && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production astro build --outDir=../../dist/angular", + "build-staging:en": "npm run xplat:generate && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production DOCS_LANG=en astro build --outDir=../../dist/angular", + "build-staging:jp": "npm run xplat:generate:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", "build-staging:kr": "npm run generate:kr && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 DOCS_ENV=staging NODE_ENV=production DOCS_LANG=kr astro build --outDir=../../dist/angular-kr", - "build-production": "npm run sync:generated-from-xplat && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production astro build --outDir=../../dist/angular", - "build-production:en": "npm run sync:generated-from-xplat && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production DOCS_LANG=en astro build --outDir=../../dist/angular", - "build-production:jp": "npm run sync:generated-from-xplat:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", + "build-production": "npm run xplat:generate && npm run generate && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production astro build --outDir=../../dist/angular", + "build-production:en": "npm run xplat:generate && npm run generate:en && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production DOCS_LANG=en astro build --outDir=../../dist/angular", + "build-production:jp": "npm run xplat:generate:jp && npm run generate:jp && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production DOCS_LANG=jp astro build --outDir=../../dist/angular-jp", "build-production:kr": "npm run generate:kr && cross-env PLATFORM=Angular NODE_OPTIONS=--max-old-space-size=4096 NODE_ENV=production DOCS_LANG=kr astro build --outDir=../../dist/angular-kr", "preview": "cross-env PLATFORM=Angular astro preview --outDir=../../dist/angular", "preview:en": "cross-env PLATFORM=Angular DOCS_LANG=en astro preview --outDir=../../dist/angular --port 4321", diff --git a/docs/angular/scripts/clean-synced.mjs b/docs/angular/scripts/clean-synced.mjs new file mode 100644 index 0000000000..7e0363d9d5 --- /dev/null +++ b/docs/angular/scripts/clean-synced.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * One-shot cleanup for the old xplat → Angular copy step. + * + * The Angular site used to build by copying `docs/xplat/generated/Angular/{lang}` + * over `docs/angular/src/content/{lang}`, which left generated topics sitting in + * the tracked content tree. The site now overlays the generated tree in place, so + * those copies are dead weight: they are shadowed by the xplat originals and can + * only cause confusion (edits to them have no effect). + * + * This deletes, from the Angular tree, every file the generator also emits. + * + * Files that are *tracked* in git are never touched — a tracked file that xplat + * also provides is a topic that was moved upstream without deleting the Angular + * copy, and removing it is a reviewable change, not a cleanup side effect. Those + * are reported at the end so they can be handled deliberately. + * + * Usage: + * node scripts/clean-synced.mjs # report only + * node scripts/clean-synced.mjs --apply # actually delete + * node scripts/clean-synced.mjs --apply --lang=jp + */ + +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, '../../..'); + +const args = process.argv.slice(2); +const apply = args.includes('--apply'); +const langArg = args.find(a => a.startsWith('--lang='))?.split('=')[1]; +const langs = langArg ? [langArg] : ['en', 'jp']; + +/** + * Paths the old copy step never wrote, so nothing under them can be a leftover. + * These stay Angular-owned, exactly as the overlay's excludes keep them. + */ +const NEVER_COPIED = /(^|\/)(grids|changelog)\/|(^|\/)toc\.(json|yml)$/; + +/** Relative paths of every file the generator emits for `lang`. */ +function generatedFiles(sourceDir) { + const out = []; + const visit = (dir) => { + for (const entry of readdirSync(dir)) { + const abs = join(dir, entry); + if (statSync(abs).isDirectory()) visit(abs); + else out.push(relative(sourceDir, abs).replace(/\\/g, '/')); + } + }; + visit(sourceDir); + return out; +} + +/** + * Aborts the run. Anything git cannot answer confidently has to stop the + * script *before* it deletes: a git that fails to run would otherwise make + * every file look untracked, and this script deletes untracked files. + */ +function abortOnGitFailure(result, what) { + if (result.error) { + console.error(`[clean-synced] Could not run git (${what}): ${result.error.message}`); + console.error('[clean-synced] Refusing to delete anything without a working git. Aborting.'); + process.exit(1); + } + const stderr = (result.stderr ?? '').toString().trim(); + console.error(`[clean-synced] git ${what} failed with exit code ${result.status}.`); + if (stderr) console.error(` ${stderr}`); + console.error('[clean-synced] Refusing to delete anything without a trustworthy git. Aborting.'); + process.exit(1); +} + +/** Verifies up front that git runs and that repoRoot really is a repository. */ +function assertGitAvailable() { + const r = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (r.error || r.status !== 0) abortOnGitFailure(r, 'rev-parse --is-inside-work-tree'); +} + +/** + * True when git has the path in the index — those are never deleted here. + * + * `git ls-files --error-unmatch` exits 0 for a tracked path and 1 for an + * untracked one. Every other outcome (git missing, fatal repo error, a signal) + * is a failure to answer, not a "no", so it aborts rather than green-lighting + * a delete. + */ +function isTracked(absPath) { + const r = spawnSync('git', ['ls-files', '--error-unmatch', '--', absPath], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (r.error || r.status === null) abortOnGitFailure(r, `ls-files -- ${absPath}`); + if (r.status === 0) return true; + if (r.status === 1) return false; + return abortOnGitFailure(r, `ls-files -- ${absPath}`); +} + +/** Removes directories left empty by the deletions, bottom-up. */ +function pruneEmptyDirs(dir, stopAt) { + let current = dir; + while (current.startsWith(stopAt) && current !== stopAt) { + if (!existsSync(current) || readdirSync(current).length > 0) return; + rmSync(current, { recursive: true }); + current = dirname(current); + } +} + +// Fail fast: without git this script cannot tell a leftover copy from a +// tracked topic, and it must never delete the latter. +assertGitAvailable(); + +let totalRemoved = 0; +const trackedOverlaps = []; + +for (const lang of langs) { + const sourceDir = join(repoRoot, `docs/xplat/generated/Angular/${lang}`); + const targetDir = join(repoRoot, `docs/angular/src/content/${lang}`); + + if (!existsSync(sourceDir)) { + console.log(`[clean-synced] ${lang}: no generated output — run "npm run xplat:generate" first. Skipping.`); + continue; + } + if (!existsSync(targetDir)) continue; + + const stale = []; + for (const rel of generatedFiles(sourceDir)) { + if (NEVER_COPIED.test(rel)) continue; + const candidate = join(targetDir, rel); + if (!existsSync(candidate)) continue; + if (isTracked(candidate)) { + trackedOverlaps.push(`docs/angular/src/content/${lang}/${rel}`); + continue; + } + stale.push(candidate); + } + + console.log(`[clean-synced] ${lang}: ${stale.length} leftover generated file(s) in the Angular tree`); + for (const file of stale) { + console.log(` ${apply ? 'removed ' : 'would remove '}${relative(repoRoot, file).replace(/\\/g, '/')}`); + if (apply) { + rmSync(file); + pruneEmptyDirs(dirname(file), targetDir); + } + } + totalRemoved += stale.length; +} + +console.log( + apply + ? `\n[clean-synced] Removed ${totalRemoved} file(s).` + : `\n[clean-synced] ${totalRemoved} file(s) would be removed. Re-run with --apply.` +); + +if (trackedOverlaps.length) { + console.log( + `\n[clean-synced] ${trackedOverlaps.length} tracked Angular topic(s) are also provided by xplat.\n` + + ' xplat wins, so these are shadowed and never served. Delete them in a\n' + + ' reviewed commit, or move the change upstream into docs/xplat/src/content:' + ); + for (const file of trackedOverlaps) console.log(` ${file}`); +} diff --git a/docs/angular/scripts/sync-generated.mjs b/docs/angular/scripts/sync-generated.mjs deleted file mode 100644 index 35f09cea1e..0000000000 --- a/docs/angular/scripts/sync-generated.mjs +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node -import { closeSync, cpSync, existsSync, ftruncateSync, openSync, readFileSync, readdirSync, statSync, writeSync } from 'fs'; -import { join, dirname, relative } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const lang = process.argv.find(a => a.startsWith('--lang='))?.split('=')[1] ?? 'en'; - -const repoRoot = join(__dirname, '../../..'); -const sourceDir = join(repoRoot, `docs/xplat/generated/Angular/${lang}`); -const targetDir = join(repoRoot, `docs/angular/src/content/${lang}`); - -console.log(`Syncing generated Angular MDX content from xplat (lang: ${lang})...`); - -if (!existsSync(sourceDir)) { - console.error(`Source directory not found: ${sourceDir}`); - console.error('Run the xplat generation script first.'); - process.exit(1); -} - -function shouldCopy(src) { - // Skip grids folder (Angular has its own) - if (src.includes('/grids/') || src.includes('\\grids\\')) { - return false; - } - // Skip changelog folder (Angular has its own) - if (src.includes('/changelog/') || src.includes('\\changelog\\')) { - return false; - } - // Skip TOC files (Angular manages its own) - if (src.endsWith('toc.json') || src.endsWith('toc.yml')) { - return false; - } - return true; -} - -function normalizeMarkdownSpacing(content) { - const hasFinalNewline = /\r?\n$/.test(content); - const lines = content.replace(/\r\n/g, '\n').split('\n'); - const result = []; - let blankCount = 0; - let inFence = false; - - for (const line of lines) { - if (/^\s*(```|~~~)/.test(line)) { - inFence = !inFence; - blankCount = 0; - result.push(line); - continue; - } - - if (inFence) { - result.push(line); - continue; - } - - if (line.trim() === '') { - blankCount++; - if (blankCount <= 1) { - result.push(''); - } - continue; - } - - blankCount = 0; - result.push(line); - } - - let normalized = result.join('\n').replace(/\n+$/, ''); - if (hasFinalNewline) { - normalized += '\n'; - } - return normalized; -} - -function normalizeCopiedMarkdownFiles(srcDir, destDir) { - let normalizedCount = 0; - - function visit(srcPath) { - if (!shouldCopy(srcPath)) return; - - const stat = statSync(srcPath); - if (stat.isDirectory()) { - for (const entry of readdirSync(srcPath)) { - visit(join(srcPath, entry)); - } - return; - } - - if (!/\.(md|mdx)$/i.test(srcPath)) return; - - const destPath = join(destDir, relative(srcDir, srcPath)); - let fd; - - try { - fd = openSync(destPath, 'r+'); - const original = readFileSync(fd, 'utf8'); - const normalized = normalizeMarkdownSpacing(original); - - if (normalized !== original) { - ftruncateSync(fd, 0); - writeSync(fd, normalized, 0, 'utf8'); - normalizedCount++; - } - } catch (error) { - if (error?.code === 'ENOENT') return; - throw error; - } finally { - if (fd !== undefined) { - closeSync(fd); - } - } - } - - visit(srcDir); - return normalizedCount; -} - -// Copy all generated content files from source to target -console.log(`Copying from ${sourceDir} to ${targetDir}`); -cpSync(sourceDir, targetDir, { - recursive: true, - filter: shouldCopy, -}); - -const normalizedFiles = normalizeCopiedMarkdownFiles(sourceDir, targetDir); - -console.log(' Generated content synced successfully'); -console.log(' Excluded: grids/, changelog/, toc files'); -console.log(` Normalized markdown spacing in ${normalizedFiles} file(s)`); diff --git a/docs/angular/src/content.config.ts b/docs/angular/src/content.config.ts index 41c43926c0..6c432ff847 100644 --- a/docs/angular/src/content.config.ts +++ b/docs/angular/src/content.config.ts @@ -1,6 +1,6 @@ import { z } from 'astro/zod'; -import { createDocsCollection } from 'docs-template/content'; -import { readFileSync } from 'node:fs'; +import { createDocsCollection, docRootsFromEnv } from 'docs-template/content'; +import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,8 +13,30 @@ try { lang = cfg.lang ?? lang; } catch { /* use defaults */ } +// Angular's own, hand-authored topics. const docsDir = path.join(root, 'src', 'content', lang, 'components'); +// Topics generated from the shared cross-platform source. These are read in +// place — nothing is copied into src/content — and they take precedence over +// the Angular tree, so a topic that has moved to xplat is served from xplat +// even if a stale copy is still sitting in src/content. +// +// changelog/ and grids/ stay Angular-owned and are never taken from xplat. +const xplatDir = path.join(root, '..', 'xplat', 'generated', 'Angular', lang, 'components'); + +// astro.config.ts owns the root list: createDocsSite resolves `source.docsDir` +// plus `source.overlayDirs` (excludes and all) and publishes the result as +// DOCS_SOURCE_PATHS, so the collection is built from exactly the roots the rest +// of the site resolves against. The literal fallback below only applies when +// this config is loaded without that env var — `astro check`, a direct +// `getCollection()` in a script — and must stay in step with astro.config.ts. +const rootsFromEnv = docRootsFromEnv(); +const roots = rootsFromEnv.length + ? rootsFromEnv + : existsSync(xplatDir) + ? [{ dir: xplatDir, exclude: ['changelog/**', 'grids/**'] }, docsDir] + : [docsDir]; + const tableOfContentsSchema = z.object({ tableOfContents: z .union([ @@ -28,5 +50,5 @@ const tableOfContentsSchema = z.object({ }); export const collections = { - docs: createDocsCollection(docsDir, { exclude: ['**/*.md'], extendSchema: tableOfContentsSchema }), + docs: createDocsCollection(roots, { exclude: ['**/*.md'], extendSchema: tableOfContentsSchema }), }; diff --git a/docs/angular/src/content/en/.gitignore b/docs/angular/src/content/en/.gitignore index bc6cd82100..df23f835c0 100644 --- a/docs/angular/src/content/en/.gitignore +++ b/docs/angular/src/content/en/.gitignore @@ -60,19 +60,5 @@ components/pivotGrid/*.mdx !components/pivotGrid/pivot-grid-features.mdx !components/pivotGrid/pivot-grid-custom.mdx -# All xplat-generated topics that should be ignored: -/components/charts/ -/components/geo-map*.mdx -/components/spreadsheet-*.mdx -/components/excel-library*.mdx -/components/excel-utility.mdx -/components/bullet-graph.mdx -/components/dashboard-tile.mdx -/components/linear-gauge.mdx -/components/radial-gauge.mdx -/components/zoomslider-overview.mdx -/components/general-changelog-dv.mdx -/components/inputs/color-editor.mdx -/components/interactivity/accessibility-compliance.mdx -/components/maps/map-api.mdx -/components/menus/toolbar.mdx +# xplat-generated topics are no longer copied into this tree — the site reads +# them straight from docs/xplat/generated/Angular/. Nothing to ignore here. diff --git a/docs/angular/src/content/en/components/geo-map-binding-data-overview.mdx b/docs/angular/src/content/en/components/geo-map-binding-data-overview.mdx deleted file mode 100644 index 6de1ba59da..0000000000 --- a/docs/angular/src/content/en/components/geo-map-binding-data-overview.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Angular Map | Data Visualization Tools | Data Binding | Infragistics" -description: Use Infragistics' Angular map to display data that contains geographic locations from view models or geo-spatial data loaded from shape files on geographic imagery maps. View Ignite UI for Angular map demos! -keywords: "Angular map, geo-spatial data, Ignite UI for Angular, Infragistics, data binding" -license: commercial -mentionedTypes: ["GeographicMap", "Series"] -namespace: Infragistics.Controls.Maps -llms: - description: "The Ignite UI for Angular map component is designed to display geo-spatial data from shape files and/or geographic locations from data models on geographic imagery maps." ---- -import ApiLink from 'igniteui-astro-components/components/mdx/ApiLink.astro'; - -# Angular Data Binding - -The Ignite UI for Angular map component is designed to display geo-spatial data from shape files and/or geographic locations from data models on geographic imagery maps. The property of geographic series is used for the purpose of binding to data models. - -## Types of Data Sources -The following section list some of data source that you can bind in the geographic map component - -- [Binding Shape Files](./geo-map-binding-shp-file.mdx) -- [Binding JSON Files](./geo-map-binding-data-json-points.mdx) -- [Binding CSV Files](./geo-map-binding-data-csv.mdx) -- [Binding Data Models](./geo-map-binding-data-model.mdx) -- [Binding Multiple Sources](./geo-map-binding-multiple-sources.mdx) - -## API References - diff --git a/docs/angular/src/content/jp/.gitignore b/docs/angular/src/content/jp/.gitignore index 67c5e1e6f8..df23f835c0 100644 --- a/docs/angular/src/content/jp/.gitignore +++ b/docs/angular/src/content/jp/.gitignore @@ -60,21 +60,5 @@ components/pivotGrid/*.mdx !components/pivotGrid/pivot-grid-features.mdx !components/pivotGrid/pivot-grid-custom.mdx -# All xplat-generated topics that should be ignored: -/components/charts/ -/components/geo-map*.mdx -/components/spreadsheet-*.mdx -/components/excel-library*.mdx -/components/excel-utility.mdx -/components/bullet-graph.mdx -/components/dashboard-tile.mdx -/components/linear-gauge.mdx -/components/radial-gauge.mdx -/components/zoomslider-overview.mdx -/components/general-changelog-dv.mdx -/components/inputs/color-editor.mdx -/components/interactivity/accessibility-compliance.mdx -/components/maps/map-api.mdx -/components/menus/toolbar.mdx -/components/general-step-by-step-guide-using-cli.mdx -/components/localization.mdx +# xplat-generated topics are no longer copied into this tree — the site reads +# them straight from docs/xplat/generated/Angular/. Nothing to ignore here. diff --git a/package-lock.json b/package-lock.json index dc86737fb6..c65149c919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,8 @@ "cspell": "^9.2.2", "markdownlint-cli": "^0.49.1", "sass-embedded": "^1.98.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^5.0.0" }, "engines": { "node": ">=22.12.0" @@ -2316,12 +2317,33 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", @@ -3223,6 +3245,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3232,6 +3265,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3348,6 +3388,44 @@ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -3545,6 +3623,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3806,6 +3894,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -4897,6 +4995,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -6988,9 +7096,9 @@ } }, "node_modules/magic-string": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", - "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", + "integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -10432,6 +10540,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10484,6 +10599,13 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10493,6 +10615,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", @@ -10662,6 +10791,16 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/tinyclip": { "version": "0.1.15", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", @@ -11311,6 +11450,89 @@ } } }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", @@ -11394,6 +11616,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 2762e6a0a2..2417edd014 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,8 @@ "xplat:build-production:react:jp": "npm run build-production:react:jp --prefix docs/xplat", "xplat:build-production:webcomponents:jp": "npm run build-production:webcomponents:jp --prefix docs/xplat", "xplat:build-production:blazor:jp": "npm run build-production:blazor:jp --prefix docs/xplat", + "test": "vitest run", + "test:watch": "vitest", "lint:md": "markdownlint \"docs/**/*.mdx\" \"docs/**/*.md\"", "lint:md:fix": "markdownlint --fix \"docs/**/*.mdx\" \"docs/**/*.md\"", "spellcheck": "cspell \"docs/**/*.mdx\" \"docs/**/*.md\" --no-progress", @@ -141,8 +143,8 @@ "check-mdx-links:broken:wc": "node --experimental-strip-types scripts/check-mdx-links.mjs --platform=wc --resolve-only --list-broken --broken-limit=100 --broken-md=reports/mdx-broken-links-wc.md --list-ambiguities --ambiguity-md=reports/api-link-ambiguity-report-wc.md --fail-on-ambiguity", "check-mdx-links:broken:blazor": "node --experimental-strip-types scripts/check-mdx-links.mjs --platform=blazor --resolve-only --list-broken --broken-limit=100 --broken-md=reports/mdx-broken-links-blazor.md --list-ambiguities --ambiguity-md=reports/api-link-ambiguity-report-blazor.md --fail-on-ambiguity", "check-relative-links": "node scripts/check-relative-links.mjs", - "check-relative-links:ci": "npm run sync:generated-from-xplat --prefix docs/angular && npm run sync:generated-from-xplat:jp --prefix docs/angular && npm run generate:en --prefix docs/angular && npm run generate:jp --prefix docs/angular && npm run generate:react --prefix docs/xplat && npm run generate:webcomponents --prefix docs/xplat && npm run generate:blazor --prefix docs/xplat && npm run generate:react:jp --prefix docs/xplat && npm run generate:webcomponents:jp --prefix docs/xplat && npm run generate:blazor:jp --prefix docs/xplat && node scripts/check-relative-links-ci.mjs", - "check-relative-links:report": "npm run generate:en --prefix docs/angular && npm run generate:jp --prefix docs/angular && npm run generate:react --prefix docs/xplat && npm run generate:webcomponents --prefix docs/xplat && npm run generate:blazor --prefix docs/xplat && npm run generate:react:jp --prefix docs/xplat && npm run generate:webcomponents:jp --prefix docs/xplat && npm run generate:blazor:jp --prefix docs/xplat && node scripts/check-relative-links-ci.mjs --md=reports/relative-links-report.md", + "check-relative-links:ci": "npm run xplat:generate --prefix docs/angular && npm run xplat:generate:jp --prefix docs/angular && npm run generate:en --prefix docs/angular && npm run generate:jp --prefix docs/angular && npm run generate:react --prefix docs/xplat && npm run generate:webcomponents --prefix docs/xplat && npm run generate:blazor --prefix docs/xplat && npm run generate:react:jp --prefix docs/xplat && npm run generate:webcomponents:jp --prefix docs/xplat && npm run generate:blazor:jp --prefix docs/xplat && node scripts/check-relative-links-ci.mjs", + "check-relative-links:report": "npm run xplat:generate --prefix docs/angular && npm run xplat:generate:jp --prefix docs/angular && npm run generate:en --prefix docs/angular && npm run generate:jp --prefix docs/angular && npm run generate:react --prefix docs/xplat && npm run generate:webcomponents --prefix docs/xplat && npm run generate:blazor --prefix docs/xplat && npm run generate:react:jp --prefix docs/xplat && npm run generate:webcomponents:jp --prefix docs/xplat && npm run generate:blazor:jp --prefix docs/xplat && node scripts/check-relative-links-ci.mjs --md=reports/relative-links-report.md", "check-html-links": "node scripts/check-html-links.mjs", "check-html-links:report": "node scripts/check-html-links.mjs --md=reports/html-links-report.md" }, @@ -170,7 +172,8 @@ "cspell": "^9.2.2", "markdownlint-cli": "^0.49.1", "sass-embedded": "^1.98.0", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "vitest": "^5.0.0" }, "peerDependencies": { "@astrojs/mdx": ">=7", diff --git a/scripts/check-mdx-links.mjs b/scripts/check-mdx-links.mjs index c5a514265f..54c1c93d27 100644 --- a/scripts/check-mdx-links.mjs +++ b/scripts/check-mdx-links.mjs @@ -39,6 +39,12 @@ import { getPackageClassSuffixes, getPackageIds, } from '../src/lib/api-platform-config.ts'; +import { + ANGULAR_AUTHORED_ROOT, + ANGULAR_OVERLAY_ROOT, + isShadowedAuthoredFile, + isUnservedOverlayFile, +} from './lib/angular-content-roots.mjs'; // CLI args const args = Object.fromEntries( @@ -66,10 +72,33 @@ const FAIL_ON_AMBIGUITY = args['fail-on-ambiguity'] !== undefined; const BROKEN_LIMIT = parseListLimit(args['broken-limit'] ?? args['unresolved-limit'] ?? '100'); const NO_SYNC = !!args['no-sync']; const RESOLVE_ONLY = !!args['resolve-only']; +// The Angular site serves two content roots: its own topics plus the xplat +// generator's Angular output, which it overlays rather than copying in. Both +// are scanned so API links in generated topics keep their coverage. const DEFAULT_SRC = PLATFORM === 'angular' - ? 'docs/angular/src/content' - : 'docs/xplat/src/content'; -const SRC_DIR = String(args.src ?? DEFAULT_SRC); + ? [ANGULAR_AUTHORED_ROOT, ANGULAR_OVERLAY_ROOT] + : ['docs/xplat/src/content']; +// Configured, *unfiltered*: docs/xplat/generated/Angular does not exist on a +// clean checkout and is created by the generate step further down, so an +// existsSync() filter here would drop it before it is ever written and silently +// shrink Angular coverage. Existence is resolved at scan time instead. +const CONFIGURED_SRC_DIRS = args.src ? [String(args.src)] : DEFAULT_SRC; +let SRC_DIRS = CONFIGURED_SRC_DIRS; +let SRC_DIR = SRC_DIRS.join(', '); + +/** Drops configured roots that are still absent at scan time, warning about each. */ +function resolveSrcDirs(dirs) { + return dirs.filter(d => { + if (existsSync(d)) return true; + const hint = d === ANGULAR_OVERLAY_ROOT + ? '\n Generate it first:\n' + + ' npm run xplat:generate --prefix docs/angular\n' + + ' npm run xplat:generate:jp --prefix docs/angular' + : ''; + console.warn(`\n[warn] Source root "${d}" does not exist — skipping it.${hint}`); + return false; + }); +} const API_LINK_INDEX_VERSION = String(args.index ?? process.env.API_LINK_INDEX_VERSION ?? (process.env.NODE_ENV === 'production' ? 'prod-latest' : 'staging-latest')); const PLATFORM_CONFIGS = API_PLATFORM_CONFIGS; @@ -579,14 +608,14 @@ function runRequiredNpmScript(script, prefix, description) { } } -// For Angular: regenerate xplat Angular MDX and sync it into docs/angular before scanning +// For Angular: regenerate the xplat Angular MDX the site overlays before scanning if (PLATFORM === 'angular' && !NO_SYNC) { - const syncScripts = [ - ['en', 'sync:generated-from-xplat'], - ['jp', 'sync:generated-from-xplat:jp'], + const generateScripts = [ + ['en', 'xplat:generate'], + ['jp', 'xplat:generate:jp'], ]; - for (const [lang, script] of syncScripts) { + for (const [lang, script] of generateScripts) { runRequiredNpmScript(script, 'docs/angular', `Refreshing Angular generated content (lang=${lang})`); } console.log(); @@ -600,10 +629,23 @@ if (XPLAT_GENERATE_SCRIPTS[PLATFORM] && !NO_SYNC && !args.src) { console.log(); } +// Resolved here, *after* any generate step above, so a root that only exists +// once generation has run is still picked up. +SRC_DIRS = resolveSrcDirs(CONFIGURED_SRC_DIRS); +SRC_DIR = SRC_DIRS.join(', '); + console.log(`\nScanning sources in "${SRC_DIR}"`); console.log(`Platforms: ${targetPlatforms.join(', ')}\n`); -let mdxFiles = walkMdx(resolve(SRC_DIR)); +let mdxFiles = SRC_DIRS.flatMap(d => walkMdx(resolve(d))); +if (PLATFORM === 'angular') { + // Only files the Angular site renders: the overlay's excluded grids/ and + // changelog/ are on disk but never served, and an authored topic whose slug + // xplat also provides is shadowed — xplat always wins. + const beforeFilter = mdxFiles.length; + mdxFiles = mdxFiles.filter(f => !isUnservedOverlayFile(f) && !isShadowedAuthoredFile(f)); + console.log(` Overlay filter : ${beforeFilter - mdxFiles.length} unserved MDX file(s) skipped\n`); +} if (XPLAT_GENERATE_SCRIPTS[PLATFORM] && !args.src) { const platformName = PLATFORM_MAP[PLATFORM]; const beforeFilter = mdxFiles.length; diff --git a/scripts/check-relative-links.mjs b/scripts/check-relative-links.mjs index a2ca373826..98dcb45eb9 100644 --- a/scripts/check-relative-links.mjs +++ b/scripts/check-relative-links.mjs @@ -9,9 +9,10 @@ * whether the target exists (trying .mdx, .md, and bare extensions). * JSX-style href attributes with relative paths are also checked. * - * When --platform=angular the script scans docs/angular/src/content. + * When --platform=angular the script scans docs/angular/src/content plus + * docs/xplat/generated/Angular — the two roots the Angular site serves. * When --platform=react|wc|blazor it scans docs/xplat/src/content. - * Omitting --platform scans both trees in one pass. + * Omitting --platform scans every tree in one pass. * * Usage: * node scripts/check-relative-links.mjs @@ -27,6 +28,13 @@ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; // join used in walkMdx +import { + ANGULAR_AUTHORED_ROOT, + ANGULAR_OVERLAY_ROOT, + getRootGroup, + isShadowedAuthoredFile, + isUnservedOverlayFile, +} from './lib/angular-content-roots.mjs'; // CLI args const args = Object.fromEntries( @@ -44,15 +52,40 @@ const SUMMARY = args.summary === true; const XPLAT_PLATFORMS = new Set(['react', 'wc', 'blazor']); +/** + * Drops roots that are not on disk, so a language the xplat generator does not + * emit (`kr`) simply has no overlay rather than a hard failure. Losing the + * Angular overlay root, though, silently shrinks coverage over every generated + * topic — so that one is called out. + */ +function keepExisting(dirs) { + return dirs.filter(d => { + if (existsSync(d)) return true; + if (d === ANGULAR_OVERLAY_ROOT) { + console.warn( + `[warn] "${d}" is missing — the xplat Angular overlay was not scanned.\n` + + ' Links in generated topics are unchecked. Generate it first:\n' + + ' npm run xplat:generate --prefix docs/angular\n' + + ' npm run xplat:generate:jp --prefix docs/angular' + ); + } + return false; + }); +} + function getSrcDirs() { if (args.src) return [String(args.src)]; - if (PLATFORM === 'angular') return ['docs/angular/src/content']; + // The Angular site overlays the xplat generator's Angular output on its own + // content instead of copying it in, so both trees have to be scanned — and + // links may point across them (see lib/angular-content-roots.mjs). + if (PLATFORM === 'angular') { + return keepExisting([ANGULAR_AUTHORED_ROOT, ANGULAR_OVERLAY_ROOT]); + } if (PLATFORM === 'xplat' || (PLATFORM && XPLAT_PLATFORMS.has(PLATFORM))) { // Scan source for _shared/ template links, plus the React/WC/Blazor // generated output (after generate.mjs has rewritten _shared/ paths). - // Angular is intentionally excluded: generated/Angular/ is an - // intermediate artifact that gets synced into docs/angular/src/content/ - // and validated there by --platform=angular. + // Angular is intentionally excluded here: generated/Angular/ is one of + // the two roots the Angular site serves, so --platform=angular scans it. // Run the generate scripts before this check so generated/ is up to date. const dirs = ['docs/xplat/src/content']; for (const p of ['React', 'WebComponents', 'Blazor']) { @@ -65,7 +98,7 @@ function getSrcDirs() { console.error(`Unknown platform "${PLATFORM}". Use: angular, xplat, react, wc, blazor`); process.exit(1); } - return ['docs/angular/src/content', 'docs/xplat/src/content']; + return keepExisting([ANGULAR_AUTHORED_ROOT, ANGULAR_OVERLAY_ROOT, 'docs/xplat/src/content']); } // File walking @@ -166,12 +199,12 @@ function blankInapplicablePlatformBlocks(content, platforms) { /** * Maps a source file path to the set of applicable platforms for PlatformBlock * filtering. Files in docs/xplat/src/content are shared across all xplat - * platforms, we filter out Angular only blocks; files in docs/angular/src - * keep Angular blocks and skip xplat-only ones. + * platforms, we filter out Angular only blocks; files in docs/angular/src and + * in the generator's Angular output keep Angular blocks and skip xplat-only ones. */ function platformSetForFile(filePath) { const normalized = filePath.replace(/\\/g, '/'); - if (normalized.includes('docs/angular/src/')) { + if (normalized.includes('docs/angular/src/') || normalized.includes('docs/xplat/generated/Angular/')) { return new Set(['Angular']); } if (normalized.includes('docs/xplat/src/')) { @@ -229,30 +262,43 @@ function isAbsoluteDocLink(url) { */ function getLangRoot(filePath) { const normalized = filePath.replace(/\\/g, '/'); - const m = normalized.match(/^(.*\/content\/(?:en|jp|kr))\//i); + const m = normalized.match(/^(.*\/(?:content|generated\/[^/]+)\/(?:en|jp|kr))\//i); return m ? m[1] + '/' : null; } +// `OVERLAY_ROOT_GROUPS`, `getRootGroup`, `isUnservedOverlayFile` and +// `isShadowedAuthoredFile` live in scripts/lib/angular-content-roots.mjs — the +// single description of what the Angular site actually serves, shared with +// check-mdx-links.mjs. + /** * Resolves an absolute doc link like /treegrid/tree-grid against the - * language content root. Tries components/{path}.mdx then {path}.mdx. + * language content roots. Tries components/{path}.mdx then {path}.mdx in each + * root that shares the slug namespace. * Returns the resolved path string or null if not found. */ -function resolveAbsoluteLink(langRoot, url) { +function resolveAbsoluteLink(langRoots, url) { const path = stripHash(url).slice(1); // strip leading '/' if (!path) return 'hash-only'; // Astro lowercases all URL slugs at build time. Always resolve using the // lowercased path so that camelCase links like /pivotGrid/... are flagged // as broken (the built URL is /pivotgrid/...). const pathLower = path.toLowerCase(); - const candidates = [ - resolve(langRoot, 'components', pathLower), - resolve(langRoot, pathLower), - ]; - for (const base of candidates) { - if (existsSync(base)) return base; - if (existsSync(base + '.mdx')) return base + '.mdx'; - if (existsSync(base + '.md')) return base + '.md'; + for (const langRoot of langRoots) { + const candidates = [ + resolve(langRoot, 'components', pathLower), + resolve(langRoot, pathLower), + ]; + for (const base of candidates) { + for (const candidate of [base, base + '.mdx', base + '.md']) { + // A file the site never serves cannot satisfy a link: the xplat + // copies of grids/ and changelog/ sit on disk but are excluded + // from the overlay, so accepting one would hide a genuinely + // broken link on a page that *is* served. + if (isUnservedOverlayFile(candidate)) continue; + if (existsSync(candidate)) return candidate; + } + } } return null; } @@ -342,21 +388,40 @@ function extractRelativeLinks(content, filePath) { * missingExt = true means the path has no extension but resolves via .mdx — * the link should be written as ./page.mdx, not ./page. */ -function resolveLink(fileDir, href) { +function resolveLink(fileDir, href, langRoots = []) { const path = stripHash(href); if (!path) return { resolved: 'hash-only', missingExt: false }; - const abs = resolve(fileDir, path); - - if (existsSync(abs)) return { resolved: abs, missingExt: false }; - - if (existsSync(abs + '.mdx')) { + const describe = (abs) => { const lastDot = path.lastIndexOf('.'); const lastSlash = path.lastIndexOf('/'); const hasExt = lastDot > lastSlash; const isBare = !path.startsWith('./') && !path.startsWith('../'); - const missingExt = !hasExt || isBare; - return { resolved: abs + '.mdx', missingExt, bare: isBare }; + return { resolved: abs, missingExt: !hasExt || isBare, bare: isBare }; + }; + + // The file's own root first, then any root it shares a slug namespace with: + // the same relative path is re-anchored on each peer, so a link that points + // at a topic served from the other tree still resolves. + const [ownRoot, ...peerRoots] = langRoots; + const bases = [resolve(fileDir, path)]; + if (ownRoot && peerRoots.length) { + const fromRoot = relative(resolve(ownRoot), bases[0]); + if (!fromRoot.startsWith('..')) { + for (const peer of peerRoots) bases.push(resolve(peer, fromRoot)); + } + } + + // Candidates re-anchored on a peer root can land on a file the site never + // serves (the overlay's excluded grids/ and changelog/); those must not + // satisfy the link, or a broken /grids/... reference would pass. + for (const abs of bases) { + if (isUnservedOverlayFile(abs)) continue; + if (existsSync(abs)) return { resolved: abs, missingExt: false }; + } + for (const abs of bases) { + if (isUnservedOverlayFile(abs + '.mdx')) continue; + if (existsSync(abs + '.mdx')) return describe(abs + '.mdx'); } return { resolved: null, missingExt: false }; @@ -371,7 +436,12 @@ let filesToScan; let scanDescription; const srcDirs = getSrcDirs(); -filesToScan = srcDirs.flatMap(d => walkMdx(resolve(d))); +// Only scan files the Angular site actually renders: skip the overlay's +// excluded grids/ and changelog/, and skip authored topics that xplat shadows — +// the latter are on disk but never served, so their links belong to no page. +filesToScan = srcDirs + .flatMap(d => walkMdx(resolve(d))) + .filter(f => !isUnservedOverlayFile(f) && !isShadowedAuthoredFile(f)); scanDescription = `source dirs: ${srcDirs.join(', ')}`; console.log(`\nScanning for relative links`); @@ -393,18 +463,19 @@ for (const file of filesToScan) { const relFile = relative(cwd, file).replace(/\\/g, '/'); const langRoot = getLangRoot(file); + const langRoots = getRootGroup(langRoot); for (const { href, line, kind } of links) { if (kind === 'absolute') { if (langRoot) { - const resolved = resolveAbsoluteLink(langRoot, href); + const resolved = resolveAbsoluteLink(langRoots, href); if (resolved === null) { brokenLinks.push({ file: relFile, line, href, reason: 'not-found' }); } } continue; } - const { resolved, missingExt, bare } = resolveLink(fileDir, href); + const { resolved, missingExt, bare } = resolveLink(fileDir, href, langRoots); if (resolved === null) { brokenLinks.push({ file: relFile, line, href, reason: 'not-found' }); } else if (bare) { diff --git a/scripts/lib/angular-content-roots.mjs b/scripts/lib/angular-content-roots.mjs new file mode 100644 index 0000000000..0ad5b50e1e --- /dev/null +++ b/scripts/lib/angular-content-roots.mjs @@ -0,0 +1,129 @@ +/** + * angular-content-roots.mjs + * + * One description of "which files does the Angular site actually serve", shared + * by every link checker so the answer cannot drift between them. + * + * The Angular site is built from two content roots overlaid in place — nothing + * is ever copied between the trees: + * + * 1. docs/xplat/generated/Angular/{lang}/components (highest precedence) + * 2. docs/angular/src/content/{lang}/components (hand-authored) + * + * Two consequences a checker has to model: + * + * • Parts of the overlay are excluded, so those files exist on disk but are + * never rendered — see `isUnservedOverlayFile`. + * • xplat always wins a slug collision, so an authored topic the generator + * also emits is never rendered either — see `isShadowedAuthoredFile`. + * + * KEEP IN SYNC with the roots and excludes in + * `docs/angular/src/content.config.ts` and the `source.overlayDirs` entry in + * `docs/angular/astro.config.ts`. If an exclude is added there, add it here. + */ + +import { existsSync } from 'node:fs'; + +/** Repo-relative path of the Angular site's own, hand-authored content root. */ +export const ANGULAR_AUTHORED_ROOT = 'docs/angular/src/content'; + +/** Repo-relative path of the xplat generator's Angular output (the overlay). */ +export const ANGULAR_OVERLAY_ROOT = 'docs/xplat/generated/Angular'; + +/** + * Content roots that share one slug namespace. The Angular site serves its own + * tree and the xplat generator's Angular output as a single set of pages, so a + * link may legitimately point from one tree at a topic that lives in the other. + */ +export const OVERLAY_ROOT_GROUPS = [ + [ANGULAR_AUTHORED_ROOT, ANGULAR_OVERLAY_ROOT], +]; + +/** + * Directories excluded from the overlay in `docs/angular/src/content.config.ts`. + * They stay Angular-owned, so the xplat copies are never served. + */ +export const OVERLAY_EXCLUDED_DIRS = ['changelog', 'grids']; + +/** + * Paths that exist in the generated tree but are not served, because the site + * excludes them from the overlay. + */ +const UNSERVED_OVERLAY_PATHS = [ + new RegExp( + `(^|/)${ANGULAR_OVERLAY_ROOT}/[^/]+/components/(${OVERLAY_EXCLUDED_DIRS.join('|')})(/|$)`, + 'i', + ), +]; + +/** Forward-slash form of a path, for matching against the repo-relative patterns. */ +function normalize(filePath) { + return String(filePath).replace(/\\/g, '/'); +} + +/** + * True when `filePath` lives in the overlay but under a path the Angular site + * excludes, so the file is on disk yet never rendered. Such files must not be + * scanned, and must not satisfy a link from a page that *is* rendered. + */ +export function isUnservedOverlayFile(filePath) { + const normalized = normalize(filePath); + return UNSERVED_OVERLAY_PATHS.some(re => re.test(normalized)); +} + +// The leading group is whatever precedes the repo-relative root — an absolute +// prefix ending in `/`, or nothing at all for an already-relative path. +const AUTHORED_TOPIC_RE = new RegExp( + `^(|.*/)${ANGULAR_AUTHORED_ROOT}/([^/]+)/components/(.+)$`, + 'i', +); + +/** + * True when `filePath` is a hand-authored Angular topic whose slug the xplat + * generator also provides. xplat wins every collision, so the authored file is + * dead weight: it is never rendered, and scanning it would report links that no + * published page contains. + */ +export function isShadowedAuthoredFile(filePath) { + const match = AUTHORED_TOPIC_RE.exec(normalize(filePath)); + if (!match) return false; + const [, prefix, lang, rel] = match; + const overlayCandidate = `${prefix}${ANGULAR_OVERLAY_ROOT}/${lang}/components/${rel}`; + // An overlay file the site excludes shadows nothing — `grids/` and + // `changelog/` stay Angular-owned even though xplat also emits them. + if (isUnservedOverlayFile(overlayCandidate)) return false; + return existsSync(overlayCandidate); +} + +/** True when the Angular site never renders `filePath`, for either reason above. */ +export function isUnservedAngularFile(filePath) { + return isUnservedOverlayFile(filePath) || isShadowedAuthoredFile(filePath); +} + +/** + * Returns every language root that resolves alongside `langRoot`, itself first. + * Roots that are not on disk (a language the generator does not emit) are dropped. + * + * `langRoot` is absolute — the scan resolves its source dirs — so the + * repo-relative group entries are matched as a substring and the prefix before + * them is carried over to the peers. + */ +export function getRootGroup(langRoot) { + if (!langRoot) return []; + const normalized = normalize(langRoot).replace(/\/$/, ''); + for (const group of OVERLAY_ROOT_GROUPS) { + for (const base of group) { + const at = normalized.lastIndexOf(base + '/'); + if (at === -1) continue; + const prefix = normalized.slice(0, at); + const lang = normalized.slice(at + base.length + 1); + if (!lang || lang.includes('/')) continue; + const peers = group + .filter(other => other !== base) + .map(other => `${prefix}${other}/${lang}/`) + .filter(dir => existsSync(dir)); + return [langRoot, ...peers]; + } + } + return [langRoot]; +} diff --git a/scripts/lib/angular-content-roots.test.ts b/scripts/lib/angular-content-roots.test.ts new file mode 100644 index 0000000000..e5359bd612 --- /dev/null +++ b/scripts/lib/angular-content-roots.test.ts @@ -0,0 +1,165 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + ANGULAR_AUTHORED_ROOT, + ANGULAR_OVERLAY_ROOT, + getRootGroup, + isShadowedAuthoredFile, + isUnservedAngularFile, + isUnservedOverlayFile, + OVERLAY_EXCLUDED_DIRS, +} from './angular-content-roots.mjs'; + +/** Repo root of the fake checkout, in the forward-slash form the module expects. */ +let repo: string; + +const authored = (lang: string): string => `${repo}/${ANGULAR_AUTHORED_ROOT}/${lang}`; +const overlay = (lang: string): string => `${repo}/${ANGULAR_OVERLAY_ROOT}/${lang}`; + +/** Creates an empty file at `absPath`, parents included. */ +function touch(absPath: string): string { + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, ''); + return absPath; +} + +beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ng-roots-')).replace(/\\/g, '/'); + + // Authored tree: one topic the generator also emits, one it does not, and + // one under a directory the site keeps for itself. + touch(`${authored('en')}/components/charts/pie.md`); + touch(`${authored('en')}/components/unique.md`); + touch(`${authored('en')}/components/grids/grid.md`); + touch(`${authored('jp')}/components/charts/pie.md`); + touch(`${authored('kr')}/components/charts/pie.md`); + + // Overlay tree: the generated twins, plus the excluded subtrees. + touch(`${overlay('en')}/components/charts/pie.md`); + touch(`${overlay('en')}/components/grids/grid.md`); + touch(`${overlay('en')}/components/changelog/notes.md`); + touch(`${overlay('jp')}/components/charts/pie.md`); +}); + +afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); +}); + +describe('isUnservedOverlayFile', () => { + it.each(OVERLAY_EXCLUDED_DIRS)('treats the overlay %s directory as unserved', dir => { + expect(isUnservedOverlayFile(`${ANGULAR_OVERLAY_ROOT}/en/components/${dir}/topic.md`)).toBe(true); + }); + + it('serves an overlay file outside the excluded directories', () => { + expect(isUnservedOverlayFile(`${ANGULAR_OVERLAY_ROOT}/en/components/charts/pie.md`)).toBe(false); + }); + + it('matches the excluded directory itself, without a trailing path', () => { + expect(isUnservedOverlayFile(`${ANGULAR_OVERLAY_ROOT}/en/components/grids`)).toBe(true); + }); + + it('does not match a directory that merely starts with an excluded name', () => { + expect(isUnservedOverlayFile(`${ANGULAR_OVERLAY_ROOT}/en/components/gridsy/topic.md`)).toBe(false); + }); + + it('matches when the repo-relative path is prefixed by an absolute directory', () => { + expect(isUnservedOverlayFile(`${overlay('en')}/components/changelog/notes.md`)).toBe(true); + }); + + it('normalises Windows separators', () => { + expect(isUnservedOverlayFile(`${ANGULAR_OVERLAY_ROOT}/en/components/grids/grid.md`.replace(/\//g, '\\'))) + .toBe(true); + }); + + it('ignores case', () => { + expect(isUnservedOverlayFile('docs/xplat/generated/angular/EN/components/Changelog/notes.md')).toBe(true); + }); + + it('never treats an authored file as an unserved overlay file', () => { + expect(isUnservedOverlayFile(`${ANGULAR_AUTHORED_ROOT}/en/components/grids/grid.md`)).toBe(false); + }); +}); + +describe('isShadowedAuthoredFile', () => { + it('reports an authored topic the generator also emits', () => { + expect(isShadowedAuthoredFile(`${authored('en')}/components/charts/pie.md`)).toBe(true); + }); + + it('does not report an authored topic with no generated twin', () => { + expect(isShadowedAuthoredFile(`${authored('en')}/components/unique.md`)).toBe(false); + }); + + it('does not report an authored topic whose twin the site excludes', () => { + // `grids/` stays Angular-owned, so the generated copy shadows nothing. + expect(isShadowedAuthoredFile(`${authored('en')}/components/grids/grid.md`)).toBe(false); + }); + + it('does not report a topic in a language the generator does not emit', () => { + expect(isShadowedAuthoredFile(`${authored('kr')}/components/charts/pie.md`)).toBe(false); + }); + + it('reports a shadowed topic in the Japanese tree', () => { + expect(isShadowedAuthoredFile(`${authored('jp')}/components/charts/pie.md`)).toBe(true); + }); + + it('returns false for a path outside the authored root', () => { + expect(isShadowedAuthoredFile(`${overlay('en')}/components/charts/pie.md`)).toBe(false); + expect(isShadowedAuthoredFile(`${repo}/docs/xplat/src/content/en/components/charts/pie.md`)).toBe(false); + }); +}); + +describe('isUnservedAngularFile', () => { + it('is true for an excluded overlay file', () => { + expect(isUnservedAngularFile(`${overlay('en')}/components/changelog/notes.md`)).toBe(true); + }); + + it('is true for a shadowed authored file', () => { + expect(isUnservedAngularFile(`${authored('en')}/components/charts/pie.md`)).toBe(true); + }); + + it('is false for an authored file the site actually renders', () => { + expect(isUnservedAngularFile(`${authored('en')}/components/unique.md`)).toBe(false); + }); + + it('is false for a served overlay file', () => { + expect(isUnservedAngularFile(`${overlay('en')}/components/charts/pie.md`)).toBe(false); + }); +}); + +describe('getRootGroup', () => { + it('pairs the authored English root with its overlay', () => { + expect(getRootGroup(authored('en'))).toEqual([authored('en'), `${overlay('en')}/`]); + }); + + it('pairs the overlay root with the authored tree', () => { + expect(getRootGroup(overlay('en'))).toEqual([overlay('en'), `${authored('en')}/`]); + }); + + it('pairs the Japanese roots', () => { + expect(getRootGroup(authored('jp'))).toEqual([authored('jp'), `${overlay('jp')}/`]); + }); + + it('drops a peer the generator does not emit', () => { + expect(getRootGroup(authored('kr'))).toEqual([authored('kr')]); + }); + + it('ignores a trailing slash on the input', () => { + expect(getRootGroup(`${authored('en')}/`)).toEqual([`${authored('en')}/`, `${overlay('en')}/`]); + }); + + it('returns a path outside both roots on its own', () => { + const outside = `${repo}/docs/xplat/src/content/en`; + expect(getRootGroup(outside)).toEqual([outside]); + }); + + it('returns a deeper path inside a root on its own', () => { + const deeper = `${authored('en')}/components`; + expect(getRootGroup(deeper)).toEqual([deeper]); + }); + + it('returns an empty list for an empty root', () => { + expect(getRootGroup('')).toEqual([]); + }); +}); diff --git a/src/__snapshots__/html-page.md b/src/__snapshots__/html-page.md new file mode 100644 index 0000000000..8ac3bc615b --- /dev/null +++ b/src/__snapshots__/html-page.md @@ -0,0 +1,24 @@ +# Data Grid + +The grid supports "smart" quotes, an ellipsis... and the Ignite UI(TM) trademark. + +Read the [editing topic](https://example.test/docs/grids/data-grid/editing.md), the [API section](https://example.test/docs/grids/data-grid/editing.md?tab=api#cells), the [PDF sheet](https://example.test/docs/assets/sheet.pdf) and the [external page](https://example.test/external). + +## Options + +| Name | Type | +| --- | --- | +| data | Array | + +- Sorting +- Filtering + +```typescript +const grid = new Grid(); +grid.data = []; +``` + +> **Note:** +> Virtualization is on by default. + +[Grid Overview Example](https://example.test/samples/grid-overview) diff --git a/src/__snapshots__/llms-txt.txt b/src/__snapshots__/llms-txt.txt new file mode 100644 index 0000000000..a224d8322e --- /dev/null +++ b/src/__snapshots__/llms-txt.txt @@ -0,0 +1,25 @@ +# Ignite UI + +> Docs for Ignite UI. + +## Documentation sets + +- [Abridged documentation](/docs/llms-small.txt): a compact version of the documentation, with non-essential content removed +- [Combined docs](/docs/llms-full.txt): Single-file Markdown export of all docs. +- [API Reference](https://example.test/api/angular/llms.txt): Full TypeDoc/docfx API reference for all packages — classes, interfaces, enums, and members + +- [React Grids](/docs/_llms-txt/react-grids.txt): Grid docs only. + +## Grids & Lists + +- [Grids & Lists Overview](/docs/grids.md): Every grid component. + Tags: grid, table + +### Data Grid + +- [Grids & Lists Data Grid Overview](/docs/grids/data-grid.md) +- [Grids & Lists Cell Editing](/docs/grids/data-grid/editing.md): Editing cells in the grid. + +## General + +- [General Getting Started](/docs/general/getting-started.md) \ No newline at end of file diff --git a/src/content-helper.ts b/src/content-helper.ts index 77ba71a9f6..a320054747 100644 --- a/src/content-helper.ts +++ b/src/content-helper.ts @@ -10,9 +10,9 @@ * import { collections } from 'docs-template/content'; * export { collections }; * - * `createDocsSite({ source: { docsDir } })` in astro.config.ts - * automatically sets DOCS_SOURCE_PATH, so the exported `collections` - * object picks it up with no extra configuration. + * `createDocsSite({ source: { docsDir, overlayDirs } })` in astro.config.ts + * publishes the full root list, so the exported `collections` object picks + * it up with no extra configuration. * * ── Custom excludes / extra schema fields ──────────────────────────────── * @@ -25,12 +25,34 @@ * extendSchema: z.object({ myCustomField: z.string().optional() }), * }), * }; + * + * ── Overlaying a generated tree on an authored one ──────────────────────── + * + * Pass several roots, highest precedence first. All roots share one slug + * namespace, and the first root to provide a slug wins — nothing is copied + * between the trees, so the authored tree stays free of build output: + * + * export const collections = { + * docs: createDocsCollection([ + * { dir: generatedDir, exclude: ['changelog/**'] }, // wins + * authoredDir, + * ]), + * }; */ import { defineCollection } from 'astro:content'; import { glob } from 'astro/loaders'; import { z } from 'astro/zod'; -import { pathToFileURL } from 'node:url'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { docRootsFromEnv, toRootList, type DocsContentRoot } from './lib/doc-roots.ts'; + +// The canonical root helpers live in `lib/doc-roots.ts` — re-exported here so a +// consuming `content.config.ts` can read the root list `createDocsSite` +// published (`docRootsFromEnv()`) from the same module it already imports. +export { docRootsFromEnv } from './lib/doc-roots.ts'; +export type { DocsContentRoot, ResolvedDocRoot } from './lib/doc-roots.ts'; /** Sentinel value placed on entries that have no title so we can remove them after loading. */ const SKIP_TITLE = '\x00skip'; @@ -55,6 +77,269 @@ function withTitleFilter(baseLoader: any): any { }; } +// --------------------------------------------------------------------------- +// Multi-root overlay support +// --------------------------------------------------------------------------- +// +// Astro's `glob()` loader owns the whole store: it snapshots `store.keys()` when +// it starts and deletes every id it did not touch by the time it finishes. Two +// glob loaders sharing one collection would therefore wipe each other's entries. +// +// `scopeStore` hands each root its own view of the store so that: +// • `keys()` lists only the entries that came from *that* root, so a root's +// cleanup sweep can never delete another root's pages; +// • `get()` only ever hands a root back its *own* entries, so a shadowed page +// is silently skipped instead of overwriting the winner (and without +// tripping Astro's duplicate-slug warning, which fires inside the loader +// between its `get` and its `set`); +// • `set()` is refused for ids a higher-precedence root has claimed. +// +// Ownership is decided by the entry's own `filePath`, not just by what a root +// wrote during this pass: on a *warm* load the glob loader returns early — with +// no `set` — as soon as it sees an unchanged digest, so a root that wins every +// one of its slugs may never call `set` at all. `get()` therefore registers the +// claim itself, which is what keeps the winner from being overwritten by the +// next root on the second and every later build against a populated store. +// +// Roots are always ordered highest precedence first, so root 0 wins every +// collision — see `src/lib/doc-roots.ts` for the convention. +// +// ── Dev-server watching ────────────────────────────────────────────────────── +// Each glob loader registers its own `add`/`change`/`unlink` handlers on the +// shared file watcher, and each handler only reacts to paths under its own +// root. That is fine for adds and edits (the scoped store lets a higher root +// take a slug over, and keeps a lower one from stealing it) but not for +// deletes: when a *winning* file goes away, its root removes the entry, and +// nothing tells the shadowed root that its file is now the page — the route +// vanishes until the next full reload. `scopeWatcher` closes that gap by +// following every unlink under root N with a synthetic change for the same +// relative path in roots N+1…, so the first lower root that still has the file +// re-syncs it through its own loader (and the scoped store again makes sure +// only the highest of those wins). + +/** Absolute path of the file an entry was loaded from, or `undefined`. */ +function entrySourcePath(entry: unknown, configRoot: string): string | undefined { + const filePath = (entry as { filePath?: string } | undefined)?.filePath; + return filePath ? path.resolve(configRoot, filePath) : undefined; +} + +interface ScopeStoreOptions { + /** Absolute path of the root this view belongs to. */ + root: string; + /** Index of this root in the precedence list (0 = highest). */ + rootIndex: number; + /** True when this is the lowest-precedence root. */ + isLast: boolean; + /** Astro's `config.root`, used to absolutize the relative `filePath` on entries. */ + configRoot: string; + /** Shared across roots for one load pass: entry id → index of the root that claimed it. */ + claimed: Map; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function scopeStore(store: any, options: ScopeStoreOptions): any { + const { root, rootIndex, isLast, configRoot, claimed } = options; + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + + /** + * Ownership of a stored entry, decided by the file it was loaded from: + * `'mine'` — the file lives under this root; + * `'other'` — it lives under a different root; + * `'orphan'` — it has no file path (predates this loader, or came from + * elsewhere). Only the last root claims orphans, so they are + * still swept exactly once. + */ + const ownership = (id: string): 'mine' | 'other' | 'orphan' => { + const source = entrySourcePath(store.get(id), configRoot); + if (!source) return 'orphan'; + return source.startsWith(rootWithSep) ? 'mine' : 'other'; + }; + + /** True when `id` was loaded from this root — decided by the entry's own file path. */ + const ownsEntry = (id: string): boolean => { + const owner = ownership(id); + return owner === 'orphan' ? isLast : owner === 'mine'; + }; + + /** True when any *other* root has taken this id during this load pass. */ + const takenByOther = (id: string): boolean => { + const owner = claimed.get(id); + return owner !== undefined && owner !== rootIndex; + }; + + /** + * True when a root that outranks this one has taken the id. Writes are + * blocked only by higher precedence, never by lower, so a root can still + * take over a slug it should win — which is what happens when the dev + * server's watcher adds a file that shadows an already-loaded page. + */ + const takenByHigher = (id: string): boolean => { + const owner = claimed.get(id); + return owner !== undefined && owner < rootIndex; + }; + + return { + ...store, + keys: () => [...store.keys()].filter(ownsEntry), + entries: () => [...store.entries()].filter(([id]: [string]) => ownsEntry(id)), + values: () => [...store.entries()].filter(([id]: [string]) => ownsEntry(id)).map(([, v]: [string, unknown]) => v), + // Hiding another root's entry keeps the two files from being compared + // against each other, which is what would otherwise emit a + // duplicate-slug warning for a slug that is deliberately shadowed. + // + // Reading one's own entry also *claims* the id. The loader calls `get` + // for every file it walks, but only calls `set` when the content + // actually changed, so claiming here is the only thing that records the + // winner on a warm load — without it the next root would see an + // unclaimed id and overwrite the page it is supposed to be shadowed by. + get: (id: string) => { + if (takenByOther(id)) return undefined; + switch (ownership(id)) { + case 'mine': + claimed.set(id, rootIndex); + return store.get(id); + case 'other': + return undefined; + default: + // No file path to go on (or no entry at all) — nothing to + // claim; hand back whatever the store has. + return store.get(id); + } + }, + set: (entry: { id: string }) => { + if (takenByHigher(entry.id)) return false; + claimed.set(entry.id, rootIndex); + return store.set(entry); + }, + delete: (id: string) => { + if (takenByOther(id)) return; + claimed.delete(id); + return store.delete(id); + }, + }; +} + +type WatchHandler = (filePath: string) => unknown; + +/** Source extensions the loaders serve; a deleted `.mdx` may be backed by a `.md`. */ +const SOURCE_EXTENSIONS = ['.md', '.mdx']; + +interface ScopeWatcherOptions { + /** Index of the root this view belongs to (0 = highest precedence). */ + rootIndex: number; + /** Per-root `change` handlers registered so far, shared across all views. */ + changeHandlers: WatchHandler[][]; + /** Called after a root's own `unlink` handler has run for `filePath`. */ + onUnlinked: (rootIndex: number, filePath: string) => Promise; +} + +/** + * Wraps the dev server's file watcher for one root. Every call passes through + * to the real watcher; the view only records the root's `change` handler (so + * the overlay can re-sync a file on the root's behalf) and follows the root's + * `unlink` handler with `onUnlinked`. + * + * Methods are bound to the real watcher rather than delegated through a + * prototype so the watcher's internal state is never written onto the view. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function scopeWatcher(watcher: any, options: ScopeWatcherOptions): any { + const { rootIndex, changeHandlers, onUnlinked } = options; + + return new Proxy(watcher, { + get(target, prop, receiver) { + if (prop === 'on') { + return (event: string, handler: WatchHandler) => { + let registered = handler; + if (event === 'change') { + changeHandlers[rootIndex].push(handler); + } else if (event === 'unlink') { + registered = async (filePath: string) => { + await handler(filePath); + await onUnlinked(rootIndex, filePath); + }; + } + target.on(event, registered); + return receiver; + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** + * Runs one glob loader per content root against a single collection. + * + * Roots are listed highest precedence first and loaded in that order, so the + * first root to provide a slug is the one that wins. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function overlayLoader(loaders: Array<{ root: string; loader: any }>): any { + if (loaders.length === 1) return loaders[0].loader; + + const rootsWithSep = loaders.map(({ root }) => (root.endsWith(path.sep) ? root : root + path.sep)); + + return { + name: 'docs-overlay-loader', + load: async (ctx: any) => { + // Entry `filePath`s are stored relative to Astro's project root, + // which the config exposes as a file:// URL. + const configRoot = ctx.config?.root ? fileURLToPath(ctx.config.root) : process.cwd(); + const claimed = new Map(); + const changeHandlers: WatchHandler[][] = loaders.map(() => []); + + /** + * A file under root `rootIndex` was deleted. Re-sync the same + * relative path from every lower-precedence root that still has it, + * highest first — the scoped store lets the first of them claim the + * slug and refuses the rest, so the page never disappears while a + * shadowed copy exists. Paths outside the root (which the root's own + * handler ignored too) and paths no lower root can serve are no-ops. + */ + const onUnlinked = async (rootIndex: number, deletedPath: string): Promise => { + const abs = path.resolve(deletedPath); + if (!abs.startsWith(rootsWithSep[rootIndex])) return; + + const relPath = abs.slice(rootsWithSep[rootIndex].length); + const ext = path.extname(relPath); + if (!SOURCE_EXTENSIONS.includes(ext)) return; + const stem = relPath.slice(0, -ext.length); + // Same extension first, so an overlay `.mdx` falls back to a base + // `.mdx` before a base `.md` — matching `findFirstInRoots`. + const candidates = [ext, ...SOURCE_EXTENSIONS.filter(e => e !== ext)].map(e => stem + e); + + for (let lower = rootIndex + 1; lower < loaders.length; lower++) { + for (const candidate of candidates) { + const file = path.join(loaders[lower].root, candidate); + if (!fs.existsSync(file)) continue; + // The root's own handler applies its glob (and excludes) + // before syncing, so an excluded copy is skipped here too. + for (const handler of changeHandlers[lower]) await handler(file); + } + } + }; + + for (const [rootIndex, { root, loader }] of loaders.entries()) { + await loader.load({ + ...ctx, + store: scopeStore(ctx.store, { + root, + rootIndex, + isLast: rootIndex === loaders.length - 1, + configRoot, + claimed, + }), + watcher: ctx.watcher + ? scopeWatcher(ctx.watcher, { rootIndex, changeHandlers, onUnlinked }) + : ctx.watcher, + }); + } + }, + }; +} + /** * Base frontmatter schema for MDX/Markdown documentation files. * Wraps with a z.preprocess that injects a sentinel title for entries @@ -86,8 +371,8 @@ function makeDocsSchema(extend?: z.ZodObject) { interface CreateDocsCollectionOptions { /** - * Glob patterns to exclude (relative to `sourceDir`). A leading `!` is - * added automatically when missing, so `'internal/**'` and + * Glob patterns to exclude, applied to *every* root (relative to each root). + * A leading `!` is added automatically when missing, so `'internal/**'` and * `'!internal/**'` are both accepted. */ exclude?: string[]; @@ -99,29 +384,38 @@ interface CreateDocsCollectionOptions { } /** - * Creates an Astro content collection (`docs`) for a docs site, - * using a glob loader against the given source directory. + * Creates an Astro content collection (`docs`) for a docs site, using a glob + * loader against each source directory. * * @param sourceDir - Absolute path to the directory containing the source - * `.md`/`.mdx` files. Defaults to `process.env.DOCS_SOURCE_PATH` if omitted. + * `.md`/`.mdx` files, or a list of such directories **ordered highest + * precedence first**: when the same slug exists in more than one root, the + * earliest root in the list provides the page and the rest are ignored. + * Defaults to `process.env.DOCS_SOURCE_PATH` if omitted. * @param options - Optional exclude patterns and schema extension. */ export function createDocsCollection( - sourceDir?: string, + sourceDir?: DocsContentRoot | DocsContentRoot[], { exclude = [], extendSchema }: CreateDocsCollectionOptions = {}, ) { - const dir = sourceDir ?? process.env.DOCS_SOURCE_PATH; - if (!dir) { + const configured = sourceDir ?? process.env.DOCS_SOURCE_PATH; + const roots = toRootList( + (Array.isArray(configured) ? configured : [configured]) + .filter((r): r is DocsContentRoot => Boolean(r)), + ).filter(r => Boolean(r.dir)); + + if (roots.length === 0) { throw new Error( '[docs-template] createDocsCollection: no source directory provided. ' + - 'Pass a path as the first argument or set the DOCS_SOURCE_PATH env variable.' + 'Pass a path (or list of paths) as the first argument or set the DOCS_SOURCE_PATH env variable.' ); } - const excludePatterns = exclude.map(p => (p.startsWith('!') ? p : `!${p}`)); + const toNegated = (patterns: string[]) => patterns.map(p => (p.startsWith('!') ? p : `!${p}`)); - return defineCollection({ - loader: withTitleFilter(glob({ + const loaders = roots.map(({ dir, exclude: rootExclude }) => ({ + root: path.resolve(dir), + loader: glob({ base: pathToFileURL(dir.endsWith('/') ? dir : dir + '/'), pattern: [ '*.{md,mdx}', @@ -133,9 +427,13 @@ export function createDocsCollection( '!README.md', '!CHANGELOG.md', '!LICENSE.md', - ...excludePatterns, + ...toNegated([...exclude, ...rootExclude]), ], - })), + }), + })); + + return defineCollection({ + loader: withTitleFilter(overlayLoader(loaders)), // eslint-disable-next-line @typescript-eslint/no-explicit-any schema: makeDocsSchema(extendSchema) as any, }); @@ -155,5 +453,5 @@ export function createDocsCollection( * export { collections }; */ export const collections = { - docs: createDocsCollection(process.env.DOCS_SOURCE_PATH), + docs: createDocsCollection(docRootsFromEnv()), }; diff --git a/src/html-to-md.test.ts b/src/html-to-md.test.ts new file mode 100644 index 0000000000..7762ca10a0 --- /dev/null +++ b/src/html-to-md.test.ts @@ -0,0 +1,153 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { buildHtmlToMdConverter, htmlPageToMd } from './html-to-md.ts'; + +const fixture = (name: string): string => + path.join(fileURLToPath(new URL('../test/fixtures/html-to-md/', import.meta.url)), name); + +const PAGE = fixture('page.html'); +const SITE_URL = 'https://example.test/docs/'; + +describe('buildHtmlToMdConverter', () => { + const td = buildHtmlToMdConverter(); + + it('writes headings in ATX style', () => { + expect(td.turndown('

Options

')).toBe('## Options'); + }); + + it('converts a table to GFM pipe syntax', () => { + const md: string = td.turndown( + '' + + '
NameType
dataArray
', + ); + + expect(md).toContain('| Name | Type |'); + expect(md).toContain('| data | Array |'); + }); + + it('writes code blocks as fenced blocks carrying the language', () => { + const md: string = td.turndown('
const a = 1;
'); + + expect(md).toBe('```typescript\nconst a = 1;\n```'); + }); + + it('uses a dash as the bullet list marker', () => { + expect(td.turndown('
  • Sorting
  • Filtering
')).toBe('- Sorting\n- Filtering'); + }); + + it('drops scripts, styles and decorative icons', () => { + const md: string = td.turndown( + '

Text

' + + '', + ); + + expect(md).toBe('Text'); + }); + + it('removes the breadcrumb nav', () => { + const md: string = td.turndown('

Body

'); + + expect(md).toBe('Body'); + }); + + it('turns a DocsAside into a blockquote labelled from aria-label', () => { + const md: string = td.turndown('

Careful.

'); + + expect(md).toBe('> **Warning:**\n> Careful.'); + }); + + it('turns a sample iframe into a titled link', () => { + const md: string = td.turndown( + '
', + ); + + expect(md).toBe('[Grid Example](https://example.test/s/1)'); + }); +}); + +describe('htmlPageToMd', () => { + const convert = (url = SITE_URL) => htmlPageToMd(PAGE, url, buildHtmlToMdConverter()); + + it('returns an empty string when the file does not exist', async () => { + expect(await htmlPageToMd(fixture('missing.html'), SITE_URL, buildHtmlToMdConverter())).toBe(''); + }); + + it('returns an empty string when the page has no content element', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(await htmlPageToMd(fixture('no-content.html'), SITE_URL, buildHtmlToMdConverter())).toBe(''); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it('keeps the fenced code block with its language and unhighlighted source', async () => { + const md = await convert(); + + expect(md).toContain('```typescript\nconst grid = new Grid();\ngrid.data = [];\n```'); + }); + + it('strips navigation chrome from the page', async () => { + const md = await convert(); + + expect(md).not.toContain('Home'); + expect(md).not.toContain('docs-breadcrumb'); + expect(md).not.toContain('Footer chrome'); + expect(md).not.toContain('Premium'); + }); + + it('normalises typographic characters to ASCII', async () => { + const md = await convert(); + + expect(md).toContain('"smart" quotes'); + expect(md).toContain('an ellipsis...'); + expect(md).toContain('Ignite UI(TM)'); + expect(md).not.toMatch(/[‘’“”…™]/); + }); + + it('absolutizes internal links and appends .md, keeping query and fragment', async () => { + const md = await convert(); + + expect(md).toContain('(https://example.test/docs/grids/data-grid/editing.md)'); + expect(md).toContain('(https://example.test/docs/grids/data-grid/editing.md?tab=api#cells)'); + }); + + it('leaves a link that already has a file extension alone', async () => { + const md = await convert(); + + expect(md).toContain('(https://example.test/docs/assets/sheet.pdf)'); + }); + + it('leaves external links untouched', async () => { + const md = await convert(); + + expect(md).toContain('(https://example.test/external)'); + expect(md).not.toContain('external.md'); + }); + + it('leaves links alone when the site URL cannot be parsed', async () => { + const md = await convert('not a url'); + + expect(md).toContain('(/docs/grids/data-grid/editing)'); + }); + + it('renders the aside as a blockquote without its decorative icon or title', async () => { + const md = await convert(); + + expect(md).toContain('> **Note:**'); + expect(md).toContain('> Virtualization is on by default.'); + }); + + it('ends with exactly one trailing newline', async () => { + const md = await convert(); + + expect(md.endsWith('\n')).toBe(true); + expect(md.endsWith('\n\n')).toBe(false); + }); + + it('matches the recorded Markdown for the fixture page', async () => { + await expect(await convert()).toMatchFileSnapshot('./__snapshots__/html-page.md'); + }); +}); diff --git a/src/integration.ts b/src/integration.ts index 03c8a7c9d0..0786618f1d 100644 --- a/src/integration.ts +++ b/src/integration.ts @@ -59,6 +59,15 @@ import { type LlmsMeta, type LlmsSet, type SidebarEntry, } from './llms.ts'; import { buildSidebarFromToc } from './sidebar'; +import { + DOC_ROOTS_ENV, + findFirstInRoots, + resolveDocRoots, + serializeDocRoots, + toRootList, + type DocsContentRoot, + type ResolvedDocRoot, +} from './lib/doc-roots.ts'; import { getPlatformHead } from './platform'; import type { HeadEntry, PlatformKey, NavLang } from './platform.ts'; import { getGtmContainerId } from './lib/platform-context.js'; @@ -155,8 +164,8 @@ interface GenerateLlmsMdOptions { outDir: string; /** Page slugs to convert (stripped of trailing slashes). */ slugs: string[]; - /** Path to the source docs directory — used only for diagnostic warnings. */ - docsDir: string; + /** Source docs roots, highest precedence first — used only for diagnostic warnings. */ + docsDirs: ResolvedDocRoot[]; /** Configured site URL. */ siteUrl: string; /** Named documentation subsets to assemble into combined .txt files. */ @@ -218,7 +227,7 @@ function resolveApiDocsLlmsUrl(siteUrl: string, platform: PlatformKey | null): s * /llms-small.txt — same as full, but with code blocks and inline code removed * /_llms-txt/*.txt — topic-specific bundles defined by `llmsSets` */ -async function generateLlmsMdFiles({ outDir, slugs, docsDir, siteUrl, llmsSets }: GenerateLlmsMdOptions): Promise { +async function generateLlmsMdFiles({ outDir, slugs, docsDirs, siteUrl, llmsSets }: GenerateLlmsMdOptions): Promise { console.log(`[docs-template] Generating ${slugs.length} .md files…`); const mdStart = Date.now(); @@ -236,9 +245,8 @@ async function generateLlmsMdFiles({ outDir, slugs, docsDir, siteUrl, llmsSets } // Resolve the source MDX/MD path so warnings point developers at the // file they need to edit, not the built HTML artifact. - const sourceRef = ['.mdx', '.md'] - .map(ext => path.join(docsDir, slug + ext)) - .find(f => fs.existsSync(f)) ?? path.join(docsDir, slug + '.mdx'); + const sourceRef = findFirstInRoots(docsDirs, ['.mdx', '.md'].map(ext => slug + ext)) + ?? path.join(docsDirs[0]?.dir ?? '', slug + '.mdx'); const md = await htmlPageToMd(htmlPath, siteUrl, td, sourceRef); if (!md) { skippedSlugs.push(slug); mdDone++; return ''; } @@ -356,8 +364,13 @@ export interface SiteMetaOptions { /** Localized site description for non-English builds. Used in the llms.txt * manifest blockquote instead of the (typically English) `description`. */ localizedDescription?: string; - /** Path to the source markdown files. */ - docsDir?: string; + /** + * Path to the source markdown files, or several such roots ordered + * highest precedence first when the site overlays content roots. A root + * may carry its own `exclude` globs — those subtrees are treated as absent + * from it, so the next root supplies the slug. + */ + docsDir?: DocsContentRoot | DocsContentRoot[]; sidebar?: SidebarEntry[]; platform?: PlatformKey | null; navLang?: NavLang; @@ -410,8 +423,11 @@ export function siteMetaIntegration({ packages = [], selectedPackage = '', }: SiteMetaOptions = {} as SiteMetaOptions): AstroIntegration { - const llmsMetaMap = docsDir - ? buildLlmsMetaMap(docsDir, sidebar ?? []) + // Content roots, highest precedence first. A page's metadata and its raw + // source are both read from the first root that supplies its slug. + const docsDirs = toRootList(docsDir); + const llmsMetaMap = docsDirs.length + ? buildLlmsMetaMap(docsDirs, sidebar ?? []) : new Map(); const virtualId = 'virtual:docs-template/site-meta'; @@ -508,7 +524,7 @@ export const selectedPackage = ${JSON.stringify(selectedPackage)}; }, 'astro:server:setup'({ server }) { - if (!docsDir) return; + if (!docsDirs.length) return; // Dev-mode convenience: serve raw source MDX/MD files for /{slug}.md // requests so LLM clients can fetch Markdown during local development // without a full production build. The HTML→MD pipeline only runs at @@ -518,14 +534,14 @@ export const selectedPackage = ${JSON.stringify(selectedPackage)}; if (!req.url?.endsWith('.md')) return next(); // Strip leading slash and .md suffix to get the slug const slug = req.url.slice(1, -3); - for (const ext of ['.md', '.mdx']) { - const src = path.join(docsDir, slug + ext); + const src = findFirstInRoots(docsDirs, ['.md', '.mdx'].map(ext => slug + ext)); + if (src) { try { const raw = await fsp.readFile(src, 'utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(raw); return; - } catch { /* try next extension */ } + } catch { /* fall through to the next middleware */ } } next(); }); @@ -548,7 +564,7 @@ export const selectedPackage = ${JSON.stringify(selectedPackage)}; // Include a UTF-8 signature because static preview servers may omit charset. await fsp.writeFile(path.join(outDir, 'llms.txt'), withUtf8Bom(llmsContent), 'utf-8'); - if (docsDir) { + if (docsDirs.length) { const slugs = pages .map(p => p.pathname.replace(/\/$/, '')) .filter(s => s && s !== '404' && s !== 'index'); @@ -556,7 +572,7 @@ export const selectedPackage = ${JSON.stringify(selectedPackage)}; await generateLlmsMdFiles({ outDir, slugs, - docsDir, + docsDirs, siteUrl: configuredSite, llmsSets, }); @@ -639,8 +655,22 @@ function createBasePrependIntegration(base: string): AstroIntegration { export interface DocsSiteSource { /** Absolute path to the TOC file. */ tocPath: string; - /** Absolute path to the Markdown docs directory. */ + /** + * Absolute path to the site's own Markdown docs directory. This stays the + * site's "home" root: sibling files such as `environment.json` are looked + * up next to it, and it is the lowest-precedence content root. + */ docsDir: string; + /** + * Extra content roots overlaid on `docsDir`, ordered highest precedence + * first. All roots share one slug namespace, so a page present in an + * overlay replaces the `docsDir` page with the same slug — nothing is + * copied between the trees. + * + * Roots that do not exist on disk are ignored, so a language an upstream + * generator does not emit simply has no overlay. + */ + overlayDirs?: DocsContentRoot[]; } export interface CreateDocsSiteOptions { @@ -727,16 +757,26 @@ export function createDocsSite(options: CreateDocsSiteOptions = {} as CreateDocs ...astroExtra } = options; + // Ordered content roots, highest precedence first: any overlays, then the + // site's own docs directory. Overlays that are absent on disk are dropped. + const docRoots = resolveDocRoots(source.docsDir, source.overlayDirs); + const sidebar = buildSidebarFromToc({ tocPath: source.tocPath!, - docsDir: source.docsDir!, + docsDir: docRoots, exclude: sidebarOptions.exclude ?? [], }); // Expose env vars so consuming content.config.ts and components can read them. + // DOCS_SOURCE_PATH stays the site's own root — `environment.json` and other + // siblings are resolved against it — while DOCS_SOURCE_PATHS carries the + // full precedence-ordered list for the content loader and remark plugins. if (source.docsDir) { process.env.DOCS_SOURCE_PATH = source.docsDir; } + if (docRoots.length) { + process.env[DOC_ROOTS_ENV] = serializeDocRoots(docRoots); + } process.env.DOCS_BUILD_MODE = mode; process.env.DOCS_BASE = base ? base.replace(/\/$/, '') : ''; process.env.DOCS_TRAILING_SLASH = (astroExtra.trailingSlash as string) ?? 'ignore'; @@ -872,7 +912,7 @@ export function createDocsSite(options: CreateDocsSiteOptions = {} as CreateDocs title, description, localizedDescription, - docsDir: source.docsDir, + docsDir: docRoots, sidebar, platform, navLang, diff --git a/src/lib/api-platform-config.test.ts b/src/lib/api-platform-config.test.ts new file mode 100644 index 0000000000..b19a52f17a --- /dev/null +++ b/src/lib/api-platform-config.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { + API_PLATFORM_CONFIGS, + apiDocRoot, + apiDocsPlatformPath, + createApiPackages, + getPackageClassSuffixes, + getPackageIds, + PLATFORM_MAP, + type PlatformName, +} from './api-platform-config.ts'; + +const PLATFORMS = Object.keys(API_PLATFORM_CONFIGS) as PlatformName[]; +const BASE = 'https://staging.example.test/api'; + +describe('apiDocsPlatformPath', () => { + it.each([ + ['Angular', 'angular'], + ['React', 'react'], + ['WebComponents', 'webcomponents'], + ['Blazor', 'blazor'], + ] as Array<[PlatformName, string]>)('maps %s to the %s folder', (platform, folder) => { + expect(apiDocsPlatformPath(platform)).toBe(folder); + }); +}); + +describe('apiDocRoot', () => { + it('joins the base URL, the platform folder, the package and the version', () => { + expect(apiDocRoot(BASE, 'Angular', 'igniteui-angular-charts')) + .toBe('https://staging.example.test/api/angular/igniteui-angular-charts/latest'); + }); + + it.each(PLATFORMS)('uses the folder of %s', platform => { + expect(apiDocRoot(BASE, platform, 'pkg')) + .toBe(`${BASE}/${apiDocsPlatformPath(platform)}/pkg/latest`); + }); +}); + +describe('createApiPackages', () => { + it.each(PLATFORMS)('returns one entry per package for %s', platform => { + const packages = createApiPackages(BASE, platform); + const definitions = API_PLATFORM_CONFIGS[platform].apiPackages; + + expect(Object.keys(packages)).toEqual(Object.keys(definitions)); + }); + + it('builds each docRoot from the given base URL', () => { + const packages = createApiPackages(BASE, 'React'); + + expect(packages['charts'].docRoot).toBe(`${BASE}/react/igniteui-react-charts/latest`); + expect(packages['core'].docRoot).toBe(`${BASE}/react/igniteui-react/latest`); + }); + + it('keeps the definition fields and adds the runtime flags', () => { + expect(createApiPackages(BASE, 'Angular')['core']).toEqual({ + packageId: 'igniteui-angular', + classSuffix: 'Component', + docRoot: `${BASE}/angular/igniteui-angular/latest`, + noPackagePrefix: true, + preserveCase: true, + }); + }); + + it('carries the pascalCaseMembers flag through for Blazor', () => { + expect(createApiPackages(BASE, 'Blazor')['core'].pascalCaseMembers).toBe(true); + }); +}); + +describe('getPackageIds', () => { + it.each(PLATFORMS)('returns a package id for every key of %s', platform => { + const ids = getPackageIds(platform); + const definitions = API_PLATFORM_CONFIGS[platform].apiPackages; + + expect(Object.keys(ids)).toEqual(Object.keys(definitions)); + for (const [key, id] of Object.entries(ids)) { + expect(id).toBe(definitions[key].packageId); + } + }); + + it('maps every Blazor key onto an IgniteUI.Blazor package', () => { + for (const id of Object.values(getPackageIds('Blazor'))) { + expect(id.startsWith('IgniteUI.Blazor')).toBe(true); + } + }); +}); + +describe('getPackageClassSuffixes', () => { + it('returns Component for the Angular packages that declare it', () => { + const suffixes = getPackageClassSuffixes('Angular'); + + expect(suffixes['core']).toBe('Component'); + expect(suffixes['excel']).toBeUndefined(); + }); + + it('leaves every React suffix undefined', () => { + const suffixes = getPackageClassSuffixes('React'); + + expect(Object.values(suffixes).every(value => value === undefined)).toBe(true); + }); + + it.each(PLATFORMS)('returns an entry for every key of %s', platform => { + expect(Object.keys(getPackageClassSuffixes(platform))) + .toEqual(Object.keys(API_PLATFORM_CONFIGS[platform].apiPackages)); + }); +}); + +describe('PLATFORM_MAP', () => { + it('maps each short name to a configured platform', () => { + expect(PLATFORM_MAP).toEqual({ + angular: 'Angular', + react: 'React', + wc: 'WebComponents', + blazor: 'Blazor', + }); + }); + + it('covers every platform in the config registry', () => { + expect(new Set(Object.values(PLATFORM_MAP))).toEqual(new Set(PLATFORMS)); + }); +}); diff --git a/src/lib/doc-roots.test.ts b/src/lib/doc-roots.test.ts new file mode 100644 index 0000000000..abc535d511 --- /dev/null +++ b/src/lib/doc-roots.test.ts @@ -0,0 +1,365 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DOC_ROOTS_ENV, + docRootsFromEnv, + findFirstInRoots, + findInRoots, + isExcludedFromRoot, + normalizeRoot, + resolveDocRoots, + rootDirs, + rootForFile, + serializeDocRoots, + toRootList, + type ResolvedDocRoot, +} from './doc-roots.ts'; + +let tmp: string; + +/** A root object for the exclude tests; the directory never has to exist. */ +const rootWith = (...exclude: string[]): ResolvedDocRoot => ({ dir: path.resolve('/docs'), exclude }); + +/** Creates `/` and returns its absolute path. */ +function makeDir(name: string): string { + const dir = path.join(tmp, name); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +/** Writes an empty file under `dir`, creating parent directories. */ +function makeFile(dir: string, relPath: string): string { + const file = path.join(dir, relPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, ''); + return file; +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-roots-')); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('isExcludedFromRoot', () => { + it('returns false when the root excludes nothing', () => { + expect(isExcludedFromRoot(rootWith(), 'changelog/a.mdx')).toBe(false); + }); + + it('matches a whole subtree with a trailing double star', () => { + const root = rootWith('changelog/**'); + + expect(isExcludedFromRoot(root, 'changelog/a.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'changelog/x/y.mdx')).toBe(true); + }); + + it('does not let a subtree pattern match a longer directory name', () => { + expect(isExcludedFromRoot(rootWith('changelog/**'), 'changelogs/a.mdx')).toBe(false); + }); + + it('matches only one level with a single star', () => { + const root = rootWith('internal/*.mdx'); + + expect(isExcludedFromRoot(root, 'internal/notes.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'internal/deep/notes.mdx')).toBe(false); + }); + + it('matches at any depth with a leading double star', () => { + const root = rootWith('**/_*.mdx'); + + expect(isExcludedFromRoot(root, '_draft.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'grids/_draft.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'grids/data-grid/_draft.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'grids/draft.mdx')).toBe(false); + }); + + it('matches exactly one non-slash character with a question mark', () => { + const root = rootWith('a?.mdx'); + + expect(isExcludedFromRoot(root, 'ab.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'abc.mdx')).toBe(false); + expect(isExcludedFromRoot(root, 'a.mdx')).toBe(false); + expect(isExcludedFromRoot(root, 'a/.mdx')).toBe(false); + }); + + it('treats regex metacharacters as literals', () => { + const root = rootWith('a.b'); + + expect(isExcludedFromRoot(root, 'a.b')).toBe(true); + expect(isExcludedFromRoot(root, 'axb')).toBe(false); + }); + + it('ignores a leading negation marker', () => { + expect(isExcludedFromRoot(rootWith('!changelog/**'), 'changelog/a.mdx')).toBe(true); + }); + + it('normalises Windows separators in the path being tested', () => { + expect(isExcludedFromRoot(rootWith('changelog/**'), 'changelog\\x\\y.mdx')).toBe(true); + }); + + it('is true when any of several patterns matches', () => { + const root = rootWith('changelog/**', 'grids/**'); + + expect(isExcludedFromRoot(root, 'grids/data-grid.mdx')).toBe(true); + expect(isExcludedFromRoot(root, 'charts/pie.mdx')).toBe(false); + }); +}); + +describe('normalizeRoot', () => { + it('absolutises a string root and gives it an empty exclude list', () => { + expect(normalizeRoot('docs/content')).toEqual({ + dir: path.resolve('docs/content'), + exclude: [], + }); + }); + + it('absolutises an object root and keeps its excludes', () => { + expect(normalizeRoot({ dir: 'docs/content', exclude: ['changelog/**'] })).toEqual({ + dir: path.resolve('docs/content'), + exclude: ['changelog/**'], + }); + }); + + it('fills in a missing exclude list on an object root', () => { + expect(normalizeRoot({ dir: tmp }).exclude).toEqual([]); + }); + + it('copies the exclude list so the result cannot mutate the input', () => { + const input = { dir: tmp, exclude: ['changelog/**'] }; + const result = normalizeRoot(input); + result.exclude.push('grids/**'); + + expect(input.exclude).toEqual(['changelog/**']); + }); + + it('leaves an already absolute directory alone', () => { + expect(normalizeRoot(tmp).dir).toBe(path.resolve(tmp)); + }); +}); + +describe('resolveDocRoots', () => { + it('lists the overlays before the base directory', () => { + const base = makeDir('base'); + const overlay = makeDir('overlay'); + + expect(resolveDocRoots(base, [overlay]).map(r => r.dir)).toEqual([overlay, base]); + }); + + it('de-duplicates repeated directories, keeping the first entry', () => { + const base = makeDir('base'); + + const roots = resolveDocRoots(base, [{ dir: base, exclude: ['changelog/**'] }]); + + expect(roots).toHaveLength(1); + expect(roots[0].exclude).toEqual(['changelog/**']); + }); + + it('drops roots that do not exist on disk', () => { + const base = makeDir('base'); + + expect(resolveDocRoots(base, [path.join(tmp, 'no-such-overlay')]).map(r => r.dir)).toEqual([base]); + }); + + it('returns an empty list when there is no base and no overlay', () => { + expect(resolveDocRoots(undefined)).toEqual([]); + }); + + it('returns only the overlays when the base is undefined', () => { + const overlay = makeDir('overlay'); + + expect(resolveDocRoots(undefined, [overlay]).map(r => r.dir)).toEqual([overlay]); + }); + + it('keeps the excludes of each root', () => { + const base = makeDir('base'); + const overlay = makeDir('overlay'); + + const roots = resolveDocRoots(base, [{ dir: overlay, exclude: ['changelog/**'] }]); + + expect(roots.map(r => r.exclude)).toEqual([['changelog/**'], []]); + }); +}); + +describe('toRootList', () => { + it('returns an empty list for undefined', () => { + expect(toRootList(undefined)).toEqual([]); + }); + + it('wraps a single root into a one-entry list', () => { + expect(toRootList(tmp)).toEqual([{ dir: path.resolve(tmp), exclude: [] }]); + }); + + it('normalises every entry of an array', () => { + expect(toRootList([tmp, { dir: tmp, exclude: ['a/**'] }])).toEqual([ + { dir: path.resolve(tmp), exclude: [] }, + { dir: path.resolve(tmp), exclude: ['a/**'] }, + ]); + }); + + it('drops falsy entries of an array', () => { + expect(toRootList([tmp, '', undefined as never])).toHaveLength(1); + }); + + it('returns an empty list for an empty array', () => { + expect(toRootList([])).toEqual([]); + }); +}); + +describe('rootDirs', () => { + it('returns the bare directories in order', () => { + expect(rootDirs([tmp, { dir: path.join(tmp, 'overlay') }])) + .toEqual([path.resolve(tmp), path.resolve(path.join(tmp, 'overlay'))]); + }); + + it('returns an empty list for an empty root list', () => { + expect(rootDirs([])).toEqual([]); + }); +}); + +describe('findInRoots', () => { + it('returns the file from the first root that has it', () => { + const overlay = makeDir('overlay'); + const base = makeDir('base'); + const wanted = makeFile(overlay, 'a.mdx'); + makeFile(base, 'a.mdx'); + + expect(findInRoots([overlay, base], 'a.mdx')).toBe(wanted); + }); + + it('falls through to the next root when the first excludes the path', () => { + const overlay = makeDir('overlay'); + const base = makeDir('base'); + makeFile(overlay, 'changelog/a.mdx'); + const wanted = makeFile(base, 'changelog/a.mdx'); + + expect(findInRoots([{ dir: overlay, exclude: ['changelog/**'] }, base], 'changelog/a.mdx')).toBe(wanted); + }); + + it('returns undefined when no root has the file', () => { + expect(findInRoots([makeDir('base')], 'missing.mdx')).toBeUndefined(); + }); + + it('returns undefined for an empty root list', () => { + expect(findInRoots([], 'a.mdx')).toBeUndefined(); + }); +}); + +describe('findFirstInRoots', () => { + it('checks a root against every candidate before moving to the next root', () => { + const overlay = makeDir('overlay'); + const base = makeDir('base'); + const wanted = makeFile(overlay, 'a.md'); + makeFile(base, 'a.mdx'); + + // The overlay's `.md` wins over the base's `.mdx` even though `.mdx` + // comes first in the candidate list. + expect(findFirstInRoots([overlay, base], ['a.mdx', 'a.md'])).toBe(wanted); + }); + + it('prefers the earlier candidate within one root', () => { + const base = makeDir('base'); + const wanted = makeFile(base, 'a.mdx'); + makeFile(base, 'a.md'); + + expect(findFirstInRoots([base], ['a.mdx', 'a.md'])).toBe(wanted); + }); + + it('skips candidates the root excludes', () => { + const overlay = makeDir('overlay'); + const base = makeDir('base'); + makeFile(overlay, 'changelog/a.mdx'); + const wanted = makeFile(base, 'changelog/a.mdx'); + + expect(findFirstInRoots( + [{ dir: overlay, exclude: ['changelog/**'] }, base], + ['changelog/a.mdx', 'changelog/a.md'], + )).toBe(wanted); + }); + + it('returns undefined when no candidate exists anywhere', () => { + expect(findFirstInRoots([makeDir('base')], ['a.mdx', 'a.md'])).toBeUndefined(); + }); +}); + +describe('rootForFile', () => { + it('returns the root a file lives under', () => { + const base = makeDir('base'); + + expect(rootForFile([base], path.join(base, 'grids', 'a.mdx'))).toBe(path.resolve(base)); + }); + + it('picks the longest matching root when roots are nested', () => { + const outer = makeDir('outer'); + const inner = makeDir(path.join('outer', 'inner')); + + expect(rootForFile([outer, inner], path.join(inner, 'a.mdx'))).toBe(path.resolve(inner)); + }); + + it('does not match a directory that only shares a name prefix', () => { + const base = makeDir('base'); + const sibling = makeDir('base-other'); + + expect(rootForFile([base], path.join(sibling, 'a.mdx'))).toBeUndefined(); + }); + + it('returns undefined for a file outside every root', () => { + expect(rootForFile([makeDir('base')], path.join(tmp, 'elsewhere', 'a.mdx'))).toBeUndefined(); + }); + + it('returns undefined for an empty root list', () => { + expect(rootForFile([], path.join(tmp, 'a.mdx'))).toBeUndefined(); + }); +}); + +describe('serializeDocRoots and docRootsFromEnv', () => { + it('serialises a root without excludes as a bare string', () => { + expect(serializeDocRoots([{ dir: '/docs', exclude: [] }])).toBe('["/docs"]'); + }); + + it('serialises a root with excludes as an object', () => { + expect(serializeDocRoots([{ dir: '/docs', exclude: ['changelog/**'] }])) + .toBe('[{"dir":"/docs","exclude":["changelog/**"]}]'); + }); + + it('round-trips a mixed root list through the environment', () => { + const roots: ResolvedDocRoot[] = [ + { dir: path.resolve(tmp, 'overlay'), exclude: ['changelog/**'] }, + { dir: path.resolve(tmp, 'base'), exclude: [] }, + ]; + vi.stubEnv(DOC_ROOTS_ENV, serializeDocRoots(roots)); + + expect(docRootsFromEnv()).toEqual(roots); + }); + + it('falls back to DOCS_SOURCE_PATH when the root list is malformed JSON', () => { + vi.stubEnv(DOC_ROOTS_ENV, '{not json'); + vi.stubEnv('DOCS_SOURCE_PATH', tmp); + + expect(docRootsFromEnv()).toEqual([{ dir: path.resolve(tmp), exclude: [] }]); + }); + + it('falls back to DOCS_SOURCE_PATH when the root list is not an array', () => { + vi.stubEnv(DOC_ROOTS_ENV, '{"dir":"/docs"}'); + vi.stubEnv('DOCS_SOURCE_PATH', tmp); + + expect(docRootsFromEnv()).toEqual([{ dir: path.resolve(tmp), exclude: [] }]); + }); + + it('drops entries of the wrong shape', () => { + vi.stubEnv(DOC_ROOTS_ENV, JSON.stringify(['/docs', 42, null, { exclude: [] }])); + + expect(docRootsFromEnv()).toEqual([{ dir: path.resolve('/docs'), exclude: [] }]); + }); + + it('returns an empty list when neither variable is set', () => { + vi.stubEnv(DOC_ROOTS_ENV, undefined); + vi.stubEnv('DOCS_SOURCE_PATH', undefined); + + expect(docRootsFromEnv()).toEqual([]); + }); +}); diff --git a/src/lib/doc-roots.ts b/src/lib/doc-roots.ts new file mode 100644 index 0000000000..26806cbcb8 --- /dev/null +++ b/src/lib/doc-roots.ts @@ -0,0 +1,267 @@ +/** + * doc-roots.ts + * + * Helpers for docs sites whose content is assembled from more than one + * directory on disk. + * + * A docs site has one *base* content root (`source.docsDir`) and zero or more + * *overlay* roots (`source.overlayDirs`). All roots share a single slug + * namespace: a file at `/charts/types/area-chart.mdx` is always the page + * `/charts/types/area-chart`, whichever root it came from. + * + * When the same slug exists in several roots, the overlay wins. This is how the + * Angular docs consume the cross-platform (xplat) generator: the generated + * topics under `docs/xplat/generated/Angular/{lang}/components` override the + * hand-authored ones under `docs/angular/src/content/{lang}/components`, without + * anything ever being copied into the tracked tree. + * + * ── Ordering convention ────────────────────────────────────────────────────── + * Every function here takes and returns roots **highest precedence first**, so + * a plain `for` loop over the list resolves a slug the same way the site does. + * The content loader follows the same convention: it loads the roots in list + * order and refuses writes from a lower-precedence root for any id a higher one + * has already claimed, so the *first* root to provide a slug is the one that + * wins. Never reverse the list before handing it to a consumer. + * + * ── Excludes ──────────────────────────────────────────────────────────────── + * A root may carry its own `exclude` globs — subtrees it contributes on disk but + * that the site does not serve (the Angular site keeps `changelog/` and `grids/` + * for itself, for example). The excludes travel with the root through + * `DOCS_SOURCE_PATHS`, so the content loader, the sidebar, the llms.txt metadata + * and the dev-mode raw-Markdown middleware all resolve a slug to the same file. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Env var carrying the full ordered root list as a JSON array. */ +export const DOC_ROOTS_ENV = 'DOCS_SOURCE_PATHS'; + +/** + * A content root. Use the object form to give one root its own exclusions — + * for example, keeping a generated tree's `changelog/` out of a site that + * maintains its own. + */ +export type DocsContentRoot = string | { dir: string; exclude?: string[] }; + +/** A content root with its directory absolutized and its excludes normalized. */ +export interface ResolvedDocRoot { + /** Absolute path of the root directory. */ + dir: string; + /** Glob patterns, relative to `dir`, that this root does not contribute. */ + exclude: string[]; +} + +/** Absolutizes a root and fills in a missing `exclude` list. */ +export function normalizeRoot(root: DocsContentRoot): ResolvedDocRoot { + return typeof root === 'string' + ? { dir: path.resolve(root), exclude: [] } + : { dir: path.resolve(root.dir), exclude: [...(root.exclude ?? [])] }; +} + +// --------------------------------------------------------------------------- +// Exclude matching +// --------------------------------------------------------------------------- + +/** + * Compiles one of the simple globs used by root excludes (`changelog/**`, + * `grids/**`, `internal/*.mdx`) into a regular expression. + * + * Supported: `**` (any number of path segments), `*` (anything but `/`) and + * `?` (a single character but `/`). That is the whole vocabulary the excludes + * use, which keeps this dependency-free — the content loader hands the same + * patterns to Astro's glob loader, which understands a much larger grammar, + * so anything fancier belongs there rather than here. + */ +function globToRegExp(pattern: string): RegExp { + const src = pattern.replace(/^!/, ''); + let body = ''; + + for (let i = 0; i < src.length; i++) { + const char = src[i]; + if (char === '*') { + if (src[i + 1] === '*') { + if (src[i + 2] === '/') { + // `**/` spans zero or more whole path segments. + body += '(?:[^/]*/)*'; + i += 2; + } else { + // A trailing `**` matches everything below this point. + body += '.*'; + i += 1; + } + } else { + body += '[^/]*'; + } + } else if (char === '?') { + body += '[^/]'; + } else if (char && '.+^${}()|[]'.includes(char)) { + body += '\\' + char; + } else if (char === '\\') { + body += '\\\\'; + } else { + body += char; + } + } + + return new RegExp(`^${body}$`); +} + +const globCache = new Map(); + +function matchesGlob(pattern: string, relPath: string): boolean { + let re = globCache.get(pattern); + if (!re) { + re = globToRegExp(pattern); + globCache.set(pattern, re); + } + return re.test(relPath); +} + +/** + * True when `relPath` (root-relative, POSIX separators) is excluded from `root`. + * An excluded path is treated as absent: the next root gets a chance to supply + * the slug, exactly as the content loader resolves it. + */ +export function isExcludedFromRoot(root: ResolvedDocRoot, relPath: string): boolean { + if (!root.exclude.length) return false; + const normalized = relPath.replace(/\\/g, '/'); + return root.exclude.some(pattern => matchesGlob(pattern, normalized)); +} + +// --------------------------------------------------------------------------- +// Root lists +// --------------------------------------------------------------------------- + +/** + * Normalizes a base dir plus optional overlays into an ordered, de-duplicated + * list of roots, highest precedence first. + * + * Roots that do not exist on disk are dropped — a language the xplat generator + * does not emit (`kr`, today) simply has no overlay rather than a broken one. + */ +export function resolveDocRoots( + docsDir: DocsContentRoot | undefined, + overlayDirs: readonly DocsContentRoot[] = [], +): ResolvedDocRoot[] { + const ordered = [...overlayDirs, docsDir].filter((d): d is DocsContentRoot => Boolean(d)); + const seen = new Set(); + const roots: ResolvedDocRoot[] = []; + + for (const entry of ordered) { + const root = normalizeRoot(entry); + if (!root.dir || seen.has(root.dir)) continue; + seen.add(root.dir); + if (fs.existsSync(root.dir)) roots.push(root); + } + + return roots; +} + +/** Coerces the shapes accepted by the public options into a resolved root list. */ +export function toRootList( + docsDir: DocsContentRoot | readonly DocsContentRoot[] | undefined, +): ResolvedDocRoot[] { + if (!docsDir) return []; + const list = Array.isArray(docsDir) + ? (docsDir as readonly DocsContentRoot[]) + : [docsDir as DocsContentRoot]; + return list.filter(Boolean).map(normalizeRoot); +} + +/** The bare directory paths of a root list, for consumers that only need those. */ +export function rootDirs(roots: readonly DocsContentRoot[]): string[] { + return toRootList(roots).map(r => r.dir); +} + +/** + * Returns the absolute path of the first root that contains `relPath`, + * or `undefined` when no root does. Paths a root excludes are skipped, so a + * root never answers for a page the site does not serve from it. + */ +export function findInRoots( + roots: readonly DocsContentRoot[], + relPath: string, +): string | undefined { + for (const root of toRootList(roots)) { + if (isExcludedFromRoot(root, relPath)) continue; + const candidate = path.join(root.dir, relPath); + if (fs.existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * Returns the first existing file among `relPaths`, searched root-by-root in + * precedence order. Each root is fully checked before moving to the next, so an + * overlay's `.md` beats the base's `.mdx` — matching how the loader resolves a + * slug that exists in both roots. Excluded paths are skipped, for the same + * reason as in `findInRoots`. + */ +export function findFirstInRoots( + roots: readonly DocsContentRoot[], + relPaths: readonly string[], +): string | undefined { + for (const root of toRootList(roots)) { + for (const relPath of relPaths) { + if (isExcludedFromRoot(root, relPath)) continue; + const candidate = path.join(root.dir, relPath); + if (fs.existsSync(candidate)) return candidate; + } + } + return undefined; +} + +/** + * Returns the root directory that `filePath` lives under, or `undefined` when it + * lives outside every root. Used to turn an absolute source path back into a + * slug — the relative path must be computed against the file's *own* root, not + * the highest-precedence one, or cross-root links resolve to the wrong slug. + * + * The longest matching root wins, so nested roots behave sensibly. + */ +export function rootForFile( + roots: readonly DocsContentRoot[], + filePath: string, +): string | undefined { + const abs = path.resolve(filePath); + let best: string | undefined; + + for (const { dir } of toRootList(roots)) { + const withSep = dir.endsWith(path.sep) ? dir : dir + path.sep; + if (!abs.startsWith(withSep)) continue; + if (!best || dir.length > best.length) best = dir; + } + + return best; +} + +/** Serializes a root list for `DOCS_SOURCE_PATHS`. */ +export function serializeDocRoots(roots: readonly ResolvedDocRoot[]): string { + return JSON.stringify( + roots.map(r => (r.exclude.length ? { dir: r.dir, exclude: r.exclude } : r.dir)), + ); +} + +/** + * Reads the ordered root list published by `createDocsSite`, excludes included. + * Falls back to the single `DOCS_SOURCE_PATH` for sites that never set overlays. + */ +export function docRootsFromEnv(): ResolvedDocRoot[] { + const raw = process.env[DOC_ROOTS_ENV]; + if (raw) { + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .filter((entry): entry is DocsContentRoot => + typeof entry === 'string' || + (typeof entry === 'object' && entry !== null && 'dir' in entry)) + .map(normalizeRoot); + } + } catch { /* fall through to the single-path form */ } + } + return process.env.DOCS_SOURCE_PATH + ? [normalizeRoot(process.env.DOCS_SOURCE_PATH)] + : []; +} diff --git a/src/lib/platform-context.test.ts b/src/lib/platform-context.test.ts new file mode 100644 index 0000000000..5b9d0acb93 --- /dev/null +++ b/src/lib/platform-context.test.ts @@ -0,0 +1,292 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PlatformName } from './api-platform-config.ts'; + +const STAGING = 'https://staging.infragistics.com/api'; +const PLATFORMS: PlatformName[] = ['Angular', 'React', 'WebComponents', 'Blazor']; + +let tmp: string; + +/** Loads a fresh copy of the module so its `_ctx` / `_env` caches start empty. */ +async function loadModule() { + vi.resetModules(); + return import('./platform-context.ts'); +} + +/** Points `process.cwd()` at the temp dir the test populated. */ +function useTmpAsCwd(): void { + vi.spyOn(process, 'cwd').mockReturnValue(tmp); +} + +function writeJson(relPath: string, data: unknown): void { + const file = path.join(tmp, relPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data)); +} + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'platform-ctx-')); + // A clean slate: every env var the module reads is removed by default. + // The module uses `??`, so an empty string would be a *value*, not "unset". + for (const name of ['PLATFORM', 'DOCS_ENV', 'NODE_ENV', 'API_DOCS_BASE_URL', 'LANG_CODE', 'API_LINK_INDEX_VERSION', 'DOCS_SOURCE_PATH', 'DOCS_PROJECT_ROOT', 'GTM_CONTAINER_ID']) { + vi.stubEnv(name, undefined); + } + vi.stubEnv('DOCS_ENV', 'development'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('getPlatformContext', () => { + it.each(PLATFORMS)('returns the context of %s when named explicitly', async name => { + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + const ctx = getPlatformContext(name); + + expect(ctx.name).toBe(name); + expect(Object.keys(ctx.packages)).toEqual(['common', 'charts', 'grids', 'gauges', 'maps']); + }); + + it.each(PLATFORMS)('roots every %s API package at the staging API docs base', async name => { + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + const roots = Object.values(getPlatformContext(name).apiPackages).map(pkg => pkg.docRoot); + + expect(roots.length).toBeGreaterThan(0); + expect(roots.every(root => root.startsWith(`${STAGING}/`))).toBe(true); + }); + + it('uses the production API docs base in production', async () => { + useTmpAsCwd(); + vi.stubEnv('DOCS_ENV', 'production'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext('Angular').apiPackages['core'].docRoot) + .toBe('https://www.infragistics.com/api/angular/igniteui-angular/latest'); + }); + + it('rewrites the docRoot from API_DOCS_BASE_URL', async () => { + useTmpAsCwd(); + vi.stubEnv('API_DOCS_BASE_URL', 'https://custom.test/api'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext('React').apiPackages['charts'].docRoot) + .toBe('https://custom.test/api/react/igniteui-react-charts/latest'); + }); + + it('appends the /api segment to an API_DOCS_BASE_URL that lacks it', async () => { + useTmpAsCwd(); + vi.stubEnv('API_DOCS_BASE_URL', 'https://custom.test/'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext('React').apiPackages['charts'].docRoot) + .toBe('https://custom.test/api/react/igniteui-react-charts/latest'); + }); + + it('resolves the platform from the PLATFORM env var', async () => { + useTmpAsCwd(); + vi.stubEnv('PLATFORM', 'Blazor'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('Blazor'); + }); + + it('falls back to .platform.json in the working directory', async () => { + writeJson('.platform.json', { platform: 'WebComponents' }); + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('WebComponents'); + }); + + it('prefers the PLATFORM env var over .platform.json', async () => { + writeJson('.platform.json', { platform: 'WebComponents' }); + useTmpAsCwd(); + vi.stubEnv('PLATFORM', 'Angular'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('Angular'); + }); + + it('defaults to React when nothing selects a platform', async () => { + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('React'); + }); + + it('falls back to React for an unknown PLATFORM value', async () => { + useTmpAsCwd(); + vi.stubEnv('PLATFORM', 'Vue'); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('React'); + }); + + it('falls back to React for an unknown platform in .platform.json', async () => { + writeJson('.platform.json', { platform: 'Vue' }); + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext().name).toBe('React'); + }); + + it('caches the no-arg context but not the explicitly named one', async () => { + useTmpAsCwd(); + const { getPlatformContext } = await loadModule(); + + expect(getPlatformContext()).toBe(getPlatformContext()); + expect(getPlatformContext('Angular')).not.toBe(getPlatformContext('Angular')); + }); +}); + +describe('getEnvVars', () => { + it('reads the generated environment.json for the platform, locale and mode', async () => { + writeJson('generated/React/en/environment.json', { + development: { demosBaseUrl: 'https://dev.test' }, + production: { demosBaseUrl: 'https://prod.test' }, + }); + useTmpAsCwd(); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ demosBaseUrl: 'https://dev.test' }); + }); + + it('honours LANG_CODE when locating the generated file', async () => { + writeJson('generated/React/jp/environment.json', { development: { demosBaseUrl: 'https://jp.test' } }); + useTmpAsCwd(); + vi.stubEnv('LANG_CODE', 'jp'); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ demosBaseUrl: 'https://jp.test' }); + }); + + it('falls back to the development block for an unknown mode', async () => { + writeJson('generated/React/en/environment.json', { development: { demosBaseUrl: 'https://dev.test' } }); + useTmpAsCwd(); + vi.stubEnv('DOCS_ENV', 'staging'); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ demosBaseUrl: 'https://dev.test' }); + }); + + it('falls back to an environment.json under DOCS_SOURCE_PATH', async () => { + const source = path.join(tmp, 'source'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync( + path.join(source, 'environment.json'), + JSON.stringify({ development: { demosBaseUrl: 'https://source.test' } }), + ); + useTmpAsCwd(); + vi.stubEnv('DOCS_SOURCE_PATH', source); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ demosBaseUrl: 'https://source.test' }); + }); + + it('prefers the en/environment.json under DOCS_SOURCE_PATH', async () => { + const source = path.join(tmp, 'source'); + fs.mkdirSync(path.join(source, 'en'), { recursive: true }); + fs.writeFileSync( + path.join(source, 'environment.json'), + JSON.stringify({ development: { demosBaseUrl: 'https://root.test' } }), + ); + fs.writeFileSync( + path.join(source, 'en', 'environment.json'), + JSON.stringify({ development: { demosBaseUrl: 'https://en.test' } }), + ); + useTmpAsCwd(); + vi.stubEnv('DOCS_SOURCE_PATH', source); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ demosBaseUrl: 'https://en.test' }); + }); + + it('falls back to the samplesBrowsers block of docConfig.json', async () => { + writeJson('docConfig.json', { React: { samplesBrowsers: { development: 'https://demos.test' } } }); + useTmpAsCwd(); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({ + dvDemosBaseUrl: 'https://demos.test', + demosBaseUrl: 'https://demos.test', + infragisticsBaseUrl: 'https://www.infragistics.com', + }); + }); + + it('returns an empty object when no environment source exists', async () => { + useTmpAsCwd(); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toEqual({}); + }); + + it('caches the result for the build lifetime', async () => { + writeJson('generated/React/en/environment.json', { development: { demosBaseUrl: 'https://dev.test' } }); + useTmpAsCwd(); + const { getEnvVars } = await loadModule(); + + expect(getEnvVars()).toBe(getEnvVars()); + }); +}); + +describe('getGtmContainerId', () => { + it('returns the explicit GTM_CONTAINER_ID override', async () => { + useTmpAsCwd(); + vi.stubEnv('GTM_CONTAINER_ID', 'GTM-OVERRIDE'); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe('GTM-OVERRIDE'); + }); + + it('reads GTMContainerId out of the resolved environment.json', async () => { + writeJson('generated/React/en/environment.json', { development: { GTMContainerId: 'GTM-FROMJSON' } }); + useTmpAsCwd(); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe('GTM-FROMJSON'); + }); + + it.each([ + ['development', 'GTM-WLXLBZD'], + ['staging', 'GTM-NCKNPN'], + ['production', 'GTM-T65CF7'], + ])('falls back to the built-in English id for %s', async (mode, expected) => { + useTmpAsCwd(); + vi.stubEnv('DOCS_ENV', mode); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe(expected); + }); + + it('uses the Japanese defaults for LANG_CODE jp', async () => { + useTmpAsCwd(); + vi.stubEnv('LANG_CODE', 'jp'); + vi.stubEnv('DOCS_ENV', 'production'); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe('GTM-KVNSWJ'); + }); + + it('falls back to the English defaults for a locale without its own set', async () => { + useTmpAsCwd(); + vi.stubEnv('LANG_CODE', 'kr'); + vi.stubEnv('DOCS_ENV', 'production'); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe('GTM-T65CF7'); + }); + + it('falls back to the development id for an unknown build mode', async () => { + useTmpAsCwd(); + vi.stubEnv('DOCS_ENV', 'qa'); + const { getGtmContainerId } = await loadModule(); + + expect(getGtmContainerId()).toBe('GTM-WLXLBZD'); + }); +}); diff --git a/src/lib/sidebar/helpers.test.ts b/src/lib/sidebar/helpers.test.ts new file mode 100644 index 0000000000..3cae6496b0 --- /dev/null +++ b/src/lib/sidebar/helpers.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from 'vitest'; +import { + getActiveLabel, + getAncestorTrail, + getBreadcrumb, + hasActive, + isActive, + isGroup, + isInitiallyOpen, + joinPath, + normalizeSlug, +} from './helpers.ts'; +import type { SidebarEntry, SidebarGroup } from './types'; + +/** + * Three-level fixture reused by the tree-walking helpers: + * + * Grids & Lists (root group) + * └ Data Grid (nested group) + * └ Editing (nested group) + * ├ Overview grids/data-grid/editing + * └ Cell Editing grids/data-grid/editing/cell + * └ List grids/list + * General (root group) + * └ Getting Started general/getting-started + */ +const tree = (): SidebarEntry[] => [ + { + label: 'Grids & Lists', + collapsed: false, + items: [ + { + label: 'Data Grid', + collapsed: true, + items: [ + { + label: 'Editing', + collapsed: true, + items: [ + { label: 'Overview', slug: 'grids/data-grid/editing' }, + { label: 'Cell Editing', slug: 'grids/data-grid/editing/cell' }, + ], + }, + ], + }, + { label: 'List', slug: 'grids/list' }, + ], + }, + { + label: 'General', + collapsed: false, + items: [ + { label: 'Getting Started', slug: 'general/getting-started' }, + ], + }, +]; + +describe('isGroup', () => { + it('recognises an entry with items as a group', () => { + expect(isGroup({ label: 'Grids', items: [] })).toBe(true); + }); + + it('recognises an entry with a slug as a link', () => { + expect(isGroup({ label: 'List', slug: 'grids/list' })).toBe(false); + }); +}); + +describe('normalizeSlug', () => { + it('strips a leading slash', () => { + expect(normalizeSlug('/grids/list')).toBe('grids/list'); + }); + + it('strips a trailing slash', () => { + expect(normalizeSlug('grids/list/')).toBe('grids/list'); + }); + + it('strips both a leading and a trailing slash', () => { + expect(normalizeSlug('/grids/list/')).toBe('grids/list'); + }); + + it('leaves inner slashes alone', () => { + expect(normalizeSlug('grids/data-grid/editing')).toBe('grids/data-grid/editing'); + }); + + it('returns an empty string for the root slug', () => { + expect(normalizeSlug('/')).toBe(''); + }); +}); + +describe('isActive', () => { + it('matches two identical slugs', () => { + expect(isActive('grids/list', 'grids/list')).toBe(true); + }); + + it('does not match two different slugs', () => { + expect(isActive('grids/list', 'grids/grid')).toBe(false); + }); + + it('ignores leading and trailing slashes on either side', () => { + expect(isActive('/grids/list/', 'grids/list')).toBe(true); + expect(isActive('grids/list', '/grids/list/')).toBe(true); + }); + + it('does not treat a prefix as a match', () => { + expect(isActive('grids', 'grids/list')).toBe(false); + }); +}); + +describe('hasActive', () => { + it('finds a match among the links at the top level of the list', () => { + const items = (tree()[0] as SidebarGroup).items; + expect(hasActive(items, 'grids/list')).toBe(true); + }); + + it('finds a match nested several levels deep', () => { + expect(hasActive(tree(), 'grids/data-grid/editing/cell')).toBe(true); + }); + + it('returns false when no descendant matches', () => { + expect(hasActive(tree(), 'charts/pie')).toBe(false); + }); +}); + +describe('joinPath', () => { + it('joins ancestors and the label with a greater-than sign', () => { + expect(joinPath(['Grids & Lists', 'Data Grid'], 'Editing')).toBe('Grids & Lists>Data Grid>Editing'); + }); + + it('returns just the label when there are no ancestors', () => { + expect(joinPath([], 'General')).toBe('General'); + }); + + it('keeps empty ancestor labels as empty segments', () => { + expect(joinPath([''], 'General')).toBe('>General'); + }); +}); + +describe('isInitiallyOpen', () => { + it('opens a collapsed group that contains the active page', () => { + const group: SidebarGroup = { + label: 'Editing', + collapsed: true, + items: [{ label: 'Overview', slug: 'grids/data-grid/editing' }], + }; + expect(isInitiallyOpen(group, 'grids/data-grid/editing')).toBe(true); + }); + + it('keeps a collapsed group closed when it does not contain the active page', () => { + const group: SidebarGroup = { + label: 'Editing', + collapsed: true, + items: [{ label: 'Overview', slug: 'grids/data-grid/editing' }], + }; + expect(isInitiallyOpen(group, 'charts/pie')).toBe(false); + }); + + it('opens a group explicitly marked as not collapsed', () => { + const group: SidebarGroup = { + label: 'General', + collapsed: false, + items: [{ label: 'Getting Started', slug: 'general/getting-started' }], + }; + expect(isInitiallyOpen(group, 'charts/pie')).toBe(true); + }); + + it('keeps a group without a collapsed flag closed', () => { + const group: SidebarGroup = { + label: 'General', + items: [{ label: 'Getting Started', slug: 'general/getting-started' }], + }; + expect(isInitiallyOpen(group, 'charts/pie')).toBe(false); + }); +}); + +describe('getAncestorTrail', () => { + it('returns every ancestor label root-first for a deeply nested page', () => { + expect(getAncestorTrail(tree(), 'grids/data-grid/editing/cell')).toEqual([ + 'Grids & Lists', + 'Data Grid', + 'Editing', + ]); + }); + + it('returns the single ancestor of a page one level down', () => { + expect(getAncestorTrail(tree(), 'general/getting-started')).toEqual(['General']); + }); + + it('returns an empty array when the slug is not in the tree', () => { + expect(getAncestorTrail(tree(), 'charts/pie')).toEqual([]); + }); +}); + +describe('getActiveLabel', () => { + it('returns the label of the matching nested link', () => { + expect(getActiveLabel(tree(), 'grids/data-grid/editing/cell')).toBe('Cell Editing'); + }); + + it('returns an empty string when the slug is not in the tree', () => { + expect(getActiveLabel(tree(), 'charts/pie')).toBe(''); + }); +}); + +describe('getBreadcrumb', () => { + it('drops the root group and ends with the active label', () => { + expect(getBreadcrumb(tree(), 'grids/data-grid/editing/cell')).toEqual([ + 'Data Grid', + 'Editing', + 'Cell Editing', + ]); + }); + + it('returns just the leaf label for a page directly under a root group', () => { + expect(getBreadcrumb(tree(), 'general/getting-started')).toEqual(['Getting Started']); + }); + + it('returns an empty array when the slug is not in the tree', () => { + expect(getBreadcrumb(tree(), 'charts/pie')).toEqual([]); + }); +}); diff --git a/src/llms.test.ts b/src/llms.test.ts new file mode 100644 index 0000000000..ff15f56b0d --- /dev/null +++ b/src/llms.test.ts @@ -0,0 +1,300 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + buildLlmsMetaMap, + buildLlmsTxt, + collectSlugs, + extractLlmsMeta, + getBroadSectionsForPlatform, + IGDOCS_BROAD_SECTIONS, + toUrlSlug, + type LlmsMeta, +} from './llms.ts'; +import type { SidebarEntry } from './lib/sidebar/types'; + +let tmp: string; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-')); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function writeDoc(relPath: string, contents: string): void { + const file = path.join(tmp, relPath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); +} + +/** A three-level sidebar reused by the walk-based helpers. */ +const sidebar = (): SidebarEntry[] => [ + { + label: 'Grids & Lists', + collapsed: false, + items: [ + { label: 'Overview', slug: 'grids' }, + { + label: 'Data Grid', + collapsed: true, + items: [ + { label: 'Overview', slug: 'grids/data-grid' }, + { label: 'Cell Editing', slug: 'grids/data-grid/editing' }, + ], + }, + ], + }, + { + label: 'General', + collapsed: false, + items: [{ label: 'Getting Started', slug: 'general/getting-started' }], + }, +]; + +describe('extractLlmsMeta', () => { + it('reads the description and keywords from the llms block', () => { + const meta = extractLlmsMeta([ + '---', + 'title: Grid', + 'llms:', + ' description: The data grid.', + ' keywords:', + ' - grid', + ' - table', + '---', + 'body', + ].join('\n')); + + expect(meta).toEqual({ description: 'The data grid.', keywords: ['grid', 'table'] }); + }); + + it('falls back to the flat llmsdescription field', () => { + const meta = extractLlmsMeta('---\ntitle: Grid\nllmsdescription: Flat form.\n---\n'); + expect(meta).toEqual({ description: 'Flat form.' }); + }); + + it('falls back to the plain description when no llms block is present', () => { + const meta = extractLlmsMeta('---\ntitle: Grid\ndescription: Plain description.\n---\n'); + expect(meta).toEqual({ description: 'Plain description.' }); + }); + + it('prefers the llms description over the plain one', () => { + const meta = extractLlmsMeta([ + '---', + 'description: Plain.', + 'llmsdescription: Flat.', + 'llms:', + ' description: Nested.', + '---', + ].join('\n')); + + expect(meta.description).toBe('Nested.'); + }); + + it('ignores a null description', () => { + expect(extractLlmsMeta('---\ntitle: Grid\ndescription: null\n---\n')).toEqual({}); + }); + + it('wraps a single string keyword into an array', () => { + expect(extractLlmsMeta('---\nllmskeywords: grid\n---\n')).toEqual({ keywords: ['grid'] }); + }); + + it('ignores keywords that are neither a string nor an array', () => { + expect(extractLlmsMeta('---\nllmskeywords: 42\n---\n')).toEqual({}); + }); + + it('returns an empty object for a document without frontmatter', () => { + expect(extractLlmsMeta('# Just a heading\n')).toEqual({}); + }); + + it('drops an empty keyword list', () => { + expect(extractLlmsMeta('---\ndescription: Grid.\nllmskeywords: []\n---\n')).toEqual({ + description: 'Grid.', + }); + }); +}); + +describe('collectSlugs', () => { + it('returns every link slug in tree order', () => { + expect(collectSlugs(sidebar())).toEqual([ + 'grids', + 'grids/data-grid', + 'grids/data-grid/editing', + 'general/getting-started', + ]); + }); + + it('returns an empty array for an empty tree', () => { + expect(collectSlugs([])).toEqual([]); + }); +}); + +describe('buildLlmsMetaMap', () => { + it('reads metadata for the slugs that have a source file', () => { + writeDoc('grids.mdx', '---\ntitle: Grids\ndescription: All grids.\n---\n'); + writeDoc('grids/data-grid/index.md', '---\ntitle: Data Grid\nllmsdescription: The grid.\n---\n'); + + const map = buildLlmsMetaMap(tmp, sidebar()); + + expect([...map.keys()]).toEqual(['grids', 'grids/data-grid']); + expect(map.get('grids')).toEqual({ description: 'All grids.' }); + expect(map.get('grids/data-grid')).toEqual({ description: 'The grid.' }); + }); + + it('skips a slug whose file has no llms metadata', () => { + writeDoc('grids.mdx', '---\ntitle: Grids\n---\n'); + expect(buildLlmsMetaMap(tmp, sidebar()).size).toBe(0); + }); + + it('reads the root index file for the empty slug', () => { + writeDoc('index.mdx', '---\ntitle: Home\ndescription: The home page.\n---\n'); + + const map = buildLlmsMetaMap(tmp, [{ label: 'Home', slug: '' }]); + expect(map.get('')).toEqual({ description: 'The home page.' }); + }); + + it('takes metadata from the highest-precedence root', () => { + const overlay = path.join(tmp, 'overlay'); + const base = path.join(tmp, 'base'); + fs.mkdirSync(overlay, { recursive: true }); + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(path.join(overlay, 'grids.mdx'), '---\ndescription: Overlay.\n---\n'); + fs.writeFileSync(path.join(base, 'grids.mdx'), '---\ndescription: Base.\n---\n'); + + const map = buildLlmsMetaMap([overlay, base], [{ label: 'Grids', slug: 'grids' }]); + expect(map.get('grids')).toEqual({ description: 'Overlay.' }); + }); +}); + +describe('toUrlSlug', () => { + it('lower-cases and hyphenates a plain label', () => { + expect(toUrlSlug('React Grids')).toBe('react-grids'); + }); + + it('collapses runs of punctuation into a single hyphen', () => { + expect(toUrlSlug('Grids & Lists')).toBe('grids-lists'); + }); + + it('strips a leading hyphen produced by leading punctuation', () => { + expect(toUrlSlug(' React')).toBe('react'); + }); + + it('keeps digits', () => { + expect(toUrlSlug('Chart 3D')).toBe('chart-3d'); + }); + + // Current behaviour: the trailing-hyphen strip is not global, so only the + // first of a leading/trailing pair is removed. + it('leaves a trailing hyphen when the label both starts and ends with punctuation', () => { + expect(toUrlSlug(' React Grids ')).toBe('react-grids-'); + }); + + it.todo('should strip both the leading and the trailing hyphen (the replace lacks the /g flag)'); +}); + +describe('getBroadSectionsForPlatform', () => { + it.each(['angular', 'react', 'blazor', 'web-components'])( + 'returns the full broad-section set for %s', + platform => { + const sections = getBroadSectionsForPlatform(platform); + expect(sections.size).toBe(IGDOCS_BROAD_SECTIONS.length); + expect(sections.has('Grids & Lists')).toBe(true); + }, + ); + + it('returns an empty set for null', () => { + expect(getBroadSectionsForPlatform(null).size).toBe(0); + }); + + it('returns an empty set for a platform with no broad sections', () => { + expect(getBroadSectionsForPlatform('appbuilder').size).toBe(0); + }); +}); + +describe('buildLlmsTxt', () => { + const metaMap = (): Map => new Map([ + ['grids', { description: 'Every grid component.', keywords: ['grid', 'table'] }], + ['grids/data-grid/editing', { description: 'Editing cells in the grid.' }], + ]); + + it('opens with the title and the site description blockquote', () => { + const txt = buildLlmsTxt('/docs', 'Ignite UI', 'Docs for Ignite UI.', sidebar(), metaMap()); + + expect(txt.startsWith('# Ignite UI\n\n> Docs for Ignite UI.\n')).toBe(true); + }); + + it('uses the localized description when one is given', () => { + const txt = buildLlmsTxt('/docs', 'Ignite UI', 'English.', [], new Map(), [], new Set(), 'jp', '日本語。'); + + expect(txt).toContain('> 日本語。'); + expect(txt).toContain('## ドキュメント セット'); + }); + + it('emits a page line with the .md URL and the description', () => { + const txt = buildLlmsTxt('/docs', 'Ignite UI', 'Docs.', sidebar(), metaMap()); + + expect(txt).toContain('- [Grids & Lists Overview](/docs/grids.md): Every grid component.'); + expect(txt).toContain(' Tags: grid, table'); + }); + + it('emits a section heading for each group', () => { + const txt = buildLlmsTxt('/docs', 'Ignite UI', 'Docs.', sidebar(), metaMap()); + + expect(txt).toContain('## Grids & Lists'); + expect(txt).toContain('### Data Grid'); + }); + + it('adds the API reference link only when an API docs URL is given', () => { + const withApi = buildLlmsTxt('/docs', 'T', 'D', [], new Map(), [], new Set(), 'en', undefined, 'https://x/api/llms.txt'); + const without = buildLlmsTxt('/docs', 'T', 'D', [], new Map()); + + expect(withApi).toContain('- [API Reference](https://x/api/llms.txt): Full TypeDoc'); + expect(without).not.toContain('API Reference'); + }); + + it('lists each documentation set with its own .txt URL', () => { + const txt = buildLlmsTxt('/docs', 'T', 'D', [], new Map(), [ + { label: 'React Grids', paths: ['grids/**'], description: 'Grid docs only.' }, + { label: 'Charts', paths: ['charts/**'] }, + ]); + + expect(txt).toContain('- [React Grids](/docs/_llms-txt/react-grids.txt): Grid docs only.'); + expect(txt).toContain('- [Charts](/docs/_llms-txt/charts.txt)'); + }); + + it('does not prefix a label whose ancestor is a broad section', () => { + const txt = buildLlmsTxt( + '/docs', + 'T', + 'D', + sidebar(), + metaMap(), + [], + getBroadSectionsForPlatform('angular'), + ); + + // "Grids & Lists" is a navigation bucket, so the Overview page under it + // keeps its bare label instead of inheriting a prefix. + expect(txt).toContain('- [Overview](/docs/grids.md)'); + }); + + it('matches the recorded manifest for a small sidebar', async () => { + const txt = buildLlmsTxt( + '/docs', + 'Ignite UI', + 'Docs for Ignite UI.', + sidebar(), + metaMap(), + [{ label: 'React Grids', paths: ['grids/**'], description: 'Grid docs only.' }], + new Set(), + 'en', + undefined, + 'https://example.test/api/angular/llms.txt', + ); + + await expect(txt).toMatchFileSnapshot('./__snapshots__/llms-txt.txt'); + }); +}); diff --git a/src/llms.ts b/src/llms.ts index ec2842a839..260a0a48bf 100644 --- a/src/llms.ts +++ b/src/llms.ts @@ -24,6 +24,7 @@ import fs from 'node:fs'; import path from 'node:path'; import matter from 'gray-matter'; +import { findFirstInRoots, toRootList, type DocsContentRoot } from './lib/doc-roots.ts'; import type { SidebarEntry, SidebarGroup, SidebarLink } from './lib/sidebar/types'; import type { NavLang } from './platform.ts'; @@ -117,20 +118,21 @@ export function collectSlugs(items: SidebarEntry[]): string[] { * it must happen before the build because the rendered HTML does not expose * LLM-specific frontmatter fields like `llms.description` or `llms.keywords`. */ -export function buildLlmsMetaMap(docsDir: string, items: SidebarEntry[]): Map { +export function buildLlmsMetaMap(docsDir: DocsContentRoot | DocsContentRoot[], items: SidebarEntry[]): Map { const map = new Map(); + const roots = toRootList(docsDir); for (const slug of collectSlugs(items)) { const candidates = slug ? [`${slug}.md`, `${slug}.mdx`, path.join(slug, 'index.md'), path.join(slug, 'index.mdx')] : ['index.md', 'index.mdx']; - for (const candidate of candidates) { - try { - const raw = fs.readFileSync(path.join(docsDir, candidate), 'utf-8'); - const meta = extractLlmsMeta(raw); - if (meta.description || meta.keywords?.length) map.set(slug, meta); - break; - } catch { /* try next candidate */ } - } + // Roots are searched highest precedence first, so the metadata comes + // from the same file that supplies the rendered page. + const source = findFirstInRoots(roots, candidates); + if (!source) continue; + try { + const meta = extractLlmsMeta(fs.readFileSync(source, 'utf-8')); + if (meta.description || meta.keywords?.length) map.set(slug, meta); + } catch { /* unreadable source — leave the slug without metadata */ } } return map; } diff --git a/src/platform.test.ts b/src/platform.test.ts new file mode 100644 index 0000000000..3e71cd8a08 --- /dev/null +++ b/src/platform.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getNavConfig, getPlatformHead, IGDOCS_PLATFORMS, PLATFORM_DEFS, type PlatformKey } from './platform.ts'; + +const PLATFORM_KEYS = Object.keys(PLATFORM_DEFS) as PlatformKey[]; +const IG_PLATFORMS = PLATFORM_KEYS.filter(key => key !== 'appbuilder'); + +describe('getNavConfig', () => { + it.each(IG_PLATFORMS)('points %s at the Infragistics nav endpoint', platform => { + expect(getNavConfig(platform)).toEqual({ + navType: 'infragistics', + navUrl: 'https://www.infragistics.com/navigation', + }); + }); + + it.each(IG_PLATFORMS)('uses the Japanese host for %s in jp', platform => { + expect(getNavConfig(platform, 'jp')).toEqual({ + navType: 'infragistics', + navUrl: 'https://jp.infragistics.com/navigation', + }); + }); + + it.each(IG_PLATFORMS)('falls back to the English host for %s in kr', platform => { + expect(getNavConfig(platform, 'kr').navUrl).toBe('https://www.infragistics.com/navigation'); + }); + + it.each(['en', 'jp', 'kr'])('keeps the AppBuilder endpoint locale-independent in %s', lang => { + expect(getNavConfig('appbuilder', lang)).toEqual({ + navType: 'appbuilder', + navUrl: 'https://www.appbuilder.dev/header-footer-export', + }); + }); + + it.each(['en', 'jp', 'kr'])('returns no nav for a null platform in %s', lang => { + expect(getNavConfig(null, lang)).toEqual({ navType: 'none', navUrl: null }); + }); + + it('returns no nav for an unknown platform', () => { + expect(getNavConfig('mystery')).toEqual({ navType: 'none', navUrl: null }); + }); + + it('defaults to the English host when no locale is given', () => { + expect(getNavConfig('angular').navUrl).toBe('https://www.infragistics.com/navigation'); + }); +}); + +describe('getPlatformHead', () => { + it.each(PLATFORM_KEYS)('opens the head of %s with the platform meta tag', platform => { + const head = getPlatformHead(platform); + + expect(Array.isArray(head)).toBe(true); + expect(head[0]).toEqual({ tag: 'meta', attrs: { property: 'docs:platform', content: platform } }); + }); + + it.each(IG_PLATFORMS)('includes the shared Infragistics navigation assets for %s', platform => { + const head = getPlatformHead(platform); + const hrefs = head.map(entry => entry.attrs?.['href']); + const srcs = head.map(entry => entry.attrs?.['src']); + + expect(hrefs).toContain('https://www.infragistics.com/css/navigation.css'); + expect(srcs).toContain('https://www.infragistics.com/assets/modern/scripts/navigation.js'); + }); + + it('includes the AppBuilder mega-menu assets instead for appbuilder', () => { + const head = getPlatformHead('appbuilder'); + const hrefs = head.map(entry => entry.attrs?.['href']); + const srcs = head.map(entry => entry.attrs?.['src']); + + expect(hrefs).toContain('https://staging.appbuilder.dev/wp-includes/css/dashicons.min.css'); + expect(srcs).toContain('https://staging.appbuilder.dev/wp-content/plugins/megamenu/js/maxmegamenu.js?ver=3.3.1'); + expect(hrefs).not.toContain('https://www.infragistics.com/css/navigation.css'); + }); + + it('carries the bootstrap cascade layer as an inline style', () => { + const head = getPlatformHead('angular'); + const styles = head.filter(entry => entry.tag === 'style'); + + expect(styles).toHaveLength(1); + expect(styles[0].content).toContain('layer(bootstrap)'); + }); + + it('warns and returns nothing for an unknown platform', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(getPlatformHead('mystery')).toEqual([]); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it('ignores the locale argument', () => { + expect(getPlatformHead('angular', 'jp')).toEqual(getPlatformHead('angular', 'en')); + }); +}); + +describe('IGDOCS_PLATFORMS', () => { + it('pairs every English entry with a Japanese one on the same platform key', () => { + for (const [name, meta] of Object.entries(IGDOCS_PLATFORMS)) { + if (meta.lang !== 'en') continue; + const jp = IGDOCS_PLATFORMS[`${name}JP`]; + expect(jp, `${name}JP`).toBeDefined(); + expect(jp.key).toBe(meta.key); + expect(jp.lang).toBe('jp'); + } + }); + + it('gives every entry its own dev port', () => { + const ports = Object.values(IGDOCS_PLATFORMS).map(meta => meta.devPort); + + expect(new Set(ports).size).toBe(ports.length); + }); + + it('uses a platform key that the head registry knows', () => { + for (const meta of Object.values(IGDOCS_PLATFORMS)) { + expect(PLATFORM_DEFS[meta.key]).toBeDefined(); + } + }); +}); diff --git a/src/plugins/rehype-api-references-grid.test.ts b/src/plugins/rehype-api-references-grid.test.ts new file mode 100644 index 0000000000..96a7220f17 --- /dev/null +++ b/src/plugins/rehype-api-references-grid.test.ts @@ -0,0 +1,86 @@ +import { markdownToHtml } from 'satteri'; +import { describe, expect, it } from 'vitest'; +import { rehypeApiReferencesGrid } from './rehype-api-references-grid.ts'; + +/** Compiles `source` through the plugin under test. */ +async function render(source: string): Promise { + const { html } = await markdownToHtml(source, { hastPlugins: [rehypeApiReferencesGrid] }); + return html; +} + +/** Contents of the generated grid nav, or `''` when the plugin did not wrap anything. */ +function nav(html: string): string { + return /