diff --git a/package-lock.json b/package-lock.json index 3f72098b..9c208c93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@node-core/doc-kit", - "version": "1.3.2", + "version": "1.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@node-core/doc-kit", - "version": "1.3.2", + "version": "1.3.3", "dependencies": { "@actions/core": "^3.0.0", "@heroicons/react": "^2.2.0", diff --git a/package.json b/package.json index 885c7512..a9517022 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@node-core/doc-kit", "type": "module", - "version": "1.3.2", + "version": "1.3.3", "repository": { "type": "git", "url": "git+https://github.com/nodejs/doc-kit.git" diff --git a/src/generators/orama-db/generate.mjs b/src/generators/orama-db/generate.mjs index 39134009..e8ec639d 100644 --- a/src/generators/orama-db/generate.mjs +++ b/src/generators/orama-db/generate.mjs @@ -37,7 +37,7 @@ export async function generate(input) { description: paragraph ? transformNodeToString(paragraph, true) : undefined, - href: `${entry.path.slice(1)}.html#${entry.heading.data.slug}`, + href: `${entry.path}.html#${entry.heading.data.slug}`, siteSection: headings[0].heading.data.name, }; }) diff --git a/src/generators/web/README.md b/src/generators/web/README.md index ed6246aa..4b4fe774 100644 --- a/src/generators/web/README.md +++ b/src/generators/web/README.md @@ -6,16 +6,17 @@ The `web` generator transforms JSX AST entries into complete web bundles, produc The `web` generator accepts the following configuration options: -| Name | Type | Default | Description | -| ---------------- | -------- | --------------------------------------------- | --------------------------------------------------------------------- | -| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written | -| `templatePath` | `string` | `'template.html'` | Path to the HTML template file | -| `project` | `string` | `'Node.js'` | Project name used in page titles and the version selector | -| `title` | `string` | `'{project} v{version} Documentation'` | Title template for HTML pages (supports `{project}`, `{version}`) | -| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links | -| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links | -| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization | -| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build | +| Name | Type | Default | Description | +| ----------------- | --------- | --------------------------------------------- | --------------------------------------------------------------------- | +| `output` | `string` | - | The directory where HTML, JavaScript, and CSS files will be written | +| `templatePath` | `string` | `'template.html'` | Path to the HTML template file | +| `project` | `string` | `'Node.js'` | Project name used in page titles and the version selector | +| `title` | `string` | `'{project} v{version} Documentation'` | Title template for HTML pages (supports `{project}`, `{version}`) | +| `useAbsoluteURLs` | `boolean` | `false` | When `true`, all internal links use absolute URLs based on `baseURL` | +| `editURL` | `string` | `'${GITHUB_EDIT_URL}/doc/api{path}.md'` | URL template for "edit this page" links | +| `pageURL` | `string` | `'{baseURL}/latest-{version}/api{path}.html'` | URL template for documentation page links | +| `imports` | `object` | See below | Object mapping `#theme/` aliases to component paths for customization | +| `virtualImports` | `object` | `{}` | Additional virtual module mappings merged into the build | #### Default `imports` @@ -60,6 +61,8 @@ All scalar (non-object) configuration values are automatically exported. The def | `versions` | `Array<{ url, label, major }>` | Pre-computed version entries with labels and URL templates (only `{path}` remains for per-page use) | | `editURL` | `string` | Partially populated "edit this page" URL template (only `{path}` remains) | | `pages` | `Array<[string, string]>` | Sorted `[name, path]` tuples for sidebar navigation | +| `useAbsoluteURLs` | `boolean` | Whether internal links use absolute URLs (mirrors config value) | +| `baseURL` | `string` | Base URL for the documentation site (used when `useAbsoluteURLs` is `true`) | | `languageDisplayNameMap` | `Map` | Shiki language alias → display name map for code blocks | #### Usage in custom components @@ -96,3 +99,30 @@ The `Layout` component receives the following props: | `children` | `ComponentChildren` | Processed page content | Custom Layout components can use any combination of these props alongside `#theme/config` imports. + +### HTML template + +The HTML template file (set via `templatePath`) uses JavaScript template literal syntax (`${...}` placeholders) and is evaluated at build time with full expression support. + +#### Available template variables + +| Variable | Type | Description | +| ------------------ | -------- | ----------------------------------------------------------------- | +| `title` | `string` | Fully resolved page title (e.g. `'File system \| Node.js v22.x'`) | +| `dehydrated` | `string` | Server-rendered HTML for the page content | +| `importMap` | `string` | JSON import map for client-side module resolution | +| `entrypoint` | `string` | Client-side entry point filename with cache-bust query | +| `speculationRules` | `string` | Speculation rules JSON for prefetching | +| `root` | `string` | Relative or absolute path to the site root | +| `metadata` | `object` | Full page metadata (frontmatter, path, heading, etc.) | +| `config` | `object` | The resolved web generator configuration | + +Since the template supports arbitrary JS expressions, you can use conditionals and method calls: + +```html +${title} + + +``` diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index e41b76aa..56f91f6f 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -31,6 +31,7 @@ export default createLazyGenerator({ templatePath: join(import.meta.dirname, 'template.html'), project: 'Node.js', title: '{project} v{version} Documentation', + useAbsoluteURLs: false, editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`, pageURL: '{baseURL}/latest-{version}/api{path}.html', imports: { diff --git a/src/generators/web/template.html b/src/generators/web/template.html index eed99d1b..fe6a5823 100644 --- a/src/generators/web/template.html +++ b/src/generators/web/template.html @@ -5,10 +5,10 @@ - {title} + ${title} - - + + @@ -20,12 +20,12 @@ - - + + -
{dehydrated}
- +
${dehydrated}
+ diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 15229094..a2b61ec9 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -3,6 +3,7 @@ import type { JSXContent } from '../jsx-ast/utils/buildContent.mjs'; export type Configuration = { templatePath: string; title: string; + useAbsoluteURLs: boolean; imports: Record; virtualImports: Record; }; diff --git a/src/generators/web/ui/components/SearchBox/index.jsx b/src/generators/web/ui/components/SearchBox/index.jsx index d18798c3..6a24e7c8 100644 --- a/src/generators/web/ui/components/SearchBox/index.jsx +++ b/src/generators/web/ui/components/SearchBox/index.jsx @@ -8,8 +8,8 @@ import SearchResults from '@node-core/ui-components/Common/Search/Results'; import SearchHit from '@node-core/ui-components/Common/Search/Results/Hit'; import styles from './index.module.css'; -import { relative } from '../../../../../utils/url.mjs'; import useOrama from '../../hooks/useOrama.mjs'; +import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs'; const SearchBox = ({ pathname }) => { const client = useOrama(pathname); @@ -23,7 +23,7 @@ const SearchBox = ({ pathname }) => { )} diff --git a/src/generators/web/ui/components/SideBar/index.jsx b/src/generators/web/ui/components/SideBar/index.jsx index 15d3010b..40a987b7 100644 --- a/src/generators/web/ui/components/SideBar/index.jsx +++ b/src/generators/web/ui/components/SideBar/index.jsx @@ -2,7 +2,7 @@ import Select from '@node-core/ui-components/Common/Select'; import SideBar from '@node-core/ui-components/Containers/Sidebar'; import styles from './index.module.css'; -import { relative } from '../../../../../utils/url.mjs'; +import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs'; import { project, version, versions, pages } from '#theme/config'; @@ -41,7 +41,7 @@ export default ({ metadata }) => { link: metadata.path === path ? `${metadata.basename}.html` - : `${relative(path, metadata.path)}.html`, + : `${relativeOrAbsolute(path, metadata.path)}.html`, })); return ( diff --git a/src/generators/web/ui/hooks/useOrama.mjs b/src/generators/web/ui/hooks/useOrama.mjs index e371b640..f8861eb4 100644 --- a/src/generators/web/ui/hooks/useOrama.mjs +++ b/src/generators/web/ui/hooks/useOrama.mjs @@ -1,7 +1,7 @@ import { create, search, load } from '@orama/orama'; import { useState, useEffect } from 'react'; -import { relative } from '../../../../utils/url.mjs'; +import { relativeOrAbsolute } from '../utils/relativeOrAbsolute.mjs'; /** * Hook for initializing and managing Orama search database @@ -26,7 +26,7 @@ export default pathname => { setClient(db); // Load the search data - fetch(relative('/orama-db.json', pathname)) + fetch(relativeOrAbsolute('/orama-db.json', pathname)) .then(response => response.ok && response.json()) .then(data => load(db, data)); }, []); diff --git a/src/generators/web/ui/types.d.ts b/src/generators/web/ui/types.d.ts index 8ecd5574..081061db 100644 --- a/src/generators/web/ui/types.d.ts +++ b/src/generators/web/ui/types.d.ts @@ -20,6 +20,7 @@ declare module '#theme/config' { // From web configuration export const templatePath: Configuration['templatePath']; export const title: Configuration['title']; + export const useAbsoluteURLs: Configuration['useAbsoluteURLs']; // From config generation export const version: string; diff --git a/src/generators/web/ui/utils/relativeOrAbsolute.mjs b/src/generators/web/ui/utils/relativeOrAbsolute.mjs new file mode 100644 index 00000000..e08b4876 --- /dev/null +++ b/src/generators/web/ui/utils/relativeOrAbsolute.mjs @@ -0,0 +1,16 @@ +import { relative } from '../../../../utils/url.mjs'; + +import { useAbsoluteURLs, baseURL } from '#theme/config'; + +/** + * Returns an absolute URL (based on baseURL) or a relative URL, + * depending on the useAbsoluteURLs configuration option. + * + * @param {string} to - Target path (e.g., '/fs', '/orama-db.json') + * @param {string} from - Current page path (e.g., '/api/fs') + * @returns {string} + */ +export const relativeOrAbsolute = (to, from) => + useAbsoluteURLs + ? new URL(`.${to}`, baseURL.replace(/\/?$/, '/')).href + : relative(to, from); diff --git a/src/generators/web/utils/__tests__/processing.test.mjs b/src/generators/web/utils/__tests__/processing.test.mjs new file mode 100644 index 00000000..afbc64c3 --- /dev/null +++ b/src/generators/web/utils/__tests__/processing.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { populateWithEvaluation } from '../processing.mjs'; + +describe('populateWithEvaluation', () => { + it('substitutes simple ${variable} placeholders', () => { + const result = populateWithEvaluation('Hello ${name}!', { name: 'World' }); + assert.strictEqual(result, 'Hello World!'); + }); + + it('supports multiple variables', () => { + const result = populateWithEvaluation('${greeting} ${name}!', { + greeting: 'Hi', + name: 'Node', + }); + assert.strictEqual(result, 'Hi Node!'); + }); + + it('supports JavaScript expressions', () => { + const result = populateWithEvaluation('${value > 5 ? "big" : "small"}', { + value: 10, + }); + assert.strictEqual(result, 'big'); + }); + + it('supports ternary expressions for conditional content', () => { + const result = populateWithEvaluation( + '${showExtra ? "extra content" : ""}', + { showExtra: false } + ); + assert.strictEqual(result, ''); + }); + + it('handles JSON.stringify for objects', () => { + const obj = { key: 'value' }; + const result = populateWithEvaluation('${JSON.stringify(data)}', { + data: obj, + }); + assert.strictEqual(result, '{"key":"value"}'); + }); + + it('preserves surrounding HTML content', () => { + const result = populateWithEvaluation( + '${title}', + { title: 'Test Page', root: '../' } + ); + assert.strictEqual( + result, + 'Test Page' + ); + }); + + it('handles empty string values', () => { + const result = populateWithEvaluation('[${content}]', { content: '' }); + assert.strictEqual(result, '[]'); + }); + + it('handles numeric values', () => { + const result = populateWithEvaluation('count: ${count}', { count: 42 }); + assert.strictEqual(result, 'count: 42'); + }); +}); diff --git a/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs new file mode 100644 index 00000000..4f8a2d7d --- /dev/null +++ b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; + +import { + setConfig, + default as getConfig, +} from '../../../../utils/configuration/index.mjs'; +import { relativeOrAbsolute } from '../relativeOrAbsolute.mjs'; + +await setConfig({ + version: 'v22.0.0', + changelog: [], + generators: { + web: { + useAbsoluteURLs: false, + baseURL: 'https://nodejs.org/docs', + }, + }, +}); + +describe('relativeOrAbsolute (relative mode)', () => { + beforeEach(() => { + getConfig('web').useAbsoluteURLs = false; + }); + + it('returns a relative path from a nested page to root', () => { + const result = relativeOrAbsolute('/', '/api/fs'); + assert.strictEqual(result, '..'); + }); + + it('returns a relative path between sibling pages', () => { + const result = relativeOrAbsolute('/http', '/fs'); + assert.strictEqual(result, 'http'); + }); + + it('returns a relative path for a deeper target', () => { + const result = relativeOrAbsolute('/orama-db.json', '/api/fs'); + assert.strictEqual(result, '../orama-db.json'); + }); + + it('returns "." when source and target resolve to the same path', () => { + const result = relativeOrAbsolute('/', '/'); + assert.strictEqual(result, '.'); + }); +}); + +describe('relativeOrAbsolute (absolute mode)', () => { + beforeEach(() => { + getConfig('web').useAbsoluteURLs = true; + }); + + it('returns an absolute URL to root', () => { + const result = relativeOrAbsolute('/', '/api/fs'); + assert.strictEqual(result, 'https://nodejs.org/docs/'); + }); + + it('returns an absolute URL for a page path', () => { + const result = relativeOrAbsolute('/http', '/fs'); + assert.strictEqual(result, 'https://nodejs.org/docs/http'); + }); + + it('returns an absolute URL for a resource', () => { + const result = relativeOrAbsolute('/orama-db.json', '/api/fs'); + assert.strictEqual(result, 'https://nodejs.org/docs/orama-db.json'); + }); +}); diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 4121ae15..e4a66f23 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -8,12 +8,29 @@ import bundleCode from './bundle.mjs'; import { createChunkedRequire } from './chunks.mjs'; import createConfigSource from './config.mjs'; import createASTBuilder from './generate.mjs'; +import { relativeOrAbsolute } from './relativeOrAbsolute.mjs'; import getConfig from '../../../utils/configuration/index.mjs'; import { populate } from '../../../utils/configuration/templates.mjs'; import { minifyHTML } from '../../../utils/html-minifier.mjs'; -import { relative } from '../../../utils/url.mjs'; import { SPECULATION_RULES } from '../constants.mjs'; +/** + * Populates a template string by evaluating it as a JavaScript template literal, + * allowing full JS expression syntax (e.g., ${if ...}, ${JSON.stringify(...)}). + * + * ONLY used for HTML template population. Do not use elsewhere. + * + * @param {string} template - The template string with ${...} placeholders + * @param {Record} config - The values available in the template + * @returns {string} The populated template + */ +export const populateWithEvaluation = (template, config) => { + const keys = Object.keys(config); + const values = Object.values(config); + const fn = new Function(...keys, `return \`${template}\`;`); + return fn(...values); +}; + /** * Converts JSX AST entries to server and client JavaScript code. * @@ -107,21 +124,25 @@ export async function processJSXEntries(entries, template) { // Step 3: Render final HTML pages const results = await Promise.all( - entries.map(async ({ data: { api, path, heading } }) => { - const root = `${relative('/', path)}/`; + entries.map(async ({ data }) => { + const unresolvedRoot = relativeOrAbsolute('/', data.path); + const root = unresolvedRoot.endsWith('/') + ? unresolvedRoot + : `${unresolvedRoot}/`; // Replace template placeholders with actual content - const renderedHtml = populate(template, { - title: `${heading.data.name} | ${titleSuffix}`, - dehydrated: serverBundle.pages.get(`${api}.js`) ?? '', + const renderedHtml = populateWithEvaluation(template, { + title: `${data.heading.data.name} | ${titleSuffix}`, + dehydrated: serverBundle.pages.get(`${data.api}.js`) ?? '', importMap: clientBundle.importMap?.replaceAll('/', root) ?? '', - entrypoint: `${api}.js?${randomUUID()}`, + entrypoint: `${data.api}.js?${randomUUID()}`, speculationRules: SPECULATION_RULES, root, - path, + metadata: data, + config, }); - return { html: await minifyHTML(renderedHtml), path }; + return { html: await minifyHTML(renderedHtml), path: data.path }; }) ); diff --git a/src/generators/web/utils/relativeOrAbsolute.mjs b/src/generators/web/utils/relativeOrAbsolute.mjs new file mode 100644 index 00000000..a37be0e3 --- /dev/null +++ b/src/generators/web/utils/relativeOrAbsolute.mjs @@ -0,0 +1,18 @@ +import getConfig from '../../../utils/configuration/index.mjs'; +import { relative } from '../../../utils/url.mjs'; + +/** + * Returns an absolute URL (based on baseURL) or a relative URL, + * depending on the useAbsoluteURLs configuration option. + * + * @param {string} to - Target path (e.g., '/fs', '/orama-db.json') + * @param {string} from - Current page path (e.g., '/api/fs') + * @returns {string} + */ +export const relativeOrAbsolute = (to, from) => { + const { useAbsoluteURLs, baseURL } = getConfig('web'); + + return useAbsoluteURLs + ? new URL(`.${to}`, baseURL.replace(/\/?$/, '/')).href + : relative(to, from); +};