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
+
+
+[Download the checklist](./checklist.pdf)
+```
+
+```html
+
+
+
+```
+
+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': '',
+ })
+ 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.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.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`,
'',
+ '',
+ '',
'```md',
'[Missing](./missing)',
+ '',
'```',
].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(
+ '
',
+ )
expect(page.html).toContain('[Missing](./missing)')
- expect(page.html).not.toMatch(/href="\.{1,2}\//)
+ expect(page.html).toContain('')
+ 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