Standalone
', + { + components: {}, + context: context as PageContext, + onRendered: (document, runtimeEmbeds) => + appendRuntimeScripts(document, runtimeEmbeds, { tabs: 'body' }), + }, + ) + expect(output).toContain('Standalone
') + expect(output).toContain('tsSsgTabs') + }) + it('preserves script order and lets recorded placement override frontmatter', () => { + runInDom('', (document) => { + appendRuntimeScripts( + document, + new Map([ + ['tabs', 'head'], + ['modal', 'body'], + ]), + { tabs: 'body', modal: 'head' }, + ) + expect(document.head.querySelectorAll('script')).toHaveLength(1) + expect(document.head.querySelector('script')?.textContent).toContain( + 'tsSsgTabs', + ) + expect(document.body.querySelectorAll('script')).toHaveLength(1) + expect(document.body.querySelector('script')?.textContent).toContain( + 'tsSsgModal', + ) + }) + }) + + it('supports explicit frontmatter embeds without rendered components', () => { + runInDom('', (document) => { + appendRuntimeScripts(document, new Map(), { tabs: 'body', modal: 'body' }) + const scripts = [...document.body.querySelectorAll('script')] + expect(scripts).toHaveLength(2) + expect(scripts[0].textContent).toContain('tsSsgTabs') + expect(scripts[1].textContent).toContain('tsSsgModal') + }) + }) +}) diff --git a/packages/ts-page-scripts/src/runtimeScripts.ts b/packages/ts-page-scripts/src/runtimeScripts.ts new file mode 100644 index 00000000..e42c8c82 --- /dev/null +++ b/packages/ts-page-scripts/src/runtimeScripts.ts @@ -0,0 +1,26 @@ +import type { + FrontmatterEmbedOptions, + RuntimeEmbeds, +} from '@purestack/ts-common' +import { buildModalScript } from './embed/modal.embed' +import { buildTabsScript } from './embed/tabs.embed' + +/** Adds component runtimes after rendering; recorded placement overrides frontmatter. */ +export function appendRuntimeScripts( + document: Document, + runtimeEmbeds: RuntimeEmbeds, + defaults: FrontmatterEmbedOptions = {}, +) { + for (const [name, build] of [ + ['tabs', buildTabsScript], + ['modal', buildModalScript], + ] as const) { + const position = runtimeEmbeds.get(name) ?? defaults[name] + if (!position) continue + const parent = position === 'body' ? document.body : document.head + if (!parent) continue + const script = document.createElement('script') + script.textContent = build() + parent.appendChild(script) + } +} diff --git a/packages/ts-pagefind/LICENSE b/packages/ts-pagefind/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-pagefind/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Yasin Koculu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ts-pagefind/README.md b/packages/ts-pagefind/README.md new file mode 100644 index 00000000..4007e345 --- /dev/null +++ b/packages/ts-pagefind/README.md @@ -0,0 +1,27 @@ +# @purestack/ts-pagefind + +Pagefind indexing and the default PureStack search provider. + +```ts +import { pagefindPlugin } from '@purestack/ts-pagefind' + +const plugin = pagefindPlugin() +``` + +Included automatically by `ts-ssg`. The `search(config)` contribution supplies +a `SiteSearch` provider. The builder coordinates full builds and lazy requests, +serializes concurrent indexing requests, and invalidates search when pages are +rendered or removed. Pagefind owns its indexing implementation and generated +`/pagefind/` assets. + +The provider preserves `pagefind.enabled`, `excludePaths`, `index: false`, +generated pages, base paths, stale-output cleanup, and warning behavior. Lazy +development indexes only pages rendered in the current session. Full builds +also include eligible static HTML files, as before. The browser search runtime +belongs to `ts-page-scripts` and follows the same site configuration. + +`buildPagefindIndex` is exported for standalone use. To use a different search +engine, replace `purestack:pagefind` by name or disable automatic plugins and +supply a `search(config)` contribution. Its provider implements +`ownsPath(pathname)` and `write({ outDir, excludeFiles, onlyFiles })`; no new +lifecycle hook is required. diff --git a/packages/ts-pagefind/package.json b/packages/ts-pagefind/package.json new file mode 100644 index 00000000..b41be1be --- /dev/null +++ b/packages/ts-pagefind/package.json @@ -0,0 +1,46 @@ +{ + "name": "@purestack/ts-pagefind", + "version": "1.1.15", + "packageManager": "yarn@4.9.2", + "module": "./dist/ts-pagefind.mjs", + "types": "./dist/ts-pagefind.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-pagefind" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-pagefind.d.mts", + "import": "./dist/ts-pagefind.mjs" + } + }, + "license": "MIT", + "files": [ + "dist/*.mjs", + "dist/*.mjs.map", + "dist/*.d.mts", + "dist/*.d.mts.map", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "lint": "biome check ./src --formatter-enabled=false --write", + "test": "vitest" + }, + "dependencies": { + "@purestack/ts-common": "workspace:^", + "logpot": "^1.0.7", + "pagefind": "^1.4.0" + } +} diff --git a/packages/ts-pagefind/src/index.ts b/packages/ts-pagefind/src/index.ts new file mode 100644 index 00000000..6155323b --- /dev/null +++ b/packages/ts-pagefind/src/index.ts @@ -0,0 +1,24 @@ +import type { SiteConfig, SiteSearch } from '@purestack/ts-common' +import { buildPagefindIndex } from './pagefind' + +export { type BuildPagefindResult, buildPagefindIndex } from './pagefind' + +/** The default search index; indexing schedules remain owned by the builder. */ +export function pagefindPlugin() { + return { + name: 'purestack:pagefind', + search(config: SiteConfig): SiteSearch { + return { + ownsPath: (pathname) => pathname.startsWith('/pagefind/'), + async write({ outDir, excludeFiles, onlyFiles }) { + await buildPagefindIndex( + outDir, + config.pagefind, + excludeFiles, + onlyFiles, + ) + }, + } + }, + } +} diff --git a/packages/ts-ssg/src/build/pagefind.test.ts b/packages/ts-pagefind/src/pagefind.test.ts similarity index 100% rename from packages/ts-ssg/src/build/pagefind.test.ts rename to packages/ts-pagefind/src/pagefind.test.ts diff --git a/packages/ts-ssg/src/build/pagefind.ts b/packages/ts-pagefind/src/pagefind.ts similarity index 100% rename from packages/ts-ssg/src/build/pagefind.ts rename to packages/ts-pagefind/src/pagefind.ts diff --git a/packages/ts-pagefind/tsconfig.json b/packages/ts-pagefind/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-pagefind/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "noImplicitOverride": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "paths": { + "@purestack/*": ["../../packages/*/src"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["src/test/**", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/ts-render/README.md b/packages/ts-render/README.md index 80be6fcb..ac0102e6 100644 --- a/packages/ts-render/README.md +++ b/packages/ts-render/README.md @@ -1,16 +1,36 @@ # @purestack/ts-render Server-side rendering for PureStack's Regor components. `renderApp` takes HTML, -a component set, and a `PageContext`, and resolves to the rendered HTML with -any required component scripts attached. +a component set, and a `PageContext`, and resolves to the rendered HTML. Each render gets a document of its own. With an async DOM scope installed, as the site builder does, renders can run at the same time; see `useDomScope` in `@purestack/ts-minidom`. -`onRendered(document)` runs on the rendered document before it becomes HTML. +`onRendered(document, runtimeEmbeds)` runs on the rendered document before it becomes HTML. It may be async, and the document stays that render's own while it runs. `componentRegistry` lets custom build pipelines register components by name. The components passed to `renderApp` apply on top of them, for that render only. The site generator uses this package to turn page markup into static output. + +The renderer records runtime requests without choosing or injecting scripts. In +standalone rendering, attach the runtimes supplied by `ts-page-scripts`: + +```ts +import type { PageContext } from '@purestack/ts-common' +import { appendRuntimeScripts } from '@purestack/ts-page-scripts' +import { renderApp } from '@purestack/ts-render' + +export function renderStandalone(html: string, components: object, context: PageContext) { + return renderApp(html, { + components, + context, + onRendered: (document, runtimeEmbeds) => + appendRuntimeScripts(document, runtimeEmbeds, context.pageInfo.frontmatter.embed), + }) +} +``` + +The SSG's default page scripts plugin performs this step through its existing +document hook before site-defined document hooks run. diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index c89c65fc..ac1487a4 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", @@ -41,7 +41,6 @@ "dependencies": { "@purestack/ts-common": "workspace:^", "@purestack/ts-minidom": "workspace:^", - "@purestack/ts-page-scripts": "workspace:^", "@purestack/ts-util": "workspace:^", "regor": "^1.7.5" } diff --git a/packages/ts-render/src/renderApp.ts b/packages/ts-render/src/renderApp.ts index a8e80165..ef0f2b7e 100644 --- a/packages/ts-render/src/renderApp.ts +++ b/packages/ts-render/src/renderApp.ts @@ -1,6 +1,5 @@ -import type { PageContext } from '@purestack/ts-common' +import type { PageContext, RuntimeEmbeds } from '@purestack/ts-common' import { runInDom } from '@purestack/ts-minidom' -import { buildModalScript, buildTabsScript } from '@purestack/ts-page-scripts' import { isPlainObject } from '@purestack/ts-util' import { createApp } from 'regor' import { componentRegistry } from './componentRegistry' @@ -15,7 +14,10 @@ export interface RenderAppOptions` block without syntax highlighting.
-#### Backend factories
+#### Default highlighter provider
-Use `markdown.createShiki(options)` or `markdown.createHighlightJs()` to create the default backend instance. Both factories can return an instance synchronously or asynchronously. Only the selected backend's factory runs, once when a build or dev server initializes its highlighter. If no factory is provided, PureStack uses its built-in factory. Factories are skipped when `mdx.disableHighlighter` is `true`. Two plugins providing a factory for the selected backend fail with both plugin names.
+The default `highlightPlugin()` comes from `@purestack/ts-highlight`. It selects Shiki or highlight.js from `mdx.highlighter`, initializes the selected backend once, and skips creation when `mdx.disableHighlighter` is true. Its adapters preserve language aliases, tagged TypeScript templates, and light/dark themes.
-The Shiki factory receives the native creation options containing PureStack's default languages and light/dark themes. Extend those options to add a TextMate grammar:
+To configure the native Shiki factory, replace the default plugin:
```ts
-import { definePlugin } from '@purestack/ts-ssg'
+import { highlightPlugin } from '@purestack/ts-highlight'
+import { defineConfig } from '@purestack/ts-ssg'
import { createHighlighter } from 'shiki'
import grammar from './mylang.tmLanguage.json' with { type: 'json' }
-export const languagePlugin = definePlugin({
- name: 'mylang',
- markdown: {
+export default defineConfig({
+ plugins: [highlightPlugin({
createShiki: (options) => createHighlighter({
...options,
langs: [...options.langs, grammar],
}),
- },
+ })],
})
```
-The highlight.js factory returns a configured `HLJSApi` instance. A fresh instance starts without languages, so register the languages your site needs:
+The same plugin accepts `createHighlightJs()`, which returns a configured highlight.js instance synchronously or asynchronously. Use `hljs.newInstance()` and register the grammars you need to avoid mutating the shared registry. Backend-specific factories belong to `HighlightPluginOptions`; the SSG plugin API uses the backend-independent `markdown.createHighlighter(config)` contract.
-```ts
-import { definePlugin } from '@purestack/ts-ssg'
-import hljs from 'highlight.js'
-import typescript from 'highlight.js/lib/languages/typescript'
-import myLanguage from './mylang'
+A custom `markdown.createHighlighter` receives the MDX configuration and returns an object with `codeToHtml(code, lang?)`, or `undefined` to leave source unhighlighted. An ordinary provider replaces the default without initializing it. Competing providers in the same precedence group fail before either initializes. Per-language `resolveHighlighter(lang)` callbacks still run before the selected provider.
-export const languagePlugin = definePlugin({
- name: 'mylang',
- markdown: {
- createHighlightJs: () => {
- const instance = hljs.newInstance()
- instance.registerLanguage('typescript', typescript)
- instance.registerLanguage('mylang', myLanguage)
- return instance
- },
- },
-})
-```
+### Search providers
+
+The default `pagefindPlugin()` comes from `@purestack/ts-pagefind` and supplies `search(config): SiteSearch`. The builder calls its `write({ outDir, excludeFiles, onlyFiles })` method after a full build and when its `ownsPath(pathname)` matches a lazy development request. `excludeFiles` contains the output paths excluded by `index: false`; `onlyFiles` limits lazy indexing to pages rendered in this session. The builder coordinates revisions and concurrent requests, so content edits and deletions invalidate search output.
-These factories provide native backend instances for the default HTML adapters. `resolveHighlighter(lang)` still runs first for each code block; its returned callback takes precedence over the configured backend.
+Only one search provider may be installed. Replace `purestack:pagefind` by name, or set `defaultPlugins: false` and supply your own provider. A provider uses decoded URLs relative to the site's base path. Pagefind continues to honor `pagefind.enabled`, exclusions, output cleanup, and its existing warning behavior. The browser search runtime remains owned by `ts-page-scripts`.
### Hooks
Every page render, in a full build or a dev server re-render, runs the page hooks:
- `onPageStart(context, file)`: before the page renders.
-- `onPageDocument(context, page)`: on the rendered document, before it becomes HTML and before its links resolve. `page` holds the `document`, `file`, `frontmatter`, and `urlPath`. The hook may await: the document, and the global `document` with it, stay this page's while other pages render.
+- `onPageDocument(context, page)`: on the rendered document, before it becomes HTML and before its links resolve. `page` holds the `document`, `file`, `frontmatter`, `urlPath`, and recorded `runtimeEmbeds`. The hook may await: the document, and the global `document` with it, stay this page's while other pages render.
- `onPageRendered(context, page)`: after it becomes HTML; changes to `page.html` are written.
- `onPageWritten(context, page)`: after the HTML file is written.
@@ -700,7 +742,7 @@ Functions:
- `resolveSiteConfig`
- `normalizeFrontmatter`, `parseFrontmatterSource`
- `buildNavigation`, `resolveNavigationConfig`, `resolvePageNavigation`
-- `createMdxHighlighter`, `DEFAULT_MDX_CODE_LANGS`, `DEFAULT_MDX_CODE_THEMES`
+- `highlightPlugin`, `pagefindPlugin`; backend helpers such as `createMdxHighlighter` are exported by `@purestack/ts-highlight`
- `defaultTemplates`, `resolvePageTemplate`
Types:
diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json
index e146fa11..74e61b29 100644
--- a/packages/ts-ssg/package.json
+++ b/packages/ts-ssg/package.json
@@ -1,6 +1,6 @@
{
"name": "@purestack/ts-ssg",
- "version": "1.1.14",
+ "version": "1.1.15",
"packageManager": "yarn@4.9.2",
"module": "./dist/ts-ssg.mjs",
"types": "./dist/ts-ssg.d.mts",
@@ -44,30 +44,32 @@
"@11ty/gray-matter": "^3.0.0",
"@purestack/ts-common": "workspace:^",
"@purestack/ts-components": "workspace:^",
+ "@purestack/ts-highlight": "workspace:^",
"@purestack/ts-html": "workspace:^",
"@purestack/ts-minidom": "workspace:^",
"@purestack/ts-page-scripts": "workspace:^",
+ "@purestack/ts-pagefind": "workspace:^",
"@purestack/ts-render": "workspace:^",
+ "@purestack/ts-seo": "workspace:^",
"@purestack/ts-style": "workspace:^",
- "@purestack/ts-svg-icons": "workspace:^",
"@purestack/ts-theme": "workspace:^",
"@purestack/ts-util": "workspace:^",
"@purestack/ts-util-node": "workspace:^",
"esbuild": "^0.28.0",
"hast-util-from-html": "^2.0.3",
"hast-util-to-html": "^9.0.5",
- "highlight.js": "^11.11.1",
"html-minifier-next": "^4.19.1",
"logpot": "^1.0.7",
"mdast-util-to-hast": "^13.2.1",
- "pagefind": "^1.4.0",
"regor": "^1.7.5",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
- "shiki": "^3.22.0",
"unified": "^11.0.5"
+ },
+ "devDependencies": {
+ "@purestack/ts-svg-icons": "workspace:^"
}
}
diff --git a/packages/ts-ssg/schema/siteConfig.schema.json b/packages/ts-ssg/schema/siteConfig.schema.json
index ced7184c..ebe727c7 100644
--- a/packages/ts-ssg/schema/siteConfig.schema.json
+++ b/packages/ts-ssg/schema/siteConfig.schema.json
@@ -4,6 +4,11 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "extensions": {
+ "type": "object",
+ "additionalProperties": true,
+ "description": "Plugin-owned configuration namespaces. Each plugin validates and resolves its settings through its config contribution."
+ },
"$schema": {
"type": "string",
"description": "JSON Schema file URL."
diff --git a/packages/ts-ssg/src/build/build-config.ts b/packages/ts-ssg/src/build/build-config.ts
index ea1ed5d9..1ba62020 100644
--- a/packages/ts-ssg/src/build/build-config.ts
+++ b/packages/ts-ssg/src/build/build-config.ts
@@ -1,5 +1,6 @@
import type { SiteConfig } from '@purestack/ts-common'
import { resolveSiteConfig } from '../config/config'
+import { resolvePluginConfigResolvers } from '../plugins/contributions'
import { resolveBuildPlugins } from '../plugins/default-plugins'
import type { PureStackPlugin } from '../plugins/plugin'
import { assertValidPlugins, withPluginSkins } from '../plugins/plugin'
@@ -15,7 +16,7 @@ export function resolveBuildSiteConfig(
): SiteConfig {
assertValidPlugins(plugins)
const config = withPluginSkins(plugins, () =>
- resolveSiteConfig(input.siteConfig),
+ resolveSiteConfig(input.siteConfig, resolvePluginConfigResolvers(plugins)),
)
if (input.publish?.enabled !== true) return config
return {
diff --git a/packages/ts-ssg/src/build/head-config.test.ts b/packages/ts-ssg/src/build/head-config.test.ts
index 36d328ff..d1fd41e4 100644
--- a/packages/ts-ssg/src/build/head-config.test.ts
+++ b/packages/ts-ssg/src/build/head-config.test.ts
@@ -1,169 +1,69 @@
import { describe, expect, it } from 'vitest'
+import { resolveSiteConfig } from '../config/config'
import { normalizeFrontmatter } from '../frontmatter/frontmatter'
import { resolveHeadConfig } from './head-config'
-describe('resolveHeadConfig', () => {
- it('generates canonical, Open Graph, and Twitter metadata from site preview defaults', () => {
- const frontmatter = normalizeFrontmatter({
- title: 'Getting Started',
- description: 'Build your first thing.',
- })
-
- const head = resolveHeadConfig(frontmatter, {
- siteTitle: 'Acme Docs',
- sitePreview: {
- title: 'Acme',
- description: 'Acme documentation.',
- image: 'assets/preview.png',
- imageAlt: 'Acme preview image',
- imageWidth: 1200,
- imageHeight: 630,
- siteName: 'Acme',
- twitterCard: 'summary_large_image',
- twitterSite: '@acme',
+describe('head configuration composition', () => {
+ it('awaits metadata defaults in order and preserves explicit page overrides', async () => {
+ const input = {
+ site: resolveSiteConfig({ siteTitle: 'Docs' }),
+ pageInfo: {
+ relPath: 'index.mdx',
+ urlPath: '/',
+ frontmatter: normalizeFrontmatter({
+ title: 'Home',
+ head: {
+ openGraph: { title: 'Explicit' },
+ canonicalUrl: 'https://explicit.example/',
+ },
+ }),
},
- basePath: '/docs',
- baseUrl: 'https://example.com',
- urlPath: '/guide/',
- })
-
- expect(head.title).toBe('Acme Docs | Getting Started')
- expect(head.description).toBe('Build your first thing.')
- expect(head.canonicalUrl).toBe('https://example.com/docs/guide/')
- expect(head.openGraph).toEqual({
- title: 'Getting Started',
- description: 'Build your first thing.',
- url: 'https://example.com/docs/guide/',
- image: 'https://example.com/docs/assets/preview.png',
- imageAlt: 'Acme preview image',
- imageWidth: 1200,
- imageHeight: 630,
- type: 'website',
- siteName: 'Acme',
- })
- expect(head.twitter).toEqual({
- cardType: 'summary_large_image',
- site: '@acme',
- title: 'Getting Started',
- description: 'Build your first thing.',
- image: 'https://example.com/docs/assets/preview.png',
- imageAlt: 'Acme preview image',
- })
- })
-
- it('lets page preview fields override site preview defaults', () => {
- const frontmatter = normalizeFrontmatter({
- title: 'Usage',
- description: 'Site description.',
- preview: {
- title: 'Usage Preview',
- description: 'Page-specific description.',
- image: '/usage.png',
- imageAlt: 'Usage preview',
- imageWidth: 640,
- imageHeight: 320,
- type: 'article',
- twitterCreator: '@writer',
+ }
+ const calls: string[] = []
+ const head = await resolveHeadConfig(input, [
+ async () => {
+ calls.push('first')
+ return {
+ openGraph: { title: 'Generated', image: '/preview.png' },
+ canonicalUrl: 'https://generated.example/',
+ }
},
- })
-
- const head = resolveHeadConfig(frontmatter, {
- siteTitle: 'Acme Docs',
- sitePreview: {
- title: 'Acme',
- description: 'Acme documentation.',
- image: '/site.png',
- imageAlt: 'Site preview',
- imageWidth: 1200,
- imageHeight: 630,
- twitterCreator: '@site',
+ () => {
+ calls.push('second')
+ return { twitter: { title: 'Social' } }
},
- baseUrl: 'https://example.com',
- urlPath: '/usage/',
- })
-
- expect(head.openGraph).toMatchObject({
- title: 'Usage Preview',
- description: 'Page-specific description.',
- image: 'https://example.com/usage.png',
- imageAlt: 'Usage preview',
- imageWidth: 640,
- imageHeight: 320,
- type: 'article',
- })
- expect(head.twitter).toMatchObject({
- creator: '@writer',
- title: 'Usage Preview',
- description: 'Page-specific description.',
- image: 'https://example.com/usage.png',
- imageAlt: 'Usage preview',
- })
+ ])
+ expect(calls).toEqual(['first', 'second'])
+ expect(head.title).toBe('Docs | Home')
+ expect(head.openGraph).toEqual({ title: 'Explicit', image: '/preview.png' })
+ expect(head.canonicalUrl).toBe('https://explicit.example/')
+ expect(head.twitter?.title).toBe('Social')
})
- it('does not reuse site image dimensions when a page overrides the preview image', () => {
- const frontmatter = normalizeFrontmatter({
- title: 'Usage',
- preview: {
- image: '/page.png',
+ it('keeps noindex enforcement and basic metadata without a metadata plugin', async () => {
+ const head = await resolveHeadConfig({
+ site: resolveSiteConfig({
+ siteTitle: 'Docs',
+ preview: { image: '/preview.png' },
+ }),
+ pageInfo: {
+ relPath: 'index.mdx',
+ urlPath: '/',
+ frontmatter: normalizeFrontmatter({
+ title: 'Home',
+ description: 'Description',
+ index: false,
+ head: { robots: 'index,follow' },
+ }),
},
})
-
- const head = resolveHeadConfig(frontmatter, {
- siteTitle: 'Acme Docs',
- sitePreview: {
- image: '/site.png',
- imageWidth: 1200,
- imageHeight: 630,
- },
- baseUrl: 'https://example.com',
- urlPath: '/usage/',
+ expect(head).toMatchObject({
+ title: 'Docs | Home',
+ description: 'Description',
+ robots: 'noindex',
})
-
- expect(head.openGraph?.image).toBe('https://example.com/page.png')
- expect(head.openGraph?.imageWidth).toBeUndefined()
- expect(head.openGraph?.imageHeight).toBeUndefined()
- })
-
- it('keeps absolute preview image URLs untouched', () => {
- const frontmatter = normalizeFrontmatter({
- title: 'Home',
- preview: {
- image: 'https://cdn.example.com/social/home.png',
- },
- })
-
- const head = resolveHeadConfig(frontmatter, {
- siteTitle: 'Acme Docs',
- basePath: '/docs',
- baseUrl: 'https://example.com',
- urlPath: '/',
- })
-
- expect(head.openGraph?.image).toBe(
- 'https://cdn.example.com/social/home.png',
- )
- expect(head.twitter?.image).toBe('https://cdn.example.com/social/home.png')
- })
-
- it('allows explicit head frontmatter to override generated preview metadata', () => {
- const frontmatter = normalizeFrontmatter({
- title: 'Generated Title',
- head: {
- canonicalUrl: 'https://canonical.example.com/page',
- openGraph: {
- title: 'Explicit OG Title',
- },
- },
- })
-
- const head = resolveHeadConfig(frontmatter, {
- siteTitle: 'Acme Docs',
- baseUrl: 'https://example.com',
- urlPath: '/',
- })
-
- expect(head.canonicalUrl).toBe('https://canonical.example.com/page')
- expect(head.openGraph?.title).toBe('Explicit OG Title')
- expect(head.openGraph?.url).toBe('https://example.com/')
+ expect(head.openGraph).toBeUndefined()
+ expect(head.twitter).toBeUndefined()
+ expect(head.canonicalUrl).toBeUndefined()
})
})
diff --git a/packages/ts-ssg/src/build/head-config.ts b/packages/ts-ssg/src/build/head-config.ts
index 1b3038c2..c4b6e0c5 100644
--- a/packages/ts-ssg/src/build/head-config.ts
+++ b/packages/ts-ssg/src/build/head-config.ts
@@ -1,216 +1,35 @@
import type {
- PageFrontmatter,
- PageTranslationInfo,
- PreviewConfig,
+ PageHeadConfigInput,
+ PageHeadConfigTemplate,
} from '@purestack/ts-common'
import type { BasicHeadConfig } from '@purestack/ts-html'
-import { merge, withBasePath } from '@purestack/ts-util'
-
-export interface HeadConfigOptions {
- siteTitle?: string
- sitePreview?: PreviewConfig
- basePath?: string
- baseUrl?: string
- urlPath?: string
- translations?: PageTranslationInfo[]
-}
-
-export function resolveHeadConfig(
- frontmatter: PageFrontmatter,
- options: HeadConfigOptions = {},
-) {
+import { isPlainObject, merge } from '@purestack/ts-util'
+
+/** Composes metadata defaults, then applies explicit page overrides. */
+export async function resolveHeadConfig(
+ input: PageHeadConfigInput,
+ templates: readonly PageHeadConfigTemplate[] = [],
+): Promise {
+ const {
+ site,
+ pageInfo: { frontmatter },
+ } = input
const title = frontmatter.title
- const description = frontmatter.description
- const head = frontmatter.head
- const siteTitle = options.siteTitle
- const preview = resolvePreviewConfig(frontmatter, options)
const resolvedTitle =
typeof title === 'string'
- ? siteTitle
- ? `${siteTitle} | ${title}`
+ ? site.siteTitle
+ ? `${site.siteTitle} | ${title}`
: title
- : typeof siteTitle === 'string'
- ? siteTitle
- : undefined
-
- const base: BasicHeadConfig = {
+ : site.siteTitle
+ let config: BasicHeadConfig = {
...(typeof resolvedTitle === 'string' ? { title: resolvedTitle } : {}),
- ...(typeof description === 'string' ? { description } : {}),
- ...preview,
- }
-
- const resolved = isPlainObject(head) ? merge(base, head) : base
- if (frontmatter.index === false) resolved.robots = 'noindex'
- return resolved
-}
-
-function resolvePreviewConfig(
- frontmatter: PageFrontmatter,
- options: HeadConfigOptions,
-): BasicHeadConfig {
- const pagePreview = frontmatter.preview
- const sitePreview = options.sitePreview
- const previewTitle = pickString(
- pagePreview?.title,
- frontmatter.title,
- sitePreview?.title,
- options.siteTitle,
- )
- const previewDescription = pickString(
- pagePreview?.description,
- frontmatter.description,
- sitePreview?.description,
- )
- const previewImage = resolvePreviewImage(pagePreview, sitePreview, options)
- const imageAlt = pickString(pagePreview?.imageAlt, sitePreview?.imageAlt)
- const pageUrl = resolvePageUrl(options)
- const siteName = pickString(sitePreview?.siteName, options.siteTitle)
- const openGraph = pruneObject({
- title: previewTitle,
- description: previewDescription,
- url: pageUrl,
- image: previewImage?.url,
- imageAlt,
- imageWidth: previewImage?.width,
- imageHeight: previewImage?.height,
- type: pickString(pagePreview?.type, sitePreview?.type, 'website'),
- siteName,
- locale: pickString(pagePreview?.locale, sitePreview?.locale),
- })
- const twitter = pruneObject({
- cardType: pickString(
- pagePreview?.twitterCard,
- sitePreview?.twitterCard,
- previewImage ? 'summary_large_image' : undefined,
- ),
- site: pickString(pagePreview?.twitterSite, sitePreview?.twitterSite),
- creator: pickString(
- pagePreview?.twitterCreator,
- sitePreview?.twitterCreator,
- ),
- title: previewTitle,
- description: previewDescription,
- image: previewImage?.url,
- imageAlt,
- })
-
- return {
- ...(pageUrl ? { canonicalUrl: pageUrl } : {}),
- ...resolveAlternateLinks(options),
- ...(Object.keys(openGraph).length > 0 ? { openGraph } : {}),
- ...(Object.keys(twitter).length > 0 ? { twitter } : {}),
+ ...(typeof frontmatter.description === 'string'
+ ? { description: frontmatter.description }
+ : {}),
}
-}
-
-function resolveAlternateLinks(
- options: HeadConfigOptions,
-): Pick {
- const translations = options.translations ?? []
- const alternates = translations
- .map((entry) => ({
- hrefLang: entry.locale,
- href: resolveAbsoluteUrl(
- withBasePath(options.basePath ?? '', entry.urlPath),
- options.baseUrl,
- ),
- }))
- .filter((entry): entry is { hrefLang: string; href: string } =>
- Boolean(entry.href),
- )
- const uniqueHrefs = new Set(alternates.map((entry) => entry.href))
- return uniqueHrefs.size > 1 ? { alternates } : {}
-}
-
-type ResolvedPreviewImage = {
- url: string
- width?: number
- height?: number
-}
-
-function resolvePreviewImage(
- pagePreview: PreviewConfig | undefined,
- sitePreview: PreviewConfig | undefined,
- options: HeadConfigOptions,
-): ResolvedPreviewImage | undefined {
- if (pagePreview?.image) {
- const url = resolvePreviewImageUrl(pagePreview.image, options)
- return url
- ? {
- url,
- width: pagePreview.imageWidth,
- height: pagePreview.imageHeight,
- }
- : undefined
- }
- if (!sitePreview?.image) return undefined
- const url = resolvePreviewImageUrl(sitePreview.image, options)
- return url
- ? {
- url,
- width: sitePreview.imageWidth,
- height: sitePreview.imageHeight,
- }
- : undefined
-}
-
-function resolvePreviewImageUrl(
- image: string,
- options: HeadConfigOptions,
-): string | undefined {
- if (isAbsoluteUrl(image)) return image
- const sitePath = image.startsWith('/') ? image : `/${image}`
- const publicPath = withBasePath(options.basePath ?? '', sitePath)
- return resolveAbsoluteUrl(publicPath, options.baseUrl) ?? publicPath
-}
-
-function resolvePageUrl(options: HeadConfigOptions): string | undefined {
- if (!options.urlPath) return undefined
- const publicPath = withBasePath(options.basePath ?? '', options.urlPath)
- return resolveAbsoluteUrl(publicPath, options.baseUrl)
-}
-
-function resolveAbsoluteUrl(
- publicPath: string,
- baseUrl: string | undefined,
-): string | undefined {
- if (isAbsoluteUrl(publicPath)) return publicPath
- const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
- if (!normalizedBaseUrl) return undefined
- try {
- return new URL(publicPath, `${normalizedBaseUrl}/`).toString()
- } catch {
- return undefined
- }
-}
-
-function normalizeBaseUrl(value: string | undefined): string | undefined {
- if (typeof value !== 'string') return undefined
- const trimmed = value.trim().replace(/\/+$/, '')
- return trimmed.length > 0 ? trimmed : undefined
-}
-
-function pickString(...values: Array): string | undefined {
- for (const value of values) {
- if (typeof value === 'string' && value.length > 0) return value
- }
- return undefined
-}
-
-function pruneObject>(value: T): T {
- return Object.fromEntries(
- Object.entries(value).filter(([, entry]) => entry !== undefined),
- ) as T
-}
-
-function isAbsoluteUrl(value: string): boolean {
- try {
- const url = new URL(value)
- return url.protocol === 'http:' || url.protocol === 'https:'
- } catch {
- return false
- }
-}
-
-function isPlainObject(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
+ for (const renderConfig of templates)
+ config = merge(config, await renderConfig(input))
+ if (isPlainObject(frontmatter.head)) config = merge(config, frontmatter.head)
+ if (frontmatter.index === false) config.robots = 'noindex'
+ return config
}
diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts
index 494bc60b..da543bfb 100644
--- a/packages/ts-ssg/src/build/incremental/index.ts
+++ b/packages/ts-ssg/src/build/incremental/index.ts
@@ -1,6 +1,13 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import type { SiteConfig, WriteStylesResult } from '@purestack/ts-common'
+import {
+ createStyleContext,
+ runWithStyleContext,
+ type StyleContext,
+ useStyleScope,
+} from '@purestack/ts-style'
import { toOutputAssetRelPath, toPosixPath } from '@purestack/ts-util'
import { getLogger, type Logger } from 'logpot'
import {
@@ -33,7 +40,6 @@ import { resolveRouteInfo } from '../../routing/route'
import { copyStaticAsset, copyStaticAssets } from '../assets'
import { resolveBuildSiteConfig } from '../build-config'
import { ContentRouteIndex } from '../content-urls'
-import { writeGeneratedFavicon } from '../favicon'
import { prepareOutDir } from '../io'
import {
type BuildManifest,
@@ -44,7 +50,6 @@ import {
writeManifest,
} from '../manifest'
import { type BuildContext, resolveHeaderFooterHtml } from '../page'
-import { buildPagefindIndex } from '../pagefind'
import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key'
import type { BuildHooks, BuildInput, BuildResult } from '../site'
import { writeSitemap } from '../sitemap'
@@ -64,14 +69,20 @@ import type { IncrementalBuilder, IncrementalBuildResult } from './types'
export type { IncrementalBuilder, IncrementalBuildResult } from './types'
+useStyleScope(new AsyncLocalStorage())
+
export async function createIncrementalBuilder(
input: BuildInput = {},
): Promise {
- const runtime = await createIncrementalRuntime(input)
- return runtime.toBuilder()
+ const styleContext = createStyleContext()
+ return runWithStyleContext(styleContext, async () => {
+ const runtime = await createIncrementalRuntime(input, styleContext)
+ return runtime.toBuilder()
+ })
}
interface IncrementalRuntimeOptions {
+ styleContext: StyleContext
config: SiteConfig
hooks: BuildHooks
plugins: readonly PureStackPlugin[]
@@ -86,6 +97,7 @@ interface IncrementalRuntimeOptions {
async function createIncrementalRuntime(
input: BuildInput,
+ styleContext: StyleContext,
): Promise {
const buildOptions = input.options ?? {}
const publishOptions = input.publish ?? {}
@@ -125,6 +137,7 @@ async function createIncrementalRuntime(
}
return new IncrementalRuntime({
+ styleContext,
config,
hooks,
plugins,
@@ -217,15 +230,19 @@ class IncrementalRuntime {
}
toBuilder(): IncrementalBuilder {
+ const bind =
+ (run: (...args: A) => R) =>
+ (...args: A): R =>
+ runWithStyleContext(this.options.styleContext, () => run(...args))
return {
- prepareForRequests: this.prepareForRequests,
- buildAll: this.buildAll,
- applyChange: this.applyChange,
- applyChanges: this.applyChanges,
- renderIfDirtyByOutPath: this.renderIfDirtyByOutPath,
- renderByUrlPath: this.renderByUrlPath,
- preparePageAssets: this.preparePageAssets,
- prepareAssetByUrlPath: this.prepareAssetByUrlPath,
+ prepareForRequests: bind(this.prepareForRequests),
+ buildAll: bind(this.buildAll),
+ applyChange: bind(this.applyChange),
+ applyChanges: bind(this.applyChanges),
+ renderIfDirtyByOutPath: bind(this.renderIfDirtyByOutPath),
+ renderByUrlPath: bind(this.renderByUrlPath),
+ preparePageAssets: bind(this.preparePageAssets),
+ prepareAssetByUrlPath: bind(this.prepareAssetByUrlPath),
}
}
@@ -267,7 +284,6 @@ class IncrementalRuntime {
for (const key of Object.keys(this.manifest.assets))
delete this.manifest.assets[key]
this.scriptCacheKeys.clear()
- await writeGeneratedFavicon(this.config)
await this.discoverAssets()
const files = await this.prepareContent(
[...this.staticAssets.values()].map((file) => file.relPath),
@@ -394,7 +410,6 @@ class IncrementalRuntime {
): Promise {
await initializePluginContext(this.context, this.options.plugins)
await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir })
- await writeGeneratedFavicon(this.config)
await this.discoverAssets()
const assetFiles = [...this.staticAssets.values()]
const contentFiles = await this.prepareContent(
@@ -501,11 +516,10 @@ class IncrementalRuntime {
pages,
})
- await buildPagefindIndex(
- this.config.outDir,
- this.config.pagefind,
- this.contentState.unindexedOutPaths(),
- )
+ await this.context.search?.write({
+ outDir: this.config.outDir,
+ excludeFiles: this.contentState.unindexedOutPaths(),
+ })
this.searchRevision = this.pageAssetRevision
this.manifest = await buildManifest(
@@ -679,6 +693,7 @@ class IncrementalRuntime {
signature: styles.signature,
outputs: styles.outputs,
}
+ await this.persistManifest()
}
this.preparedAssetRevision = revision
})
@@ -693,16 +708,17 @@ class IncrementalRuntime {
} catch {
return false
}
- if (pathname.startsWith('/pagefind/')) {
+ if (this.context.search?.ownsPath(pathname)) {
await this.queueAssetWork(async () => {
if (this.searchRevision === this.pageAssetRevision) return
const revision = this.pageAssetRevision
- await buildPagefindIndex(
- this.config.outDir,
- this.config.pagefind,
- this.contentState.unindexedOutPaths(),
- Object.values(this.manifest.content).map((entry) => entry.outPath),
- )
+ await this.context.search?.write({
+ outDir: this.config.outDir,
+ excludeFiles: this.contentState.unindexedOutPaths(),
+ onlyFiles: Object.values(this.manifest.content).map(
+ (entry) => entry.outPath,
+ ),
+ })
this.searchRevision = revision
})
return true
diff --git a/packages/ts-ssg/src/build/incremental/support.ts b/packages/ts-ssg/src/build/incremental/support.ts
index d4dba5c3..af6dd050 100644
--- a/packages/ts-ssg/src/build/incremental/support.ts
+++ b/packages/ts-ssg/src/build/incremental/support.ts
@@ -14,18 +14,11 @@ import {
resolvePlainContentFile,
} from '../../i18n/content'
import type { MdxRenderOptions } from '../../mdx/compile'
-import {
- createMdxHighlighter,
- DEFAULT_MDX_CODE_LANGS,
- DEFAULT_MDX_CODE_THEMES,
- type MdxCodeHighlighter,
-} from '../../mdx/highlight'
-import { createHljsHighlighter } from '../../mdx/highlightjs'
import { generatePluginPages } from '../../plugins/generated-pages'
import {
+ createPluginHighlighter,
type PureStackPlugin,
resolvePluginHighlighter,
- resolvePluginHighlighterFactory,
} from '../../plugins/plugin'
import {
assertUniqueContentRoutes,
@@ -136,7 +129,7 @@ export async function resolveMdxBuildOptions(
): Promise {
const highlighter = resolvePluginHighlighter(
plugins,
- await resolveHighlighter(mdx, plugins),
+ await createPluginHighlighter(plugins, mdx ?? {}),
)
return {
highlighter,
@@ -144,22 +137,6 @@ export async function resolveMdxBuildOptions(
}
}
-async function resolveHighlighter(
- mdx: Partial | undefined,
- plugins: readonly PureStackPlugin[],
-): Promise {
- if (mdx?.disableHighlighter) return undefined
- if (mdx?.highlighter === 'highlightjs') {
- const create = resolvePluginHighlighterFactory(plugins, 'createHighlightJs')
- return createHljsHighlighter(await create?.())
- }
- return await createMdxHighlighter(
- DEFAULT_MDX_CODE_THEMES,
- DEFAULT_MDX_CODE_LANGS,
- resolvePluginHighlighterFactory(plugins, 'createShiki'),
- )
-}
-
/**
* Discovers the site's pages, files and plugin-generated ones alike, and
* rejects two pages sharing one URL.
diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts
index 6e5c7ead..fac3a702 100644
--- a/packages/ts-ssg/src/build/page.ts
+++ b/packages/ts-ssg/src/build/page.ts
@@ -1,13 +1,16 @@
import path from 'node:path'
import type {
PageFrontmatter,
+ PageHeadConfigTemplate,
PageHeadTemplate,
PageInfo,
PageNavigation,
PageOutlineItem,
PageTemplateMap,
PageTranslationInfo,
+ RuntimeEmbeds,
SiteConfig,
+ SiteSearch,
SiteStyles,
} from '@purestack/ts-common'
import type { BasicHeadConfig } from '@purestack/ts-html'
@@ -52,6 +55,8 @@ export interface BuildContext {
styleCacheKey?: string
/** Style output owned by the site's stylesheet provider. */
styles?: SiteStyles
+ /** Search output owned by the site's search provider. */
+ search?: SiteSearch
config: SiteConfig
contentRoutes: ContentRouteIndex
headerHtmlByDir?: Map
@@ -62,6 +67,7 @@ export interface BuildContext {
components?: Record
templates?: PageTemplateMap
headTemplates?: readonly PageHeadTemplate[]
+ headConfigTemplates?: readonly PageHeadConfigTemplate[]
navigation?: NavigationTree
translationsByKey?: ContentTranslationsByKey
mdx?: MdxRenderOptions
@@ -75,6 +81,7 @@ export interface PageDocument {
frontmatter: PageFrontmatter
urlPath: string
outline?: PageOutlineItem[]
+ runtimeEmbeds: RuntimeEmbeds
}
export interface PageRenderResult {
@@ -161,14 +168,10 @@ export async function renderPageFromFile(
frontmatter,
context.translationsByKey,
)
- const headConfig = resolveHeadConfig(frontmatter, {
- siteTitle: context.config.siteTitle,
- sitePreview: context.config.preview,
- basePath: context.config.basePath,
- baseUrl: context.config.sitemap.baseUrl,
- urlPath,
- translations: pageInfo.translations,
- })
+ const headConfig = await resolveHeadConfig(
+ { site: context.config, pageInfo },
+ context.headConfigTemplates,
+ )
const template = frontmatter.template
const scriptEntrypoints = new Set()
const htmlShell = await renderPageShell({
@@ -185,13 +188,14 @@ export async function renderPageFromFile(
file,
htmlShell,
{ pageInfo, navigation, outline: compiled.outline, scriptEntrypoints },
- (document) =>
+ (document, runtimeEmbeds) =>
hooks.onPageDocument?.(context, {
document,
file,
frontmatter,
urlPath,
outline: compiled.outline,
+ runtimeEmbeds,
}),
)
const renderTimeMs =
@@ -483,14 +487,17 @@ function renderPageApp(
file: ResolvedContentFile,
htmlShell: string,
appContext: RenderAppContextInput,
- onDocument: (document: Document) => void | Promise,
+ onDocument: (
+ document: Document,
+ runtimeEmbeds: RuntimeEmbeds,
+ ) => void | Promise,
) {
const { scriptEntrypoints, ...baseContext } = appContext
return renderApp(htmlShell, {
components: context.components,
inheritComponents: false,
- onRendered: async (document) => {
- await onDocument(document)
+ onRendered: async (document, runtimeEmbeds) => {
+ await onDocument(document, runtimeEmbeds)
resolvePageUrls(document, {
sourceRelPath: file.relPath,
contentRoutes: context.contentRoutes,
diff --git a/packages/ts-ssg/src/build/site.ts b/packages/ts-ssg/src/build/site.ts
index abec7cc4..91890654 100644
--- a/packages/ts-ssg/src/build/site.ts
+++ b/packages/ts-ssg/src/build/site.ts
@@ -64,7 +64,7 @@ export interface BuildHooks {
export interface BuildOptions {
cleanOutDir?: boolean
writeErrorPages?: boolean
- /** Include the standard component, theme, and browser-runtime plugins. */
+ /** Include the standard component, theme, browser-runtime, highlighting, and search plugins. */
defaultPlugins?: boolean
/** Extensions to apply, in order. See {@link PureStackPlugin}. */
plugins?: PureStackPlugin[]
diff --git a/packages/ts-ssg/src/config/config.test.ts b/packages/ts-ssg/src/config/config.test.ts
index e809a6a6..672752f1 100644
--- a/packages/ts-ssg/src/config/config.test.ts
+++ b/packages/ts-ssg/src/config/config.test.ts
@@ -651,7 +651,7 @@ describe('resolveSiteConfig sitemap', () => {
expect(config.analytics.ga4.measurementId).toBe('G-TEST1234')
})
- it('auto-registers ga4 as a consent service when consent is enabled', () => {
+ it('leaves automatic ga4 consent registration to the analytics plugin', () => {
const config = resolveSiteConfig({
rootDir: process.cwd(),
consent: {
@@ -666,10 +666,7 @@ describe('resolveSiteConfig sitemap', () => {
const ga4Service = config.consent.services.find(
(entry) => entry.id === 'ga4',
)
- expect(ga4Service).toBeDefined()
- expect(ga4Service?.category).toBe('analytics')
- expect(ga4Service?.ga4MeasurementId).toBe('G-TEST1234')
- expect(ga4Service?.scripts).toHaveLength(2)
+ expect(ga4Service).toBeUndefined()
})
it('throws when ga4 is enabled without measurement id', () => {
diff --git a/packages/ts-ssg/src/config/config.ts b/packages/ts-ssg/src/config/config.ts
index dcbf5e23..1ecaeb16 100644
--- a/packages/ts-ssg/src/config/config.ts
+++ b/packages/ts-ssg/src/config/config.ts
@@ -17,6 +17,7 @@ import type {
RobotsConfig,
SiteConfig,
SiteConfigInput,
+ SiteConfigResolver,
SiteHtmlConfig,
SiteLogoConfig,
SiteMdxConfig,
@@ -32,7 +33,9 @@ import {
} from '@purestack/ts-style'
import type { DeepPartial } from '@purestack/ts-util'
import {
+ clonePlainData,
isPlainObject,
+ merge,
normalizeBasePath,
urlNormalizer,
} from '@purestack/ts-util'
@@ -68,7 +71,10 @@ const DEFAULT_CONSENT_CATEGORIES: ConsentCategory[] = [
},
]
-export function resolveSiteConfig(input: SiteConfigInput = {}): SiteConfig {
+export function resolveSiteConfig(
+ input: SiteConfigInput = {},
+ resolvers: readonly SiteConfigResolver[] = [],
+): SiteConfig {
const rootDir = input.rootDir ?? DEFAULT_ROOT
const contentDir = input.contentDir ?? path.join(rootDir, 'sample-content')
const fileConfig = loadSiteConfigFile(contentDir)
@@ -103,16 +109,19 @@ export function resolveSiteConfig(input: SiteConfigInput = {}): SiteConfig {
input.analytics,
fileConfig.analytics,
)
- const consent = resolveConsentConfig(
- input.consent,
- fileConfig.consent,
- analytics.ga4,
- )
+ const consent = resolveConsentConfig(input.consent, fileConfig.consent)
const pagefind = resolvePagefindConfig(input.pagefind, fileConfig.pagefind)
const preview = resolvePreviewConfig(input.preview, fileConfig.preview)
const i18n = resolveI18nConfig(input.i18n, fileConfig.i18n)
const mdx = resolveMdxConfig(input.mdx, fileConfig.mdx)
- return {
+ for (const extensions of [fileConfig.extensions, input.extensions]) {
+ if (extensions !== undefined && !isPlainObject(extensions))
+ throw new Error('extensions must be an object of plugin settings.')
+ }
+ const config: SiteConfig = {
+ extensions: clonePlainData(
+ merge(fileConfig.extensions ?? {}, input.extensions),
+ ),
rootDir,
contentDir,
outDir,
@@ -145,6 +154,22 @@ export function resolveSiteConfig(input: SiteConfigInput = {}): SiteConfig {
i18n,
mdx,
}
+ return applyConfigResolvers(config, resolvers, input, fileConfig)
+}
+
+export function applyConfigResolvers(
+ config: SiteConfig,
+ resolvers: readonly SiteConfigResolver[],
+ input: SiteConfigInput = {},
+ file: SiteConfigInput = {},
+): SiteConfig {
+ for (const resolve of resolvers) {
+ const contribution = resolve({ config, input, file })
+ if (!isPlainObject(contribution))
+ throw new Error('A configuration contribution must return an object.')
+ config = merge(config, clonePlainData(contribution))
+ }
+ return config
}
function resolveScriptsConfig(
@@ -408,21 +433,17 @@ function resolveSitemapConfig(
function resolveConsentConfig(
input?: DeepPartial,
file?: DeepPartial,
- ga4?: Ga4Config,
): ConsentConfig {
const enabled = input?.enabled ?? file?.enabled ?? false
const categories = resolveConsentCategories(
input?.categories,
file?.categories,
)
- let services = resolveConsentServices(
+ const services = resolveConsentServices(
input?.services,
file?.services,
categories,
)
- if (enabled) {
- services = appendGa4ConsentService(services, categories, ga4)
- }
return {
enabled,
storageKey: resolveString(
@@ -733,52 +754,6 @@ function resolveConsentServices(
return services
}
-function appendGa4ConsentService(
- services: ConsentService[],
- categories: ConsentCategory[],
- ga4?: Ga4Config,
-) {
- if (!ga4?.enabled || !ga4.measurementId) return services
- const hasService = services.some((entry) => entry.id === ga4.serviceId)
- if (hasService) return services
- const hasCategory = categories.some(
- (entry) => entry.id === ga4.consentCategory,
- )
- if (!hasCategory) {
- throw new Error(
- `analytics.ga4.consentCategory "${ga4.consentCategory}" does not exist in consent.categories.`,
- )
- }
- return [
- ...services,
- {
- id: ga4.serviceId,
- category: ga4.consentCategory,
- label: 'Google Analytics 4',
- ga4MeasurementId: ga4.measurementId,
- scripts: buildGa4ConsentScripts(ga4.measurementId),
- },
- ]
-}
-
-function buildGa4ConsentScripts(measurementId: string): ConsentScript[] {
- const measurementIdLiteral = JSON.stringify(measurementId)
- return [
- {
- src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`,
- async: true,
- },
- {
- content: [
- 'window.dataLayer = window.dataLayer || [];',
- 'function gtag(){dataLayer.push(arguments);}',
- "gtag('js', new Date());",
- `gtag('config', ${measurementIdLiteral});`,
- ].join(' '),
- },
- ]
-}
-
function resolveConsentServiceScripts(
scripts: Array> | undefined,
serviceId: string,
diff --git a/packages/ts-ssg/src/config/ga4-consent.test.ts b/packages/ts-ssg/src/config/ga4-consent.test.ts
index a3159eda..aef85ed2 100644
--- a/packages/ts-ssg/src/config/ga4-consent.test.ts
+++ b/packages/ts-ssg/src/config/ga4-consent.test.ts
@@ -1,5 +1,6 @@
import fs from 'node:fs'
import { createDom, ensureDomGlobals } from '@purestack/ts-minidom'
+import { analyticsPlugin } from '@purestack/ts-page-scripts'
import { transformSync } from 'esbuild'
import { describe, expect, it } from 'vitest'
import { resolveSiteConfig } from './config'
@@ -21,24 +22,28 @@ const site = JSON.parse(
'utf8',
),
)
-const config = resolveSiteConfig({
- consent: {
- enabled: true,
- policyVersion: '2026-10-07',
- categories: [
- { id: 'necessary', required: true },
- { id: 'analytics', required: false },
- ],
- },
- analytics: {
- ga4: {
+const resolved = resolveSiteConfig(
+ {
+ consent: {
enabled: true,
- measurementId: 'G-TEST1234',
- consentCategory: 'analytics',
- serviceId: 'ga4',
+ policyVersion: '2026-10-07',
+ categories: [
+ { id: 'necessary', required: true },
+ { id: 'analytics', required: false },
+ ],
+ },
+ analytics: {
+ ga4: {
+ enabled: true,
+ measurementId: 'G-TEST1234',
+ consentCategory: 'analytics',
+ serviceId: 'ga4',
+ },
},
},
-}).consent
+ [analyticsPlugin().config.finalize],
+)
+const config = resolved.consent
const disableKey = 'ga-disable-G-TEST1234'
function withRuntime(
diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts
index 9a5a6b32..5b8f83c4 100644
--- a/packages/ts-ssg/src/config/head.ts
+++ b/packages/ts-ssg/src/config/head.ts
@@ -24,7 +24,7 @@ export function getHead(config?: BasicHeadConfig) {
return createHead(getHeadConfig(merge(DEFAULTS, config))).push(
h('meta').attr({
name: 'generator',
- content: 'PureStack v1.1.14',
+ content: 'PureStack v1.1.15',
}),
)
}
diff --git a/packages/ts-ssg/src/config/project-config.ts b/packages/ts-ssg/src/config/project-config.ts
index cff8f88c..6438782e 100644
--- a/packages/ts-ssg/src/config/project-config.ts
+++ b/packages/ts-ssg/src/config/project-config.ts
@@ -15,7 +15,7 @@ const SKIP_PACKAGE_RESOLUTION = Symbol('skip package resolution')
/** The code a site adds to PureStack, loaded from `purestack.config.ts`. */
export interface PureStackConfig {
plugins?: PureStackPlugin[]
- /** Include the standard component, theme, and browser-runtime plugins. */
+ /** Include the standard component, theme, browser-runtime, highlighting, and search plugins. */
defaultPlugins?: boolean
}
diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts
index 994c2c8a..8a624a1f 100644
--- a/packages/ts-ssg/src/index.ts
+++ b/packages/ts-ssg/src/index.ts
@@ -1,13 +1,21 @@
export type {
+ MdxCodeHighlighter,
PageContext,
PageFrontmatter,
+ PageHeadConfigInput,
+ PageHeadConfigTemplate,
PageHeadTemplate,
PageInfo,
PageTemplate,
PageTemplateInput,
PageTemplateMap,
SiteConfig,
+ SiteConfigContributions,
SiteConfigInput,
+ SiteConfigResolution,
+ SiteConfigResolver,
+ SiteExtensions,
+ SiteSearch,
SiteStyles,
WriteStylesResult,
} from '@purestack/ts-common'
@@ -40,15 +48,6 @@ export {
parseFrontmatterSource,
} from './frontmatter/frontmatter'
export type { ResolvedContentFile } from './i18n/content'
-export {
- createMdxHighlighter,
- DEFAULT_MDX_CODE_LANGS,
- DEFAULT_MDX_CODE_THEMES,
- type MdxCodeHighlighter,
- type MdxCodeLangs,
- type MdxCodeThemes,
- type MdxShikiFactory,
-} from './mdx/highlight'
export {
buildNavigation,
type NavigationTree,
@@ -56,9 +55,15 @@ export {
resolvePageNavigation,
} from './navigation/navigation'
export {
+ analyticsPlugin,
+ authStateHintPlugin,
componentsPlugin,
+ consentPlugin,
createDefaultPlugins,
+ highlightPlugin,
+ pagefindPlugin,
pageScriptsPlugin,
+ seoPlugin,
themePlugin,
} from './plugins/default-plugins'
export {
diff --git a/packages/ts-ssg/src/mdx/codeHighlighting.test.ts b/packages/ts-ssg/src/mdx/codeHighlighting.test.ts
index 7ff6b8b0..f58d52d7 100644
--- a/packages/ts-ssg/src/mdx/codeHighlighting.test.ts
+++ b/packages/ts-ssg/src/mdx/codeHighlighting.test.ts
@@ -1,5 +1,5 @@
+import { createHljsHighlighter } from '@purestack/ts-highlight'
import { describe, expect, it } from 'vitest'
-import { createHljsHighlighter } from './highlightjs'
import { compileMarkdown } from './md'
import { compileMdx } from './mdx'
diff --git a/packages/ts-ssg/src/mdx/codeHighlighting.ts b/packages/ts-ssg/src/mdx/codeHighlighting.ts
index 70c970d2..e8d031c7 100644
--- a/packages/ts-ssg/src/mdx/codeHighlighting.ts
+++ b/packages/ts-ssg/src/mdx/codeHighlighting.ts
@@ -1,10 +1,9 @@
+import type { MdxCodeHighlighter } from '@purestack/ts-common'
import type { Element, Root, Text } from 'hast'
import { fromHtml } from 'hast-util-from-html'
import { toHtml } from 'hast-util-to-html'
import type { Raw } from 'mdast-util-to-hast'
-import type { MdxCodeHighlighter } from './highlight'
-
/** Applies the configured highlighter to code blocks and inline code. */
export async function applyCodeHighlighting(
root: Root,
diff --git a/packages/ts-ssg/src/mdx/compile.ts b/packages/ts-ssg/src/mdx/compile.ts
index 4f75304f..8e317861 100644
--- a/packages/ts-ssg/src/mdx/compile.ts
+++ b/packages/ts-ssg/src/mdx/compile.ts
@@ -1,4 +1,4 @@
-import type { PageOutlineItem } from '@purestack/ts-common'
+import type { MdxCodeHighlighter, PageOutlineItem } from '@purestack/ts-common'
import { toPosixPath } from '@purestack/ts-util'
import type { Element, Root, Text } from 'hast'
import type { Root as MdastRoot } from 'mdast'
@@ -6,7 +6,6 @@ import rehypeStringify from 'rehype-stringify'
import remarkRehype from 'remark-rehype'
import { type PluggableList, unified } from 'unified'
import { applyCodeHighlighting } from './codeHighlighting'
-import type { MdxCodeHighlighter } from './highlight'
export interface MdxCompileResult {
bodyHtml: string
@@ -33,11 +32,13 @@ const DEFAULT_CONTENT_PROCESSOR = createContentProcessor()
export function createContentProcessor(
remarkPlugins: PluggableList = [],
rehypePlugins: PluggableList = [],
+ rehypePostPlugins: PluggableList = [],
) {
return unified()
.use(remarkPlugins)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypePlugins)
+ .use(rehypePostPlugins)
.freeze()
}
@@ -53,7 +54,6 @@ export async function compileAstToHtml(
if (!isHastRoot(tree)) {
throw new Error('Content compilation did not produce a HAST root node.')
}
- wrapTablesInScrollContainers(tree)
const outline = collectOutline(tree)
if (options.highlighter) {
await applyCodeHighlighting(tree, options.highlighter)
@@ -94,40 +94,6 @@ function isHastRoot(node: unknown): node is Root {
)
}
-function wrapTablesInScrollContainers(root: Root) {
- const visit = (node: Root | Element) => {
- const children = node.children ?? []
- for (let i = 0; i < children.length; i++) {
- const child = children[i]
- if (child.type !== 'element') continue
- if (child.tagName === 'table' && !isTableScrollContainer(node)) {
- const wrapper: Element = {
- type: 'element',
- tagName: 'div',
- properties: { className: ['table-scroll'] },
- children: [child],
- }
- children[i] = wrapper
- continue
- }
- visit(child)
- }
- }
- visit(root)
-}
-
-function isTableScrollContainer(node: Root | Element): node is Element {
- if (node.type !== 'element' || node.tagName !== 'div') return false
- const className = node.properties?.className
- if (typeof className === 'string') {
- return className.split(/\s+/).includes('table-scroll')
- }
- if (Array.isArray(className)) {
- return className.includes('table-scroll')
- }
- return false
-}
-
const OUTLINE_HEADING_LEVELS = new Map([
['h1', 1],
['h2', 2],
diff --git a/packages/ts-ssg/src/mdx/imports.ts b/packages/ts-ssg/src/mdx/imports.ts
index eb4abcfb..967a689d 100644
--- a/packages/ts-ssg/src/mdx/imports.ts
+++ b/packages/ts-ssg/src/mdx/imports.ts
@@ -1,5 +1,6 @@
import fs from 'node:fs/promises'
import path from 'node:path'
+import { LANGUAGE_BY_EXTENSION } from '@purestack/ts-common'
import { parseFragment } from '@purestack/ts-minidom'
import { toPosixPath } from '@purestack/ts-util'
import remarkGfm from 'remark-gfm'
@@ -7,7 +8,6 @@ import remarkParse from 'remark-parse'
import { unified } from 'unified'
import { isSharedContentFile } from '../discover/content'
import { isContentExt } from '../discover/contentExtensions'
-import { LANGUAGE_BY_EXTENSION } from './codeLanguages'
import { findMarkupTags } from './regorMarkup'
const IMPORT_TAGS = {
diff --git a/packages/ts-ssg/src/mdx/md.test.ts b/packages/ts-ssg/src/mdx/md.test.ts
index 3ee87d8c..72855d26 100644
--- a/packages/ts-ssg/src/mdx/md.test.ts
+++ b/packages/ts-ssg/src/mdx/md.test.ts
@@ -1,4 +1,5 @@
import type { PageContext } from '@purestack/ts-common'
+import { highlightPlugin } from '@purestack/ts-highlight'
import { renderApp } from '@purestack/ts-render'
import { describe, expect, it } from 'vitest'
import { resolveMdxBuildOptions } from '../build/incremental/support'
@@ -20,7 +21,7 @@ describe('compileMarkdown', () => {
})
expect(html).toContain('')
- expect(html).toContain('')
+ expect(html).not.toContain('table-scroll')
expect(html).toContain('')
expect(html).toContain('')
expect(html).toContain('Option ')
@@ -29,9 +30,12 @@ describe('compileMarkdown', () => {
it('supports highlightjs backend', async () => {
const source = '```ts\nconst x = 1\n```'
- const mdx = await resolveMdxBuildOptions({
- highlighter: 'highlightjs',
- })
+ const mdx = await resolveMdxBuildOptions(
+ {
+ highlighter: 'highlightjs',
+ },
+ [highlightPlugin()],
+ )
const html = await renderApp(
(await compileMarkdown(source, mdx)).bodyHtml,
{
diff --git a/packages/ts-ssg/src/mdx/mdx.test.ts b/packages/ts-ssg/src/mdx/mdx.test.ts
index eff6c3dc..3a318058 100644
--- a/packages/ts-ssg/src/mdx/mdx.test.ts
+++ b/packages/ts-ssg/src/mdx/mdx.test.ts
@@ -1,12 +1,14 @@
import type { PageContext } from '@purestack/ts-common'
import { defineModalComponents } from '@purestack/ts-components'
+import {
+ createHljsHighlighter,
+ createMdxHighlighter,
+} from '@purestack/ts-highlight'
import { ensureDomGlobals } from '@purestack/ts-minidom'
import { renderApp } from '@purestack/ts-render'
import { describe, expect, it } from 'vitest'
import { resolveSiteConfig } from '../config/config'
import { normalizeFrontmatter } from '../frontmatter/frontmatter'
-import { createMdxHighlighter } from './highlight'
-import { createHljsHighlighter } from './highlightjs'
import { compileMdx, compileMdxToHtml } from './mdx'
describe('createMdxHighlighter', () => {
@@ -244,7 +246,7 @@ describe('compileMdxToHtml', () => {
})
expect(html).toContain('')
- expect(html).toContain('')
+ expect(html).not.toContain('table-scroll')
expect(html).toContain('')
expect(html).toContain('')
expect(html).toContain('Bus ')
diff --git a/packages/ts-ssg/src/mdx/mdx.ts b/packages/ts-ssg/src/mdx/mdx.ts
index 611938a3..e18493c3 100644
--- a/packages/ts-ssg/src/mdx/mdx.ts
+++ b/packages/ts-ssg/src/mdx/mdx.ts
@@ -14,14 +14,7 @@ import {
restoreRegorMarkup,
} from './regorMarkup'
-export {
- DEFAULT_MDX_CODE_LANGS,
- DEFAULT_MDX_CODE_THEMES,
- type MdxCodeHighlighter,
- type MdxCodeLangs,
- type MdxCodeThemes,
- type MdxShikiFactory,
-} from './highlight'
+export type { MdxCodeHighlighter } from '@purestack/ts-common'
export type { MdxCompileResult, MdxRenderOptions }
diff --git a/packages/ts-ssg/src/mdx/regorMarkup.ts b/packages/ts-ssg/src/mdx/regorMarkup.ts
index 574fcd39..21ac1030 100644
--- a/packages/ts-ssg/src/mdx/regorMarkup.ts
+++ b/packages/ts-ssg/src/mdx/regorMarkup.ts
@@ -34,11 +34,11 @@ Internally, the interesting helpers are:
So the short version is: `regorMarkup.ts` is the compatibility layer that lets this project support Regor component syntax inside `.mdx` files while still using a mostly markdown-first pipeline.
*/
+import type { MdxCodeHighlighter } from '@purestack/ts-common'
import {
renderHighlightedCodeBlockHtml,
renderHighlightedInlineCodeHtml,
} from './codeHighlighting'
-import type { MdxCodeHighlighter } from './highlight'
type MarkdownNode = {
type?: unknown
diff --git a/packages/ts-ssg/src/plugins/config.test.ts b/packages/ts-ssg/src/plugins/config.test.ts
new file mode 100644
index 00000000..6fc41236
--- /dev/null
+++ b/packages/ts-ssg/src/plugins/config.test.ts
@@ -0,0 +1,397 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import { h } from '@purestack/ts-html'
+import { analyticsPlugin } from '@purestack/ts-page-scripts'
+import { disableLogger, getLogger } from 'logpot'
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { resolveBuildSiteConfig } from '../build/build-config'
+import { buildSite } from '../build/site'
+import { makeRepoTempDir } from '../test/repoTempDir'
+import type { PureStackPlugin } from './plugin'
+
+declare module '@purestack/ts-common' {
+ interface SiteExtensions {
+ brand?: { text: string }
+ }
+}
+
+describe('plugin configuration', () => {
+ let root: string | undefined
+ beforeAll(() => disableLogger())
+ afterAll(async () => getLogger().close())
+ afterEach(async () => {
+ if (root) await fs.rm(root, { recursive: true, force: true })
+ })
+
+ it('owns mutable extension input and contributed settings across independent resolutions', () => {
+ const input = {
+ rootDir: process.cwd(),
+ extensions: { brand: { text: 'Input' } },
+ }
+ const first = resolveBuildSiteConfig({
+ siteConfig: input,
+ options: { defaultPlugins: false },
+ })
+ const second = resolveBuildSiteConfig({
+ siteConfig: input,
+ options: { defaultPlugins: false },
+ })
+ if (!first.extensions.brand) throw new Error('Expected brand settings')
+ first.extensions.brand.text = 'Changed'
+ expect(second.extensions.brand?.text).toBe('Input')
+ expect(input.extensions.brand.text).toBe('Input')
+ const contribution = { extensions: { brand: { text: 'Contributed' } } }
+ const configured = resolveBuildSiteConfig({
+ options: {
+ defaultPlugins: false,
+ plugins: [{ name: 'brand', config: { resolve: () => contribution } }],
+ },
+ })
+ if (!configured.extensions.brand)
+ throw new Error('Expected contributed brand')
+ configured.extensions.brand.text = 'Changed'
+ expect(contribution.extensions.brand.text).toBe('Contributed')
+ })
+
+ it('resolves typed custom site configuration and derived consent services before every factory', async () => {
+ root = await makeRepoTempDir('.tmp-plugin-config-')
+ const contentDir = path.join(root, 'content')
+ await fs.mkdir(contentDir)
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home')
+ await fs.writeFile(
+ path.join(contentDir, 'siteConfig.json'),
+ JSON.stringify({
+ extensions: {
+ brand: { text: 'From file' },
+ otherPlugin: { preserved: true },
+ },
+ }),
+ )
+ const calls: string[] = []
+ const plugin: PureStackPlugin = {
+ name: 'brand',
+ config: {
+ resolve({ config, input, file }) {
+ calls.push('config')
+ expect(config.consent.services).toHaveLength(0)
+ return {
+ extensions: {
+ brand: {
+ text:
+ input.extensions?.brand?.text ??
+ file.extensions?.brand?.text ??
+ 'Default',
+ },
+ },
+ }
+ },
+ finalize({ config }) {
+ calls.push('finalize')
+ expect(config.consent.services).toHaveLength(1)
+ expect(config.extensions.brand?.text).toBe('From input')
+ return {}
+ },
+ },
+ styles(config) {
+ calls.push('styles')
+ expect(config.consent.services).toHaveLength(1)
+ expect(config.extensions.brand?.text).toBe('From input')
+ expect(config.extensions.otherPlugin).toEqual({ preserved: true })
+ return {
+ revision: 0,
+ links: () => [],
+ write: async () => ({
+ revision: 0,
+ outPath: '',
+ outputs: [],
+ signature: '',
+ }),
+ }
+ },
+ components(config) {
+ calls.push('components')
+ expect(config.consent.services).toHaveLength(1)
+ expect(config.extensions.brand?.text).toBe('From input')
+ return {}
+ },
+ templates: {
+ doc: ({ head, bodyHtml }) =>
+ h('html').push(head, h('body').raw(bodyHtml)),
+ },
+ hooks: {
+ onConfigResolved({ config, components, templates }) {
+ calls.push('hook')
+ expect(config.extensions.brand?.text).toBe('From input')
+ expect(config.consent.services).toHaveLength(1)
+ expect(components).toEqual({})
+ expect(templates?.doc).toBeTypeOf('function')
+ },
+ },
+ }
+ await buildSite({
+ siteConfig: {
+ rootDir: root,
+ contentDir,
+ outDir: path.join(root, 'out'),
+ extensions: { brand: { text: 'From input' } },
+ consent: { enabled: true },
+ analytics: { ga4: { measurementId: 'G-TEST1234' } },
+ },
+ options: { defaultPlugins: false, plugins: [analyticsPlugin(), plugin] },
+ })
+ expect(calls).toEqual([
+ 'config',
+ 'finalize',
+ 'styles',
+ 'components',
+ 'hook',
+ ])
+ })
+
+ it('composes configuration in plugin order and retains publish output overrides', () => {
+ const calls: string[] = []
+ const config = resolveBuildSiteConfig({
+ siteConfig: {
+ rootDir: process.cwd(),
+ siteTitle: 'Input',
+ publishDir: path.join(process.cwd(), '.tmp', 'publish-review'),
+ },
+ options: {
+ defaultPlugins: false,
+ plugins: [
+ {
+ name: 'first',
+ config: {
+ resolve({ config }) {
+ calls.push(config.siteTitle)
+ return { siteTitle: 'First', html: { minify: false } }
+ },
+ finalize({ config }) {
+ calls.push(config.siteTitle)
+ return { extensions: { derivedTitle: config.siteTitle } }
+ },
+ },
+ },
+ {
+ name: 'second',
+ config: {
+ resolve({ config }) {
+ calls.push(config.siteTitle)
+ return { siteTitle: 'Second' }
+ },
+ },
+ },
+ ],
+ },
+ publish: { enabled: true },
+ })
+ expect(calls).toEqual(['Input', 'First', 'Second'])
+ expect(config.extensions.derivedTitle).toBe('Second')
+ expect(config.siteTitle).toBe('Second')
+ expect(config.html.minify).toBe(true)
+ expect(config.outDir).toBe(config.publishDir)
+ })
+
+ it.each([null, [], 42, Promise.resolve({})])(
+ 'attributes invalid config results to their plugin (%j)',
+ (value) => {
+ expect(() =>
+ resolveBuildSiteConfig({
+ options: {
+ defaultPlugins: false,
+ plugins: [
+ { name: 'invalid', config: { resolve: () => value } } as never,
+ ],
+ },
+ }),
+ ).toThrow(
+ 'Plugin "invalid" failed in config.resolve: config.resolve must return a configuration object synchronously.',
+ )
+ },
+ )
+
+ it.each([null, [], 42, Promise.resolve({})])(
+ 'attributes invalid config finalizers (%j)',
+ (value) => {
+ expect(() =>
+ resolveBuildSiteConfig({
+ options: {
+ defaultPlugins: false,
+ plugins: [
+ { name: 'invalid', config: { finalize: () => value } } as never,
+ ],
+ },
+ }),
+ ).toThrow(
+ 'Plugin "invalid" failed in config.finalize: config.finalize must return a configuration object synchronously.',
+ )
+ },
+ )
+
+ it.each([
+ {
+ name: 'measurement override',
+ original: 'G-ORIGINAL',
+ initialConsent: true,
+ finalConsent: true,
+ enabled: true,
+ },
+ {
+ name: 'analytics enabled by a resolver',
+ original: undefined,
+ initialConsent: true,
+ finalConsent: true,
+ enabled: true,
+ },
+ {
+ name: 'consent enabled by a resolver',
+ original: 'G-ORIGINAL',
+ initialConsent: false,
+ finalConsent: true,
+ enabled: true,
+ },
+ {
+ name: 'analytics disabled by a resolver',
+ original: 'G-ORIGINAL',
+ initialConsent: true,
+ finalConsent: true,
+ enabled: false,
+ },
+ {
+ name: 'consent disabled by a resolver',
+ original: 'G-ORIGINAL',
+ initialConsent: true,
+ finalConsent: false,
+ enabled: true,
+ },
+ ])(
+ 'uses final analytics configuration in rendered pages: $name',
+ async ({ original, initialConsent, finalConsent, enabled }) => {
+ root = await makeRepoTempDir('.tmp-final-analytics-')
+ const contentDir = path.join(root, 'content')
+ const outDir = path.join(root, 'out')
+ await fs.mkdir(contentDir)
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home')
+ let observed = false
+ await buildSite({
+ siteConfig: {
+ rootDir: root,
+ contentDir,
+ outDir,
+ mdx: { disableHighlighter: true },
+ pagefind: { enabled: false },
+ consent: { enabled: initialConsent },
+ analytics: { ga4: { measurementId: original } },
+ },
+ options: {
+ plugins: [
+ {
+ name: 'environment',
+ config: {
+ resolve: () => ({
+ consent: { enabled: finalConsent },
+ analytics: { ga4: { enabled, measurementId: 'G-OVERRIDE' } },
+ }),
+ },
+ components(config) {
+ observed = true
+ const services = config.consent.services
+ expect(services).toHaveLength(enabled && finalConsent ? 1 : 0)
+ if (enabled && finalConsent)
+ expect(services[0].ga4MeasurementId).toBe('G-OVERRIDE')
+ return {}
+ },
+ },
+ ],
+ },
+ })
+ expect(observed).toBe(true)
+ const html = await fs.readFile(path.join(outDir, 'index.html'), 'utf8')
+ expect(html).not.toContain('G-ORIGINAL')
+ expect(html.includes('G-OVERRIDE')).toBe(enabled)
+ expect(
+ html.includes(
+ '