diff --git a/frontend/purestack.studio/guides/links.mdx b/frontend/purestack.studio/guides/links.mdx index 97619c58..6c3de9bd 100644 --- a/frontend/purestack.studio/guides/links.mdx +++ b/frontend/purestack.studio/guides/links.mdx @@ -1,6 +1,6 @@ --- title: Links -description: Link to pages, sections, and other sites. Write links relative to your file, and every build checks that they still work. +description: Link to pages, sections, images, files, and other sites. Write links relative to your file, and every build checks that they still work. template: doc layout: showNav: true @@ -13,7 +13,7 @@ nav: # Links -Write a link the way you see your files. PureStack turns it into the right page address and checks it every time the site builds, so a renamed or deleted page never leaves a broken link behind. +Write a link the way you see your files. PureStack turns it into the right address and checks it every time the site builds, so a renamed or deleted page or image never leaves a broken link behind. ## Link to another page @@ -71,6 +71,26 @@ The same rule works for `href` on any tag, including components and links they b Links in a shared header or footer start from that header or footer file, so they work on every page that shows them. +## Images and files + +Images, videos, downloads, and other files in your content folder follow the same rule: + +```md +![Palette diagram](./images/palette.svg) + +[Download the checklist](./checklist.pdf) +``` + +```html +PureStack + + +``` + +Write a file's full name, with its extension and the same capitals, so it works on any host. + +Files that a build step writes straight into the output aren't in your content folder, so PureStack can't check them. Link them from the site root, such as `/previews/demo.html`. + ## Link outside your content Anything that starts with `/` or a protocol is used as written: @@ -92,13 +112,13 @@ If the site lives under a base path, such as `/docs`, PureStack adds it to every The build stops and names the file and the link: ```text -Content link "./themse" in "guides/semantic-tones.mdx" does not match any page. +Content link "./themse" in "guides/semantic-tones.mdx" does not match any page or file. ``` While `purestack serve` runs, only that page shows the error, and it updates as soon as you fix the link. Most fixes are one of these: -- **A typo or different capitals.** `./Themes` doesn't find `themes.mdx`. -- **The page moved.** Write the path again, starting from your file. +- **A typo or different capitals.** `./Themes` doesn't find `themes.mdx`, and `./Logo.png` doesn't find `logo.png`. +- **The page or file moved.** Write the path again, starting from your file. - **The page is built elsewhere.** Start the path at the site root, such as `/blog/`. ## Translated sites @@ -115,10 +135,11 @@ Link pages inside your language folder as usual. If a page isn't translated yet, | The page one folder up | `../` | | A section of another page | `./themes#create-a-skin` | | A section of this page | `#quick-reference` | +| An image in a subfolder | `./images/palette.svg` | | A page built separately | `/blog/` | | Another website | `https://example.com` | - Link your own pages relative to your file. Use a full path or address for - everything else. + Link your own pages and files relative to your file. Use a full path or + address for everything else. diff --git a/frontend/purestack.studio/guides/site-config.mdx b/frontend/purestack.studio/guides/site-config.mdx index 2687b024..ea0c33cf 100644 --- a/frontend/purestack.studio/guides/site-config.mdx +++ b/frontend/purestack.studio/guides/site-config.mdx @@ -95,6 +95,8 @@ The `--content` CLI option chooses the content directory. A custom runner can se The logo can use `imageSrc` and `imageSrcDark` instead of an icon, or a `monogram` mark. `imageSrc` takes priority over `icon`. `subtitle`, `appearance`, `wordmarkStyle`, `shape`, `tone`, colors, and size fields let you tune the mark and wordmark. Set `logo.href` to `null` or `""` for a noninteractive logo. For the full set of presentation fields, use the [schema](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-ssg/schema/siteConfig.schema.json) alongside the [SiteLogo component](/components/site/site-logo/). +Relative paths in `siteConfig.json`, such as `imageSrc`, `style.href`, or `privacyPolicyUrl`, start at the content directory, where the file lives. `assets/logo.svg` becomes `/assets/logo.svg` on every page. + ## Styles and themes PureStack generates a stylesheet for each configured theme. `light` and `dark` are required, and are the defaults. With the default filename, the generated files are `assets/site.css` and `assets/site.dark.css`. diff --git a/packages/ts-ssg/src/build/content-hrefs.ts b/packages/ts-ssg/src/build/content-hrefs.ts deleted file mode 100644 index b62c7775..00000000 --- a/packages/ts-ssg/src/build/content-hrefs.ts +++ /dev/null @@ -1,182 +0,0 @@ -import path from 'node:path' -import type { SiteConfig } from '@purestack/ts-common' -import { urlNormalizer } from '@purestack/ts-util' -import { CONTENT_EXTS, isContentExt } from '../discover/contentExtensions' -import type { ResolvedContentFile } from '../i18n/content' -import { resolveRouteInfo } from '../routing/route' - -const CONTENT_EXT_LIST = [...CONTENT_EXTS] - -/** - * Every content page the build knows, keyed by its posix source path, so a - * content link is checked against real files without touching the disk. - */ -export class ContentRouteIndex { - private readonly byRelPath = new Map() - - constructor(files: readonly ResolvedContentFile[]) { - for (const file of files) { - this.byRelPath.set(toIndexKey(file.relPath, file.ext), file) - } - } - - hasSamePages(other: ContentRouteIndex | undefined) { - if (!other || other.byRelPath.size !== this.byRelPath.size) return false - for (const relPath of this.byRelPath.keys()) { - if (!other.byRelPath.has(relPath)) return false - } - return true - } - - findFile(relPath: string) { - return this.byRelPath.get(toIndexKey(relPath, path.posix.extname(relPath))) - } - - /** - * Finds the page an extension-less path names. `a/c` is the file `a/c.*`, - * else the folder page `a/c/index.*` or `a/c/c.*`; `a/c/` asks for the - * folder page first. - */ - findPage(stem: string, preferFolder: boolean) { - if (!stem) return this.findWithContentExt('index') - const file = () => this.findWithContentExt(stem) - const folder = () => - this.findWithContentExt(`${stem}/index`) ?? - this.findWithContentExt(`${stem}/${path.posix.basename(stem)}`) - return preferFolder ? (folder() ?? file()) : (file() ?? folder()) - } - - private findWithContentExt(stem: string) { - for (const ext of CONTENT_EXT_LIST) { - const file = this.byRelPath.get(`${stem}${ext}`) - if (file) return file - } - return undefined - } -} - -/** - * Turns a link written in content into the URL of the page it names. - * - * Relative links are relative to the source file, with or without a content - * extension, and must match a page or the build fails. Root-absolute links - * without a content extension are URLs, possibly served by another build, so - * they pass through unchanged, as do external, hash, query and asset links. - */ -export function resolveContentHref( - href: string, - sourceRelPath: string, - routes: ContentRouteIndex, - config?: SiteConfig, -): string { - if (!href || href.trim() !== href) return href - if (urlNormalizer.isSpecialHref(href)) return href - - const { base, suffix } = urlNormalizer.splitSuffix(href) - const hrefPath = toPosixPath(base) - const ext = path.posix.extname(hrefPath).toLowerCase() - const isContentLink = isContentExt(ext) - const isAbsolute = hrefPath.startsWith('/') - if (isAbsolute && !isContentLink) return href - - const source = toPosixPath(sourceRelPath) - const targetRelPath = isAbsolute - ? resolveAbsoluteTargetRelPath(source, hrefPath, config) - : path.posix.join(path.posix.dirname(source), decodePath(hrefPath)) - const isAsset = Boolean(ext) && !isContentLink - if (escapesContentRoot(targetRelPath)) { - if (isAsset) return href - throw new Error( - `Content link "${href}" in "${source}" escapes the content root.`, - ) - } - - const file = findWithLocaleFallback( - isContentLink ? targetRelPath : toPageStem(targetRelPath), - source, - config, - isContentLink - ? (relPath) => routes.findFile(relPath) - : (stem) => routes.findPage(stem, isFolderPath(hrefPath)), - ) - if (file) return `${resolveRouteInfo(file).urlPath}${suffix}` - // A relative link with a non-content extension names an asset, not a page. - if (isAsset) return href - throw new Error( - isContentLink - ? `Content link "${href}" in "${source}" points to a missing file "${targetRelPath}".` - : `Content link "${href}" in "${source}" does not match any page. Write a root-absolute URL such as "/blog/" for pages outside this content folder.`, - ) -} - -function resolveAbsoluteTargetRelPath( - source: string, - hrefPath: string, - config?: SiteConfig, -) { - const targetRelPath = path.posix.normalize( - decodePath(hrefPath).replace(/^\/+/, ''), - ) - if (!config?.i18n.enabled) return targetRelPath - const sourceLocale = resolveLocale(source, config) - if (!sourceLocale || resolveLocale(targetRelPath, config)) { - return targetRelPath - } - return path.posix.join(sourceLocale, targetRelPath) -} - -/** - * A translated page may link to a page that exists only in the default - * locale; the link then names that page instead of failing the build. - */ -function findWithLocaleFallback( - targetRelPath: string, - source: string, - config: SiteConfig | undefined, - find: (relPath: string) => ResolvedContentFile | undefined, -) { - const file = find(targetRelPath) - if (file || !config?.i18n.enabled) return file - const locale = resolveLocale(targetRelPath, config) - const { defaultLocale } = config.i18n - if (!locale || !defaultLocale || locale === defaultLocale) return undefined - if (resolveLocale(source, config) !== locale) return undefined - return find(`${defaultLocale}${targetRelPath.slice(locale.length)}`) -} - -function resolveLocale(relPath: string, config: SiteConfig) { - const locale = relPath.split('/')[0] - return config.i18n.locales.includes(locale) ? locale : undefined -} - -function escapesContentRoot(relPath: string) { - return relPath === '..' || relPath.startsWith('../') -} - -/** `guides/` and `.` name folders; the content root is the empty stem. */ -function toPageStem(relPath: string) { - const stem = relPath.replace(/\/+$/, '') - return stem === '.' ? '' : stem -} - -/** `./`, `.`, `..` and `guides/` name folders; `guides` may name a file. */ -function isFolderPath(hrefPath: string) { - return /(^|\/)\.{0,2}$/.test(hrefPath) -} - -function decodePath(value: string) { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function toIndexKey(relPath: string, ext: string) { - const posix = toPosixPath(relPath) - return `${posix.slice(0, posix.length - ext.length)}${ext.toLowerCase()}` -} - -function toPosixPath(value: string) { - return value.replaceAll('\\', '/') -} diff --git a/packages/ts-ssg/src/build/content-hrefs.test.ts b/packages/ts-ssg/src/build/content-urls.test.ts similarity index 75% rename from packages/ts-ssg/src/build/content-hrefs.test.ts rename to packages/ts-ssg/src/build/content-urls.test.ts index f2ac33c4..73314c52 100644 --- a/packages/ts-ssg/src/build/content-hrefs.test.ts +++ b/packages/ts-ssg/src/build/content-urls.test.ts @@ -2,19 +2,23 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' import { describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' +import { isContentExt } from '../discover/contentExtensions' import { resolveContentFiles } from '../i18n/content' -import { ContentRouteIndex, resolveContentHref } from './content-hrefs' +import { ContentRouteIndex, resolveContentUrl } from './content-urls' +/** Paths with a content extension become pages; the rest become assets. */ function indexPages(relPaths: string[], config = createConfig()) { + const isPage = (relPath: string) => isContentExt(path.extname(relPath)) return new ContentRouteIndex( resolveContentFiles( config, - relPaths.map((relPath) => ({ + relPaths.filter(isPage).map((relPath) => ({ absPath: relPath, relPath, ext: path.extname(relPath), })), ), + relPaths.filter((relPath) => !isPage(relPath)), ) } @@ -28,10 +32,10 @@ function createConfig(i18n?: Partial) { function resolverFor(relPaths: string[], config = createConfig()) { const routes = indexPages(relPaths, config) return (href: string, sourceRelPath: string) => - resolveContentHref(href, sourceRelPath, routes, config) + resolveContentUrl(href, sourceRelPath, routes, config) } -describe('resolveContentHref', () => { +describe('resolveContentUrl', () => { describe('relative to the source file across folder pages and leaf pages', () => { const resolve = resolverFor([ 'a/a.mdx', @@ -237,17 +241,88 @@ describe('resolveContentHref', () => { ['/blog'], ['/guides/themes/'], ['/'], + ['/assets/site.css'], + ['/components/site/consent/guide-mode.html'], + ['data:image/png;base64,iVBORw0KGgo='], + ['cid:logo@example'], [''], [' ./themes'], - ['./diagram.svg'], - ['../assets/logo.png'], - ['../../../../outside.png'], ])('leaves %j unchanged', (href) => { const resolve = resolverFor(['index.mdx', 'guides/themes.mdx']) expect(resolve(href, 'guides/themes.mdx')).toBe(href) }) + describe('files', () => { + const resolve = resolverFor([ + 'index.mdx', + 'guides/semantic-tones.mdx', + 'guides/themes.mdx', + 'guides/img.png', + 'guides/diagrams/flow.svg', + 'guides/my image.png', + 'guides/LICENSE', + 'guides/app.ts', + 'assets/logo.png', + 'guides\\windows.png', + ]) + const source = 'guides/semantic-tones.mdx' + + it.each([ + ['./img.png', '/guides/img.png'], + ['img.png', '/guides/img.png'], + ['./diagrams/flow.svg#node', '/guides/diagrams/flow.svg#node'], + ['./img.png?v=2', '/guides/img.png?v=2'], + ['../assets/logo.png', '/assets/logo.png'], + ['./my%20image.png', '/guides/my image.png'], + ['./LICENSE', '/guides/LICENSE'], + ['./windows.png', '/guides/windows.png'], + ])('resolves %s to %s', (url, expected) => { + expect(resolve(url, source)).toBe(expected) + }) + + it('resolves files from header and other non-page sources', () => { + expect(resolve('./guides/img.png', 'header.mdx')).toBe('/guides/img.png') + }) + + it('fails when a file is missing or named with different capitals', () => { + expect(() => resolve('./missing.png', source)).toThrow( + 'Content link "./missing.png" in "guides/semantic-tones.mdx" points to a missing file "guides/missing.png".', + ) + expect(() => resolve('./IMG.png', source)).toThrow( + 'points to a missing file "guides/IMG.png"', + ) + }) + + it('fails for TypeScript sources, which are bundled rather than copied', () => { + expect(() => resolve('./app.ts', source)).toThrow( + 'points to a missing file "guides/app.ts"', + ) + }) + + it('fails when a file path escapes the content root', () => { + expect(() => resolve('../../../outside.png', source)).toThrow( + 'Content link "../../../outside.png" in "guides/semantic-tones.mdx" escapes the content root.', + ) + }) + + it('falls back to the default locale for untranslated files', () => { + const config = createConfig({ + defaultLocale: 'en', + locales: ['en', 'tr'], + urlStrategy: 'prefix-all', + }) + const resolveLocalized = resolverFor( + ['en/docs/a.mdx', 'tr/docs/a.mdx', 'en/docs/diagram.png'], + config, + ) + + expect(resolveLocalized('./diagram.png', 'tr/docs/a.mdx')).toBe( + '/en/docs/diagram.png', + ) + }) + }) + it('fails with a hint when a relative link matches no page', () => { const resolve = resolverFor([ 'guides/semantic-tones.mdx', @@ -255,7 +330,7 @@ describe('resolveContentHref', () => { ]) expect(() => resolve('./themse', 'guides/semantic-tones.mdx')).toThrow( - 'Content link "./themse" in "guides/semantic-tones.mdx" does not match any page. Write a root-absolute URL such as "/blog/" for pages outside this content folder.', + 'Content link "./themse" in "guides/semantic-tones.mdx" does not match any page or file. Write a root-absolute URL such as "/blog/" for anything outside this content folder.', ) expect(() => resolve('../blog/', 'guides/semantic-tones.mdx')).toThrow( 'does not match any page', @@ -386,12 +461,25 @@ describe('ContentRouteIndex', () => { it('compares page sets regardless of order', () => { const pages = indexPages(['a.mdx', 'b/index.mdx']) - expect(pages.hasSamePages(indexPages(['b/index.mdx', 'a.mdx']))).toBe(true) - expect(pages.hasSamePages(indexPages(['a.mdx']))).toBe(false) + expect(pages.hasSameFiles(indexPages(['b/index.mdx', 'a.mdx']))).toBe(true) + expect(pages.hasSameFiles(indexPages(['a.mdx']))).toBe(false) expect( - pages.hasSamePages(indexPages(['a.mdx', 'b/index.mdx', 'c.mdx'])), + pages.hasSameFiles(indexPages(['a.mdx', 'b/index.mdx', 'c.mdx'])), ).toBe(false) - expect(pages.hasSamePages(indexPages(['a.mdx', 'b/b.mdx']))).toBe(false) - expect(pages.hasSamePages(undefined)).toBe(false) + expect(pages.hasSameFiles(indexPages(['a.mdx', 'b/b.mdx']))).toBe(false) + expect(pages.hasSameFiles(undefined)).toBe(false) + }) + + it('compares asset sets and keeps them across page updates', () => { + const files = indexPages(['a.mdx', 'img.png']) + + expect(files.hasSameFiles(indexPages(['img.png', 'a.mdx']))).toBe(true) + expect(files.hasSameFiles(indexPages(['a.mdx']))).toBe(false) + expect(files.hasSameFiles(indexPages(['a.mdx', 'logo.png']))).toBe(false) + expect(files.withPages(files.pages).hasSameFiles(files)).toBe(true) + expect(files.withAssets(['img.png', 'app.ts']).hasSameFiles(files)).toBe( + true, + ) + expect(files.withAssets([]).hasAsset('img.png')).toBe(false) }) }) diff --git a/packages/ts-ssg/src/build/content-urls.ts b/packages/ts-ssg/src/build/content-urls.ts new file mode 100644 index 00000000..ab514f80 --- /dev/null +++ b/packages/ts-ssg/src/build/content-urls.ts @@ -0,0 +1,234 @@ +import path from 'node:path' +import type { SiteConfig } from '@purestack/ts-common' +import { isTypeScriptAssetPath, urlNormalizer } from '@purestack/ts-util' +import { CONTENT_EXTS, isContentExt } from '../discover/contentExtensions' +import type { ResolvedContentFile } from '../i18n/content' +import { resolveRouteInfo } from '../routing/route' + +const CONTENT_EXT_LIST = [...CONTENT_EXTS] + +/** + * Every page and asset the build publishes from the content folder, keyed by + * posix source path, so content URLs are checked without touching the disk. + */ +export class ContentRouteIndex { + private readonly pagesByRelPath = new Map() + private readonly assetRelPaths = new Set() + + constructor( + pages: readonly ResolvedContentFile[], + assets: Iterable = [], + ) { + for (const page of pages) { + this.pagesByRelPath.set(toIndexKey(page.relPath, page.ext), page) + } + for (const asset of assets) { + // TypeScript sources become hashed bundles, not files at their own path. + if (!isTypeScriptAssetPath(asset)) { + this.assetRelPaths.add(toPosixPath(asset)) + } + } + } + + get pages() { + return [...this.pagesByRelPath.values()] + } + + withPages(pages: readonly ResolvedContentFile[]) { + return new ContentRouteIndex(pages, this.assetRelPaths) + } + + withAssets(assets: Iterable) { + return new ContentRouteIndex(this.pages, assets) + } + + hasSameFiles(other: ContentRouteIndex | undefined) { + return ( + other !== undefined && + haveSameKeys(this.pagesByRelPath, other.pagesByRelPath) && + haveSameKeys(this.assetRelPaths, other.assetRelPaths) + ) + } + + findFile(relPath: string) { + return this.pagesByRelPath.get( + toIndexKey(relPath, path.posix.extname(relPath)), + ) + } + + /** + * Finds the page an extension-less path names. `a/c` is the file `a/c.*`, + * else the folder page `a/c/index.*` or `a/c/c.*`; `a/c/` asks for the + * folder page first. + */ + findPage(stem: string, preferFolder: boolean) { + if (!stem) return this.findWithContentExt('index') + const file = () => this.findWithContentExt(stem) + const folder = () => + this.findWithContentExt(`${stem}/index`) ?? + this.findWithContentExt(`${stem}/${path.posix.basename(stem)}`) + return preferFolder ? (folder() ?? file()) : (file() ?? folder()) + } + + hasAsset(relPath: string) { + return this.assetRelPaths.has(relPath) + } + + private findWithContentExt(stem: string) { + for (const ext of CONTENT_EXT_LIST) { + const file = this.pagesByRelPath.get(`${stem}${ext}`) + if (file) return file + } + return undefined + } +} + +/** + * Turns a URL written in content into the public URL of the page or file it + * names. + * + * Relative URLs are relative to the source file. Pages may omit their + * extension; files are named exactly. Either must exist or the build fails. + * Root-absolute URLs without a content extension may be served by another + * build, so they pass through unchanged, as do external, hash and query URLs. + */ +export function resolveContentUrl( + url: string, + sourceRelPath: string, + routes: ContentRouteIndex, + config?: SiteConfig, +): string { + if (!url || url.trim() !== url) return url + if (urlNormalizer.isSpecialHref(url)) return url + + const { base, suffix } = urlNormalizer.splitSuffix(url) + const urlPath = toPosixPath(base) + const ext = path.posix.extname(urlPath).toLowerCase() + const isContentLink = isContentExt(ext) + const isAbsolute = urlPath.startsWith('/') + if (isAbsolute && !isContentLink) return url + + const source = toPosixPath(sourceRelPath) + const targetRelPath = isAbsolute + ? resolveAbsoluteTargetRelPath(source, urlPath, config) + : path.posix.join(path.posix.dirname(source), decodePath(urlPath)) + if (escapesContentRoot(targetRelPath)) { + throw new Error( + `Content link "${url}" in "${source}" escapes the content root.`, + ) + } + + const publicPath = findWithLocaleFallback( + targetRelPath, + source, + config, + (relPath) => findPublicPath(routes, relPath, urlPath, isContentLink), + ) + if (publicPath) return `${publicPath}${suffix}` + throw new Error( + ext + ? `Content link "${url}" in "${source}" points to a missing file "${targetRelPath}".` + : `Content link "${url}" in "${source}" does not match any page or file. Write a root-absolute URL such as "/blog/" for anything outside this content folder.`, + ) +} + +function findPublicPath( + routes: ContentRouteIndex, + targetRelPath: string, + urlPath: string, + isContentLink: boolean, +) { + if (isContentLink) return toPageUrl(routes.findFile(targetRelPath)) + const stem = toPageStem(targetRelPath) + const preferFolder = isFolderPath(urlPath) + // A page always has a content extension, so an exact file is an asset. + if (!preferFolder && routes.hasAsset(stem)) return `/${stem}` + return toPageUrl(routes.findPage(stem, preferFolder)) +} + +function toPageUrl(page: ResolvedContentFile | undefined) { + return page ? resolveRouteInfo(page).urlPath : undefined +} + +function resolveAbsoluteTargetRelPath( + source: string, + urlPath: string, + config?: SiteConfig, +) { + const targetRelPath = path.posix.normalize( + decodePath(urlPath).replace(/^\/+/, ''), + ) + if (!config?.i18n.enabled) return targetRelPath + const sourceLocale = resolveLocale(source, config) + if (!sourceLocale || resolveLocale(targetRelPath, config)) { + return targetRelPath + } + return path.posix.join(sourceLocale, targetRelPath) +} + +/** + * A translated page may use a page or file that exists only in the default + * locale; the URL then names that one instead of failing the build. + */ +function findWithLocaleFallback( + targetRelPath: string, + source: string, + config: SiteConfig | undefined, + find: (relPath: string) => string | undefined, +) { + const publicPath = find(targetRelPath) + if (publicPath || !config?.i18n.enabled) return publicPath + const locale = resolveLocale(targetRelPath, config) + const { defaultLocale } = config.i18n + if (!locale || !defaultLocale || locale === defaultLocale) return undefined + if (resolveLocale(source, config) !== locale) return undefined + return find(`${defaultLocale}${targetRelPath.slice(locale.length)}`) +} + +function resolveLocale(relPath: string, config: SiteConfig) { + const locale = relPath.split('/')[0] + return config.i18n.locales.includes(locale) ? locale : undefined +} + +function escapesContentRoot(relPath: string) { + return relPath === '..' || relPath.startsWith('../') +} + +/** `guides/` and `.` name folders; the content root is the empty stem. */ +function toPageStem(relPath: string) { + const stem = relPath.replace(/\/+$/, '') + return stem === '.' ? '' : stem +} + +/** `./`, `.`, `..` and `guides/` name folders; `guides` may name a file. */ +function isFolderPath(urlPath: string) { + return /(^|\/)\.{0,2}$/.test(urlPath) +} + +function decodePath(value: string) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +function haveSameKeys( + left: ReadonlyMap | ReadonlySet, + right: ReadonlyMap | ReadonlySet, +) { + if (left.size !== right.size) return false + for (const key of left.keys()) { + if (!right.has(key)) return false + } + return true +} + +function toIndexKey(relPath: string, ext: string) { + const posix = toPosixPath(relPath) + return `${posix.slice(0, posix.length - ext.length)}${ext.toLowerCase()}` +} + +function toPosixPath(value: string) { + return value.replaceAll('\\', '/') +} diff --git a/packages/ts-ssg/src/build/incremental/change-applier.ts b/packages/ts-ssg/src/build/incremental/change-applier.ts index 474e1eeb..c92c4d5d 100644 --- a/packages/ts-ssg/src/build/incremental/change-applier.ts +++ b/packages/ts-ssg/src/build/incremental/change-applier.ts @@ -204,6 +204,7 @@ export class IncrementalChangeApplier { await removeFile(assetEntry.outPath) delete manifest.assets[relPath] this.input.scriptEntrypoints.removeTrackedEntrypoint(relPath) + this.input.contentState.refreshAssets() result.deletedAssets += 1 } if (ext === '.ts') await this.handleScriptAssetChange(relPath, result) @@ -268,6 +269,7 @@ export class IncrementalChangeApplier { outPath: assetCopy.outPath, ...signature, } + this.input.contentState.refreshAssets() result.changedAssets += 1 await this.input.persistManifest() } diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 66afc8f5..3aa3bb18 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -7,7 +7,7 @@ import { resolveContentFile, } from '../../i18n/content' import { buildNavigation } from '../../navigation/navigation' -import { ContentRouteIndex } from '../content-hrefs' +import type { ContentRouteIndex } from '../content-urls' import { type BuildManifest, type FileSignature, @@ -122,20 +122,32 @@ export class IncrementalContentState { /** * Re-reads the site's pages. Adding or removing a page can change where - * any content link resolves, so every page becomes dirty; edits that keep + * any content URL resolves, so every page becomes dirty; edits that keep * the same pages cost nothing beyond discovery. */ async refreshContent() { const { config, context } = this.input const contentFiles = await discoverSiteContent(config) - const contentRoutes = new ContentRouteIndex(contentFiles) - const pagesChanged = !contentRoutes.hasSamePages(context.contentRoutes) - context.contentRoutes = contentRoutes + this.updateContentRoutes(context.contentRoutes.withPages(contentFiles)) context.translationsByKey = buildTranslationsByKey(contentFiles) - if (pagesChanged) this.markAllPagesDirty(contentFiles) return contentFiles } + /** Picks up added or removed assets from the manifest. */ + refreshAssets() { + const { context, getManifest } = this.input + this.updateContentRoutes( + context.contentRoutes.withAssets(Object.keys(getManifest().assets)), + ) + } + + private updateContentRoutes(contentRoutes: ContentRouteIndex) { + const { context } = this.input + const filesChanged = !contentRoutes.hasSameFiles(context.contentRoutes) + context.contentRoutes = contentRoutes + if (filesChanged) this.markAllPagesDirty(contentRoutes.pages) + } + async refreshNavigationAndMarkDirty() { const contentFiles = await this.refreshContent() this.input.context.navigation = await buildNavigation( diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index 85ed98b7..5368e82c 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -528,6 +528,70 @@ describe('incremental builder', () => { }, ) + it.each(['auto', 'none'] as const)( + 're-renders a page once its missing image is added (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createLinkSite(base, mode, { + 'index.mdx': '![Logo](./images/logo.png)', + }) + expect(await site.readIndex()).toContain('points to a missing file') + + const logoPath = path.join(site.contentDir, 'images', 'logo.png') + await fs.mkdir(path.dirname(logoPath), { recursive: true }) + await fs.writeFile(logoPath, 'png', 'utf8') + const result = await site.builder.applyChange(logoPath) + + expect(result.changedAssets).toBe(1) + expect( + await site.builder.renderIfDirtyByOutPath(site.indexOutPath), + ).toBe(true) + expect(await site.readIndex()).toContain('src="/images/logo.png"') + }) + }, + ) + + it.each(['auto', 'none'] as const)( + 're-renders a page once its image is deleted (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createLinkSite(base, mode, { + 'index.mdx': '![Logo](./logo.png)', + 'logo.png': 'png', + }) + expect(await site.readIndex()).toContain('src="/logo.png"') + + const logoPath = path.join(site.contentDir, 'logo.png') + await fs.rm(logoPath) + const result = await site.builder.applyChange(logoPath) + + expect(result.deletedAssets).toBe(1) + expect( + await site.builder.renderIfDirtyByOutPath(site.indexOutPath), + ).toBe(true) + expect(await site.readIndex()).toContain('points to a missing file') + }) + }, + ) + + it('keeps pages clean when an image changes in place', async () => { + await withTempDir(async (base) => { + const site = await createLinkSite(base, 'none', { + 'index.mdx': '![Logo](./logo.png)', + 'logo.png': 'png', + }) + + const logoPath = path.join(site.contentDir, 'logo.png') + await fs.writeFile(logoPath, 'png, edited', 'utf8') + const result = await site.builder.applyChange(logoPath) + + expect(result.changedAssets).toBe(1) + expect( + await site.builder.renderIfDirtyByOutPath(site.indexOutPath), + ).toBe(false) + }) + }) + it('keeps other pages clean when an edit keeps the same pages', async () => { await withTempDir(async (base) => { const site = await createLinkSite(base, 'none', { diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index f821b023..ab5b1701 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -19,7 +19,7 @@ import { initBuiltinComponents } from '../../regor/initBuiltinComponents' import { resolveRouteInfo } from '../../routing/route' import { copyStaticAssets } from '../assets' import { resolveBuildSiteConfig } from '../build-config' -import { ContentRouteIndex } from '../content-hrefs' +import { ContentRouteIndex } from '../content-urls' import { writeGeneratedFavicon } from '../favicon' import { prepareOutDir } from '../io' import { @@ -98,7 +98,10 @@ async function createIncrementalRuntime( const scriptCacheKeys = new ScriptCacheKeyStore(manifest.assets) const context: BuildContext = { config, - contentRoutes: new ContentRouteIndex(discovered), + contentRoutes: new ContentRouteIndex( + discovered, + Object.keys(manifest.assets), + ), writeErrorPages: buildOptions.writeErrorPages === true, components: buildOptions.components, templates: buildOptions.templates, @@ -274,7 +277,10 @@ class IncrementalRuntime { copiedAssets.tsDependencyIndex, ) const contentFiles = await discoverSiteContent(this.config) - this.context.contentRoutes = new ContentRouteIndex(contentFiles) + this.context.contentRoutes = new ContentRouteIndex( + contentFiles, + copiedAssets.files.map((file) => file.relPath), + ) await resolveHeaderFooterHtml(this.context) await hooks.onContentDiscovered?.(this.context, contentFiles) this.context.navigation = await buildNavigation( diff --git a/packages/ts-ssg/src/build/page-urls.test.ts b/packages/ts-ssg/src/build/page-urls.test.ts index 8e3e3463..5de8e1e9 100644 --- a/packages/ts-ssg/src/build/page-urls.test.ts +++ b/packages/ts-ssg/src/build/page-urls.test.ts @@ -3,7 +3,7 @@ import { parseHtml } from '@purestack/ts-minidom' import { describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' import { resolveContentFiles } from '../i18n/content' -import { ContentRouteIndex } from './content-hrefs' +import { ContentRouteIndex } from './content-urls' import { markContentSource, resolvePageUrls } from './page-urls' const PAGES = [ @@ -15,6 +15,18 @@ const PAGES = [ 'guides/say "hi".mdx', ] +const ASSETS = [ + 'assets/logo.svg', + 'guides/img.png', + 'guides/img@2x.png', + 'guides/poster.jpg', + 'guides/video.mp4', + 'guides/captions.vtt', + 'guides/guide.pdf', + 'guides/icons.svg', + 'guides/demo.html', +] + function resolveBody( bodyHtml: string, { sourceRelPath = 'guides/semantic-tones.mdx', basePath = '' } = {}, @@ -29,6 +41,7 @@ function resolveBody( ext: path.extname(relPath), })), ), + ASSETS, ) const { document } = parseHtml( `${bodyHtml}`, @@ -141,9 +154,85 @@ describe('resolvePageUrls', () => { ) }) + it('resolves image, media and file URLs from the file that wrote them', () => { + expect( + resolveBody( + [ + '', + '', + '', + 'Guide', + '', + '', + '', + ].join(''), + ), + ).toBe( + [ + '', + '', + '', + 'Guide', + '', + '', + '', + ].join(''), + ) + }) + + it('resolves files in marked partials from the partial folder', () => { + expect( + resolveBody( + markContentSource( + '', + 'guides/header.mdx', + ), + { sourceRelPath: 'guides/deep/page.mdx' }, + ), + ).toBe('') + }) + + it('parses srcset candidates like the HTML parser', () => { + const dataUrl = 'data:image/png;base64,iVBOR,w0KGgo=' + + expect( + resolveBody( + ``, + ), + ).toBe( + ``, + ) + }) + + it('adds the base path to resolved files', () => { + expect( + resolveBody( + '', + { basePath: '/docs' }, + ), + ).toBe( + '', + ) + }) + + it('keeps relative form actions, which name endpoints rather than files', () => { + expect(resolveBody('
')).toBe( + '
', + ) + }) + + it('fails with the file that wrote a missing image', () => { + expect(() => resolveBody('')).toThrow( + 'Content link "./missing.png" in "guides/semantic-tones.mdx" points to a missing file "guides/missing.png".', + ) + expect(() => + resolveBody(''), + ).toThrow('points to a missing file "guides/missing@2x.png"') + }) + it('fails with the file that wrote a broken link', () => { expect(() => resolveBody('Typo')).toThrow( - 'Content link "./themse" in "guides/semantic-tones.mdx" does not match any page.', + 'Content link "./themse" in "guides/semantic-tones.mdx" does not match any page or file.', ) expect(() => resolveBody( diff --git a/packages/ts-ssg/src/build/page-urls.ts b/packages/ts-ssg/src/build/page-urls.ts index 4ad22f40..8475a7ca 100644 --- a/packages/ts-ssg/src/build/page-urls.ts +++ b/packages/ts-ssg/src/build/page-urls.ts @@ -1,10 +1,10 @@ import type { SiteConfig } from '@purestack/ts-common' import { escapeHtml, withBasePath } from '@purestack/ts-util' -import { type ContentRouteIndex, resolveContentHref } from './content-hrefs' +import { type ContentRouteIndex, resolveContentUrl } from './content-urls' const CONTENT_SOURCE_TAG = 'purestack-content-source' const CONTENT_SOURCE_TAG_NAME = CONTENT_SOURCE_TAG.toUpperCase() -const PUBLIC_URL_ATTRIBUTES = ['src', 'action', 'poster'] as const +const CONTENT_URL_ATTRIBUTES = ['href', 'src', 'poster'] as const export interface PageUrlOptions { sourceRelPath: string @@ -14,7 +14,7 @@ export interface PageUrlOptions { /** * Marks HTML written in another content file, such as a shared header, so - * the links inside it resolve from that file rather than from the page. + * the URLs inside it resolve from that file rather than from the page. */ export function markContentSource(html: string, sourceRelPath: string) { const file = escapeHtml(sourceRelPath.replaceAll('\\', '/'), true) @@ -22,9 +22,9 @@ export function markContentSource(html: string, sourceRelPath: string) { } /** - * Finishes every URL in a rendered page. Each `href` resolves from the - * content file that wrote it, and root-absolute URLs gain the base path. - * Source markers are removed, so the page keeps the markup its files wrote. + * Finishes every URL in a rendered page. Links, sources and posters resolve + * from the content file that wrote them, and root-absolute URLs gain the + * base path. Source markers are removed, so the page keeps its markup. */ export function resolvePageUrls(document: Document, options: PageUrlOptions) { visitChildren(document, options.sourceRelPath, options) @@ -57,52 +57,68 @@ function resolveElementUrls( options: PageUrlOptions, ) { const { basePath } = options.config - const href = element.getAttribute('href') - if (href !== null) { - const resolved = resolveContentHref( - href, - sourceRelPath, - options.contentRoutes, - options.config, - ) - updateAttribute(element, 'href', href, withBasePath(basePath, resolved)) - } - if (!basePath) return - for (const name of PUBLIC_URL_ATTRIBUTES) { - const value = element.getAttribute(name) - if (value !== null) { - updateAttribute(element, name, value, withBasePath(basePath, value)) - } - } - const srcset = element.getAttribute('srcset') - if (srcset !== null) { - updateAttribute( - element, - 'srcset', - srcset, - withSrcsetBasePath(basePath, srcset), + const finishUrl = (url: string) => + withBasePath( + basePath, + resolveContentUrl( + url, + sourceRelPath, + options.contentRoutes, + options.config, + ), ) + for (const name of CONTENT_URL_ATTRIBUTES) { + updateAttribute(element, name, finishUrl) } + updateAttribute(element, 'srcset', (srcset) => + mapSrcsetUrls(srcset, finishUrl), + ) + // A form posts to an endpoint, which is not a content file. + updateAttribute(element, 'action', (action) => withBasePath(basePath, action)) } -function withSrcsetBasePath(basePath: string, srcset: string) { - return srcset - .split(',') - .map((candidate) => { - const url = candidate.trim().split(/\s+/)[0] - return url - ? candidate.replace(url, withBasePath(basePath, url)) - : candidate - }) - .join(',') +/** + * Maps each image candidate URL in a `srcset`. Like the HTML parser, a URL + * runs to the next whitespace, so commas inside `data:` URLs stay intact. + */ +function mapSrcsetUrls(srcset: string, mapUrl: (url: string) => string) { + let output = '' + let index = 0 + while (index < srcset.length) { + const separatorStart = index + while (index < srcset.length && /[\s,]/.test(srcset[index])) index++ + output += srcset.slice(separatorStart, index) + if (index >= srcset.length) break + + const urlStart = index + while (index < srcset.length && !/\s/.test(srcset[index])) index++ + const token = srcset.slice(urlStart, index) + const url = token.replace(/,+$/, '') + output += mapUrl(url) + token.slice(url.length) + if (url !== token) continue + + const descriptorStart = index + let depth = 0 + while (index < srcset.length) { + const current = srcset[index] + if (current === ',' && depth === 0) break + if (current === '(') depth++ + if (current === ')') depth = Math.max(0, depth - 1) + index++ + } + output += srcset.slice(descriptorStart, index) + } + return output } function updateAttribute( element: Element, name: string, - value: string, - next: string, + finish: (value: string) => string, ) { + const value = element.getAttribute(name) + if (value === null) return + const next = finish(value) if (next !== value) element.setAttribute(name, next) } diff --git a/packages/ts-ssg/src/build/page.footer.test.ts b/packages/ts-ssg/src/build/page.footer.test.ts index c2ec3dcb..8b6e89e6 100644 --- a/packages/ts-ssg/src/build/page.footer.test.ts +++ b/packages/ts-ssg/src/build/page.footer.test.ts @@ -6,14 +6,14 @@ import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' -import { discoverContent } from '../discover/content' +import { discoverContent, discoverStaticAssets } from '../discover/content' import { type ResolvedContentFile, resolveContentFiles, resolvePlainContentFile, } from '../i18n/content' import { initBuiltinComponents } from '../regor/initBuiltinComponents' -import { ContentRouteIndex } from './content-hrefs' +import { ContentRouteIndex } from './content-urls' import { renderPageFromFile, resolveFooterHtmlByDirectory, @@ -194,7 +194,7 @@ describe('footer hierarchy', () => { toContentFile(contentDir, 'index.mdx'), ), ).rejects.toThrow( - 'Content link "./missing" in "header.mdx" does not match any page.', + 'Content link "./missing" in "header.mdx" does not match any page or file.', ) } finally { await fs.rm(root, { recursive: true, force: true }) @@ -205,6 +205,7 @@ describe('footer hierarchy', () => { async function indexContent(config: SiteConfig) { return new ContentRouteIndex( resolveContentFiles(config, await discoverContent(config.contentDir)), + (await discoverStaticAssets(config.contentDir)).map((file) => file.relPath), ) } diff --git a/packages/ts-ssg/src/build/page.tabs.test.ts b/packages/ts-ssg/src/build/page.tabs.test.ts index 32263817..2cef2afb 100644 --- a/packages/ts-ssg/src/build/page.tabs.test.ts +++ b/packages/ts-ssg/src/build/page.tabs.test.ts @@ -14,7 +14,7 @@ import { type ResolvedContentFile, resolvePlainContentFile, } from '../i18n/content' -import { ContentRouteIndex } from './content-hrefs' +import { ContentRouteIndex } from './content-urls' import { renderPageFromFile } from './page' async function writeFile(filePath: string, contents = '') { diff --git a/packages/ts-ssg/src/build/page.test.ts b/packages/ts-ssg/src/build/page.test.ts index c77f817f..6fbdf661 100644 --- a/packages/ts-ssg/src/build/page.test.ts +++ b/packages/ts-ssg/src/build/page.test.ts @@ -5,7 +5,7 @@ import type { SiteConfig } from '@purestack/ts-common' import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' -import { discoverContent } from '../discover/content' +import { discoverContent, discoverStaticAssets } from '../discover/content' import { buildTranslationsByKey, type ResolvedContentFile, @@ -15,7 +15,7 @@ import { } from '../i18n/content' import { buildNavigation } from '../navigation/navigation' import { initBuiltinComponents } from '../regor/initBuiltinComponents' -import { ContentRouteIndex } from './content-hrefs' +import { ContentRouteIndex } from './content-urls' import { renderPageFromFile } from './page' async function writeFile(filePath: string, contents = '') { @@ -272,12 +272,19 @@ describe('page content compilation', () => { '', `Computed button`, '', + '![Palette diagram](./images/palette.svg)', + '', '```md', '[Missing](./missing)', + '![Missing](./missing.png)', '```', ].join('\n'), ) await writeFile(path.join(contentDir, 'guides', 'themes.mdx'), '# Themes') + await writeFile( + path.join(contentDir, 'guides', 'images', 'palette.svg'), + '', + ) await writeFile(path.join(contentDir, 'index.mdx'), '# Home') const config = resolveSiteConfig({ rootDir: root, contentDir, outDir }) @@ -291,8 +298,12 @@ describe('page content compilation', () => { expect(page.html).toContain('Home') expect(page.html).toMatch(/]*href="\/guides\/themes\/"[^>]*>/) expect(page.html).toContain('href="/guides/themes/#create-a-skin"') + expect(page.html).toContain( + 'Palette diagram', + ) expect(page.html).toContain('[Missing](./missing)') - expect(page.html).not.toMatch(/href="\.{1,2}\//) + expect(page.html).toContain('![Missing](./missing.png)') + expect(page.html).not.toMatch(/(href|src)="\.{1,2}\//) } finally { await fs.rm(root, { recursive: true, force: true }) } @@ -340,7 +351,7 @@ describe('page content compilation', () => { toContentFile(contentDir, 'index.mdx'), ), ).rejects.toThrow( - 'Content link "./themse" in "index.mdx" does not match any page.', + 'Content link "./themse" in "index.mdx" does not match any page or file.', ) } finally { await fs.rm(root, { recursive: true, force: true }) @@ -697,6 +708,7 @@ describe('page content compilation', () => { async function indexContent(config: SiteConfig) { return new ContentRouteIndex( resolveContentFiles(config, await discoverContent(config.contentDir)), + (await discoverStaticAssets(config.contentDir)).map((file) => file.relPath), ) } diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index 4ffeb965..6eeae404 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -36,7 +36,7 @@ import { resolvePageNavigation, } from '../navigation/navigation' import { resolveRouteInfo } from '../routing/route' -import type { ContentRouteIndex } from './content-hrefs' +import type { ContentRouteIndex } from './content-urls' import { resolveHeadConfig } from './head-config' import { readSource, writeHtml } from './io' import { resolveOutPath } from './out-path' diff --git a/packages/ts-ssg/src/config/config.test.ts b/packages/ts-ssg/src/config/config.test.ts index a76d6004..6bfe71a6 100644 --- a/packages/ts-ssg/src/config/config.test.ts +++ b/packages/ts-ssg/src/config/config.test.ts @@ -514,6 +514,58 @@ describe('resolveSiteConfig sitemap', () => { expect(config.consent.services[0]?.scripts).toHaveLength(2) }) + it('resolves relative config URLs from the content root', () => { + const config = resolveSiteConfig({ + rootDir: process.cwd(), + style: { href: 'assets/custom.css' }, + logo: { + href: './', + imageSrc: './assets/../assets/logo.svg?v=2', + imageSrcDark: 'assets\\logo-dark.svg#mark', + }, + consent: { + privacyPolicyUrl: 'legal/privacy/', + categories: [ + { id: 'necessary', label: 'Necessary', required: true }, + { id: 'analytics', label: 'Analytics' }, + ], + services: [ + { + id: 'stats', + category: 'analytics', + scripts: [{ src: 'scripts/stats.js' }], + }, + ], + }, + }) + + expect(config.style.href).toBe('/assets/custom.css') + expect(config.logo.href).toBe('/') + expect(config.logo.imageSrc).toBe('/assets/logo.svg?v=2') + expect(config.logo.imageSrcDark).toBe('/assets/logo-dark.svg#mark') + expect(config.consent.privacyPolicyUrl).toBe('/legal/privacy/') + expect(config.consent.services[0]?.scripts[0]?.src).toBe( + '/scripts/stats.js', + ) + }) + + it('keeps root-absolute and external config URLs as written', () => { + const config = resolveSiteConfig({ + rootDir: process.cwd(), + style: { href: '/assets/site.css' }, + logo: { + href: 'https://example.com/', + imageSrc: 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E', + }, + consent: { privacyPolicyUrl: 'https://example.com/privacy' }, + }) + + expect(config.style.href).toBe('/assets/site.css') + expect(config.logo.href).toBe('https://example.com/') + expect(config.logo.imageSrc).toBe('data:image/svg+xml,%3Csvg%3E%3C/svg%3E') + expect(config.consent.privacyPolicyUrl).toBe('https://example.com/privacy') + }) + it('throws when consent service points to an unknown category', () => { expect(() => resolveSiteConfig({ diff --git a/packages/ts-ssg/src/config/config.ts b/packages/ts-ssg/src/config/config.ts index a61ccb53..ad7af602 100644 --- a/packages/ts-ssg/src/config/config.ts +++ b/packages/ts-ssg/src/config/config.ts @@ -31,7 +31,11 @@ import { themes, } from '@purestack/ts-style' import type { DeepPartial } from '@purestack/ts-util' -import { isPlainObject, normalizeBasePath } from '@purestack/ts-util' +import { + isPlainObject, + normalizeBasePath, + urlNormalizer, +} from '@purestack/ts-util' import { resolveNavigationConfig } from '../navigation/navigation' const DEFAULT_ROOT = path.resolve( @@ -204,10 +208,8 @@ function resolveStyleConfig( styleFile?.fileName, 'site.css', ) - const href = resolveString( - styleInput?.href, - styleFile?.href, - `/assets/${fileName}`, + const href = resolveConfigUrl( + resolveString(styleInput?.href, styleFile?.href, `/assets/${fileName}`), ) const themeNames = resolveThemes(styleInput?.themes, styleFile?.themes) const pretty = pickBoolean(styleInput?.pretty, styleFile?.pretty, false) @@ -276,14 +278,19 @@ function resolveSiteLogoConfig( ), logoBackground: input?.logoBackground ?? file?.logoBackground, logoForeground: input?.logoForeground ?? file?.logoForeground, - href: href === null || href === '' ? href : resolveString(href, '/'), + href: + href === null || href === '' + ? href + : resolveConfigUrl(resolveString(href, '/')), subtitle: resolveOptionalString(input?.subtitle ?? file?.subtitle), suffix: resolveOptionalString(input?.suffix ?? file?.suffix), ariaLabel: resolveOptionalString(input?.ariaLabel ?? file?.ariaLabel), icon: resolveOptionalString(input?.icon ?? file?.icon), - imageSrc: resolveOptionalString(input?.imageSrc ?? file?.imageSrc), - imageSrcDark: resolveOptionalString( - input?.imageSrcDark ?? file?.imageSrcDark, + imageSrc: resolveConfigUrl( + resolveOptionalString(input?.imageSrc ?? file?.imageSrc), + ), + imageSrcDark: resolveConfigUrl( + resolveOptionalString(input?.imageSrcDark ?? file?.imageSrcDark), ), monogram: resolveOptionalString(input?.monogram ?? file?.monogram), brandColor: resolveOptionalString(input?.brandColor ?? file?.brandColor), @@ -438,8 +445,8 @@ function resolveConsentConfig( file?.bannerDescription, 'We use cookies and similar technologies to improve your experience. You can accept all, reject non-essential, or manage preferences.', ), - privacyPolicyUrl: resolveOptionalString( - input?.privacyPolicyUrl ?? file?.privacyPolicyUrl, + privacyPolicyUrl: resolveConfigUrl( + resolveOptionalString(input?.privacyPolicyUrl ?? file?.privacyPolicyUrl), ), privacyPolicyLabel: resolveString( input?.privacyPolicyLabel, @@ -783,7 +790,7 @@ function resolveConsentServiceScripts( const resolved: ConsentScript[] = [] for (const entry of scripts) { if (!isPlainObject(entry)) continue - const src = resolveOptionalString(entry.src) + const src = resolveConfigUrl(resolveOptionalString(entry.src)) const content = resolveOptionalString(entry.content) if (!src && !content) { throw new Error( @@ -850,6 +857,20 @@ function resolveOptionalString(value: unknown) { return normalized.length > 0 ? normalized : undefined } +/** + * Site configuration lives at the content root, so its relative URLs start + * there: `assets/logo.svg` means `/assets/logo.svg` on every page. + */ +function resolveConfigUrl(value: string): string +function resolveConfigUrl(value: string | undefined): string | undefined +function resolveConfigUrl(value: string | undefined) { + if (!value || value.startsWith('/') || urlNormalizer.isSpecialHref(value)) { + return value + } + const { base, suffix } = urlNormalizer.splitSuffix(value) + return `${path.posix.normalize(`/${base.replaceAll('\\', '/')}`)}${suffix}` +} + function normalizeOptionalNumber(value: unknown) { if (typeof value !== 'number' || Number.isNaN(value)) return undefined if (value < 0) return undefined