From 9c9eff373f428947c9c3b9cc57223e8d17b8a225 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 15:19:28 -0400 Subject: [PATCH 1/7] feat(web): allow JS in template, absolute URL support --- package-lock.json | 4 +- package.json | 2 +- src/generators/web/index.mjs | 1 + src/generators/web/template.html | 14 +-- src/generators/web/types.d.ts | 1 + .../web/ui/components/SearchBox/index.jsx | 4 +- .../web/ui/components/SideBar/index.jsx | 4 +- src/generators/web/ui/hooks/useOrama.mjs | 4 +- .../web/ui/utils/relativeOrAbsolute.mjs | 16 ++++ .../web/utils/__tests__/processing.test.mjs | 63 +++++++++++++ .../__tests__/relativeOrAbsolute.test.mjs | 89 +++++++++++++++++++ src/generators/web/utils/processing.mjs | 38 +++++--- .../web/utils/relativeOrAbsolute.mjs | 18 ++++ 13 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 src/generators/web/ui/utils/relativeOrAbsolute.mjs create mode 100644 src/generators/web/utils/__tests__/processing.test.mjs create mode 100644 src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs create mode 100644 src/generators/web/utils/relativeOrAbsolute.mjs 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/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/utils/relativeOrAbsolute.mjs b/src/generators/web/ui/utils/relativeOrAbsolute.mjs new file mode 100644 index 00000000..9850187b --- /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 + ? `${baseURL.replace(/\/$/, '')}${to.replace(/\/$/, '')}` + : 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..7922b7a9 --- /dev/null +++ b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { setConfig } 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)', async () => { + 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('/fs', '/fs'); + assert.strictEqual(result, '.'); + }); +}); + +describe('relativeOrAbsolute (absolute mode)', async () => { + await setConfig({ + version: 'v22.0.0', + changelog: [], + generators: { + web: { + useAbsoluteURLs: true, + baseURL: 'https://nodejs.org/docs', + }, + }, + }); + + // Cache-busting query param to get a fresh module with new config + const { relativeOrAbsolute } = + await import('../relativeOrAbsolute.mjs?absolute'); + + 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'); + }); + + it('strips trailing slash from baseURL before joining', async () => { + await setConfig({ + version: 'v22.0.0', + changelog: [], + generators: { + web: { + useAbsoluteURLs: true, + baseURL: 'https://nodejs.org/docs/', + }, + }, + }); + + const { relativeOrAbsolute: roa } = + await import('../relativeOrAbsolute.mjs?trailing-slash'); + + const result = roa('/fs', '/http'); + assert.strictEqual(result, 'https://nodejs.org/docs/fs'); + }); +}); diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 4121ae15..1c817d34 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,22 @@ 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 root = `${relativeOrAbsolute('/', data.path)}/`; // 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()}`, - speculationRules: SPECULATION_RULES, + entrypoint: `${data.api}.js?${randomUUID()}`, + speculationRules: JSON.stringify(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..db8f4abd --- /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 + ? `${baseURL.replace(/\/$/, '')}${to.replace(/\/$/, '')}` + : relative(to, from); +}; From 9add0bebdcf2e7cb5b6adcf3381b715286501995 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 15:34:44 -0400 Subject: [PATCH 2/7] fix test --- .../__tests__/relativeOrAbsolute.test.mjs | 57 ++++++------------- 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs index 7922b7a9..bca425dc 100644 --- a/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs +++ b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; +import { beforeEach, describe, it } from 'node:test'; -import { setConfig } from '../../../../utils/configuration/index.mjs'; +import { + setConfig, + default as getConfig, +} from '../../../../utils/configuration/index.mjs'; import { relativeOrAbsolute } from '../relativeOrAbsolute.mjs'; await setConfig({ @@ -15,10 +18,14 @@ await setConfig({ }, }); -describe('relativeOrAbsolute (relative mode)', async () => { +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, '../..'); + assert.strictEqual(result, '..'); }); it('returns a relative path between sibling pages', () => { @@ -28,34 +35,23 @@ describe('relativeOrAbsolute (relative mode)', async () => { it('returns a relative path for a deeper target', () => { const result = relativeOrAbsolute('/orama-db.json', '/api/fs'); - assert.strictEqual(result, '../../orama-db.json'); + assert.strictEqual(result, '../orama-db.json'); }); it('returns "." when source and target resolve to the same path', () => { - const result = relativeOrAbsolute('/fs', '/fs'); + const result = relativeOrAbsolute('/', '/'); assert.strictEqual(result, '.'); }); }); -describe('relativeOrAbsolute (absolute mode)', async () => { - await setConfig({ - version: 'v22.0.0', - changelog: [], - generators: { - web: { - useAbsoluteURLs: true, - baseURL: 'https://nodejs.org/docs', - }, - }, +describe('relativeOrAbsolute (absolute mode)', () => { + beforeEach(() => { + getConfig('web').useAbsoluteURLs = true; }); - // Cache-busting query param to get a fresh module with new config - const { relativeOrAbsolute } = - await import('../relativeOrAbsolute.mjs?absolute'); - it('returns an absolute URL to root', () => { const result = relativeOrAbsolute('/', '/api/fs'); - assert.strictEqual(result, 'https://nodejs.org/docs/'); + assert.strictEqual(result, 'https://nodejs.org/docs'); }); it('returns an absolute URL for a page path', () => { @@ -67,23 +63,4 @@ describe('relativeOrAbsolute (absolute mode)', async () => { const result = relativeOrAbsolute('/orama-db.json', '/api/fs'); assert.strictEqual(result, 'https://nodejs.org/docs/orama-db.json'); }); - - it('strips trailing slash from baseURL before joining', async () => { - await setConfig({ - version: 'v22.0.0', - changelog: [], - generators: { - web: { - useAbsoluteURLs: true, - baseURL: 'https://nodejs.org/docs/', - }, - }, - }); - - const { relativeOrAbsolute: roa } = - await import('../relativeOrAbsolute.mjs?trailing-slash'); - - const result = roa('/fs', '/http'); - assert.strictEqual(result, 'https://nodejs.org/docs/fs'); - }); }); From bb9def3e25d97175bf6308d187e86f487e0b891f Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 15:35:15 -0400 Subject: [PATCH 3/7] fix test --- src/generators/web/utils/processing.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 1c817d34..31977518 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -133,7 +133,7 @@ export async function processJSXEntries(entries, template) { dehydrated: serverBundle.pages.get(`${data.api}.js`) ?? '', importMap: clientBundle.importMap?.replaceAll('/', root) ?? '', entrypoint: `${data.api}.js?${randomUUID()}`, - speculationRules: JSON.stringify(SPECULATION_RULES), + speculationRules: SPECULATION_RULES, root, metadata: data, config, From 1db920fa735b1d74ef3e621855e29af8d3c88f24 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 15:44:16 -0400 Subject: [PATCH 4/7] fixup! --- src/generators/orama-db/generate.mjs | 2 +- src/generators/web/ui/utils/relativeOrAbsolute.mjs | 4 +--- src/generators/web/utils/relativeOrAbsolute.mjs | 4 +--- 3 files changed, 3 insertions(+), 7 deletions(-) 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/ui/utils/relativeOrAbsolute.mjs b/src/generators/web/ui/utils/relativeOrAbsolute.mjs index 9850187b..fdcd5e68 100644 --- a/src/generators/web/ui/utils/relativeOrAbsolute.mjs +++ b/src/generators/web/ui/utils/relativeOrAbsolute.mjs @@ -11,6 +11,4 @@ import { useAbsoluteURLs, baseURL } from '#theme/config'; * @returns {string} */ export const relativeOrAbsolute = (to, from) => - useAbsoluteURLs - ? `${baseURL.replace(/\/$/, '')}${to.replace(/\/$/, '')}` - : relative(to, from); + useAbsoluteURLs ? new URL(`.${to}`, baseURL).href : relative(to, from); diff --git a/src/generators/web/utils/relativeOrAbsolute.mjs b/src/generators/web/utils/relativeOrAbsolute.mjs index db8f4abd..430a0d7a 100644 --- a/src/generators/web/utils/relativeOrAbsolute.mjs +++ b/src/generators/web/utils/relativeOrAbsolute.mjs @@ -12,7 +12,5 @@ import { relative } from '../../../utils/url.mjs'; export const relativeOrAbsolute = (to, from) => { const { useAbsoluteURLs, baseURL } = getConfig('web'); - return useAbsoluteURLs - ? `${baseURL.replace(/\/$/, '')}${to.replace(/\/$/, '')}` - : relative(to, from); + return useAbsoluteURLs ? new URL(`.${to}`, baseURL).href : relative(to, from); }; From 57d7dd00de39b7b964441caeb2848d9cac6712f0 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 15:56:01 -0400 Subject: [PATCH 5/7] fixup! --- src/generators/web/ui/utils/relativeOrAbsolute.mjs | 4 +++- .../web/utils/__tests__/relativeOrAbsolute.test.mjs | 2 +- src/generators/web/utils/processing.mjs | 2 +- src/generators/web/utils/relativeOrAbsolute.mjs | 4 +++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/generators/web/ui/utils/relativeOrAbsolute.mjs b/src/generators/web/ui/utils/relativeOrAbsolute.mjs index fdcd5e68..e08b4876 100644 --- a/src/generators/web/ui/utils/relativeOrAbsolute.mjs +++ b/src/generators/web/ui/utils/relativeOrAbsolute.mjs @@ -11,4 +11,6 @@ import { useAbsoluteURLs, baseURL } from '#theme/config'; * @returns {string} */ export const relativeOrAbsolute = (to, from) => - useAbsoluteURLs ? new URL(`.${to}`, baseURL).href : relative(to, from); + useAbsoluteURLs + ? new URL(`.${to}`, baseURL.replace(/\/?$/, '/')).href + : relative(to, from); diff --git a/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs index bca425dc..4f8a2d7d 100644 --- a/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs +++ b/src/generators/web/utils/__tests__/relativeOrAbsolute.test.mjs @@ -51,7 +51,7 @@ describe('relativeOrAbsolute (absolute mode)', () => { it('returns an absolute URL to root', () => { const result = relativeOrAbsolute('/', '/api/fs'); - assert.strictEqual(result, 'https://nodejs.org/docs'); + assert.strictEqual(result, 'https://nodejs.org/docs/'); }); it('returns an absolute URL for a page path', () => { diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 31977518..b6b27a9c 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -125,7 +125,7 @@ export async function processJSXEntries(entries, template) { // Step 3: Render final HTML pages const results = await Promise.all( entries.map(async ({ data }) => { - const root = `${relativeOrAbsolute('/', data.path)}/`; + const root = relativeOrAbsolute('/', data.path); // Replace template placeholders with actual content const renderedHtml = populateWithEvaluation(template, { diff --git a/src/generators/web/utils/relativeOrAbsolute.mjs b/src/generators/web/utils/relativeOrAbsolute.mjs index 430a0d7a..a37be0e3 100644 --- a/src/generators/web/utils/relativeOrAbsolute.mjs +++ b/src/generators/web/utils/relativeOrAbsolute.mjs @@ -12,5 +12,7 @@ import { relative } from '../../../utils/url.mjs'; export const relativeOrAbsolute = (to, from) => { const { useAbsoluteURLs, baseURL } = getConfig('web'); - return useAbsoluteURLs ? new URL(`.${to}`, baseURL).href : relative(to, from); + return useAbsoluteURLs + ? new URL(`.${to}`, baseURL.replace(/\/?$/, '/')).href + : relative(to, from); }; From acf914c3aa9a217d3d6319083a0486448dfe9221 Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sat, 4 Apr 2026 16:01:31 -0400 Subject: [PATCH 6/7] add docs --- src/generators/web/README.md | 50 +++++++++++++++++++++++++------- src/generators/web/ui/types.d.ts | 1 + 2 files changed, 41 insertions(+), 10 deletions(-) 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/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; From f4c6dd9351603f2114cc9aaa8cff37233a67cbe8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 4 Apr 2026 20:17:48 +0000 Subject: [PATCH 7/7] fix(web): preserve trailing slash in template root --- src/generators/web/utils/processing.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index b6b27a9c..e4a66f23 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -125,7 +125,10 @@ export async function processJSXEntries(entries, template) { // Step 3: Render final HTML pages const results = await Promise.all( entries.map(async ({ data }) => { - const root = relativeOrAbsolute('/', data.path); + const unresolvedRoot = relativeOrAbsolute('/', data.path); + const root = unresolvedRoot.endsWith('/') + ? unresolvedRoot + : `${unresolvedRoot}/`; // Replace template placeholders with actual content const renderedHtml = populateWithEvaluation(template, {