diff --git a/frontend/purestack.studio/guides/extending/plugins.mdx b/frontend/purestack.studio/guides/extending/plugins.mdx index 181e3582..8d717094 100644 --- a/frontend/purestack.studio/guides/extending/plugins.mdx +++ b/frontend/purestack.studio/guides/extending/plugins.mdx @@ -91,16 +91,26 @@ The badge appears on the page. Change its `tone` in `plugins/site.ts` while the ## Standard plugins -PureStack includes three plugins automatically in `build`, `serve`, and `publish`: +PureStack includes nine plugins automatically in `build`, `serve`, and `publish`: | Package | Factory | Provides | | --- | --- | --- | | `@purestack/ts-components` | `componentsPlugin()` | Standard components, icons, and component styles | -| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, and theme switching | -| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, navigation, code copying, search, and TOC runtimes | +| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, favicons, theme switching, and Markdown table presentation | +| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Menus, navigation, code copying, search, TOC, Tabs, and Modal runtimes | +| `@purestack/ts-page-scripts` | `authStateHintPlugin()` | Early signed-in state hint | +| `@purestack/ts-page-scripts` | `consentPlugin()` | Consent browser runtime | +| `@purestack/ts-page-scripts` | `analyticsPlugin()` | Direct or consent-managed GA4 integration | +| `@purestack/ts-seo` | `seoPlugin()` | Social previews, canonical URLs, and translated alternate links | +| `@purestack/ts-highlight` | `highlightPlugin()` | Shiki or highlight.js code highlighting | +| `@purestack/ts-pagefind` | `pagefindPlugin()` | Full-build and lazy development search indexing | The stylesheet provider initializes before component factories, then all config hooks run in plugin order. Names do not affect initialization. Components belong to each build, so hooks can override them through `context.components`. The theme and page runtime plugins supply head templates that run before the selected page template, preserving its ability to inspect or change the completed head. The lifecycle hook set is unchanged. +Metadata defaults compose through `headConfig({ site, pageInfo })` in plugin order, before explicit frontmatter `head` overrides and before head markup is rendered. It returns `BasicHeadConfig` or a promise. The SEO plugin uses this contribution for Open Graph, Twitter, canonical URLs, and translated alternate links; page templates receive the completed `headConfig`. Basic titles, descriptions, explicit head settings, and `index: false` enforcement remain in core. + +Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin contributes automatic GA4 consent services during configuration resolution, before any factory or lifecycle hook runs. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted. + To choose the standard integrations yourself, disable the automatic set and list the plugins you want. The factories are exported from `purestack`, `@purestack/ts-ssg`, and their individual packages: ```ts @@ -112,10 +122,59 @@ export default defineConfig({ }) ``` -This keeps the layouts and components while omitting the page runtimes. Theme switching belongs to the theme plugin; component-specific Tabs and Modal embeds still follow the page's `embed` settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need. +This keeps the layouts and components while omitting automatic social metadata, page runtimes, code highlighting, and search indexing. Theme switching, favicon generation, and Markdown table wrappers belong to the theme plugin. Tabs and Modal scripts belong to the page scripts plugin, including automatic component embeds and explicit frontmatter settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need. An explicitly listed standard plugin replaces the automatic instance by name and keeps its initialization position. You can also use `createDefaultPlugins()` to obtain the complete set. In programmatic builds, use `options.defaultPlugins` alongside `options.plugins`. +The highlighting plugin selects the configured backend. Configure native backend factories through `highlightPlugin({ createShiki, createHighlightJs })`, or supply `markdown.createHighlighter(config)` for a different engine. The generic provider returns `{ codeToHtml(code, lang?) }`. Ordinary providers override the default without constructing it; per-language `markdown.resolveHighlighter` callbacks retain precedence. + +The Pagefind plugin supplies a `search` provider. To use another engine, replace `purestack:pagefind` or disable the default set and install your own provider. The builder supplies excluded output paths and the rendered-page list for lazy requests, and manages invalidation on edits and deletions. Sitemap and robots generation remain built in. + +## Configure a plugin + +Plugins contribute configuration through `config.resolve({ config, input, file })` and `config.finalize({ config, input, file })`. Both are optional and synchronous. Each returns an object to merge into the config; do not mutate the inputs. All resolvers run first in plugin order, applying configuration overrides. Then all finalizers run in plugin order, deriving dependent settings from the completed overrides. Finalizers should derive settings rather than change resolver inputs. Both phases finish before highlighters, Markdown processors, styles, search providers, components, and templates initialize. The analytics plugin finalizes its automatic GA4 service here, so later site resolvers can enable analytics, change its ID or category, or provide a manual service. The existing `onConfigResolved` hook still runs after factories, so it can customize initialized components and templates. Publishing applies its output-directory and minification overrides after configuration composition. + +Custom site settings belong under `extensions` in `siteConfig.json`. File values merge with programmatic input, which takes precedence. The standard JSON schema allows plugin namespaces here while continuing to reject unknown top-level fields. Each plugin owns validation, defaults, and normalization for its namespace. Other namespaces are preserved. + +```json +{ "extensions": { "brand": { "label": "Acme" } } } +``` + +Use TypeScript module augmentation for typed settings: + +```ts +import { definePlugin } from 'purestack' + +declare module '@purestack/ts-common' { + interface SiteExtensions { + brand?: { label: string } + } +} + +export const brandPlugin = definePlugin({ + name: 'brand', + config: { + resolve({ config }) { + const brand = config.extensions.brand + if (brand !== undefined && + (brand === null || typeof brand !== 'object' || + typeof brand.label !== 'string')) { + throw new Error('extensions.brand requires a string label.') + } + return { extensions: { brand: { label: brand?.label ?? 'Docs' } } } + }, + }, + components(config) { + // config.extensions.brand contains the normalized settings here. + return {} + }, +}) +``` + +Raw `resolveSiteConfig(input)` resolves core settings and extension input. CLI and programmatic builds additionally compose their selected plugins' configuration contributions. Configuration contributions are synchronous and may run whenever a project configuration is resolved; keep file writes and other side effects in lifecycle hooks. + +Builder style state is isolated across asynchronous operations, including concurrent sites. Its owned theme options remain linked to `context.config.style.theme`, so in-place config-hook changes and `themes.setOptions` update page colors, CSS, and generated favicons consistently. Head, metadata, template, Markdown, provider, and lifecycle errors identify their owning plugin and preserve the original cause. Markdown wrappers preserve unified presets, duplicate-plugin option merging, and callback transforms. + ## How the config loads - **Where it lives.** The CLI looks for `purestack.config.ts` in the folder passed to `--content`. It must default-export `defineConfig({ plugins: [...] })`. @@ -204,6 +263,8 @@ Besides the prepared `head` and the page's `bodyHtml`, a template receives the p ## Transform Markdown +Ordinary `rehypePlugins` run before `rehypePostPlugins`, which apply final presentation before outline collection and code highlighting. The theme plugin uses this final stage for `div.table-scroll`, so tables introduced by custom transforms receive the same scrolling behavior as source tables. Both stages run on pages and shared Markdown partials. + `markdown` adds remark plugins, which change the Markdown tree, and rehype plugins, which change the HTML tree. Every page, header, and footer goes through these steps: 1. PureStack reads the Markdown. Regor markup, such as ``, stays raw HTML. @@ -525,11 +586,13 @@ Every field except `name` is optional. | `name` | `string`, unique among the site's plugins | | `defaults` | `boolean`; makes component and template contributions overridable defaults without changing initialization order | | `skins` | Skins by name, each `{ create(): ThemeSkinPair }` | +| `config` | `{ resolve?: SiteConfigResolver, finalize?: SiteConfigResolver }`; overrides, then derived settings, before factories | | `components` | `(config) => ({ [name]: component })` | | `templates` | Templates by name, each `(input) => TSNode<'html'>` | | `head` | `(input) => TSNode<'head'>`, or a promise; composes head markup before the page template | +| `headConfig` | `({ site, pageInfo }) => BasicHeadConfig`, or a promise; metadata defaults before explicit page overrides | | `styles` | `(config) => SiteStyles`; one stylesheet provider, initialized before component factories | -| `markdown` | `{ remarkPlugins?: PluggableList, rehypePlugins?: PluggableList }` | +| `markdown` | `PureStackMarkdown`: remark, rehype, final rehype transforms, and highlighter providers | | `pages` | `({ config, files }) => { path, source }[]`, or a promise of them; `source` is the text, or a function that returns it | | `hooks` | Any of the hooks above | | `devMiddleware` | `(request, response) => void`, or a promise | diff --git a/package.json b/package.json index a0736c3f..a5d4ccdd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 65fffb12..0bfee04a 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 7e38ecf1..e76b268c 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.14' +export const version: string = '1.1.15' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 4c812e8f..d0e62d1d 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-common/src/code-highlighter.ts b/packages/ts-common/src/code-highlighter.ts new file mode 100644 index 00000000..af7aee88 --- /dev/null +++ b/packages/ts-common/src/code-highlighter.ts @@ -0,0 +1,4 @@ +/** Converts source code to highlighted pre/code HTML. */ +export interface MdxCodeHighlighter { + codeToHtml: (code: string, lang?: string) => string | Promise +} diff --git a/packages/ts-ssg/src/mdx/codeLanguages.ts b/packages/ts-common/src/codeLanguages.ts similarity index 100% rename from packages/ts-ssg/src/mdx/codeLanguages.ts rename to packages/ts-common/src/codeLanguages.ts diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index 83586c36..495ac42a 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -1,3 +1,9 @@ +export type { MdxCodeHighlighter } from './code-highlighter' +export { + DEFAULT_CODE_LANGUAGES, + LANGUAGE_BY_EXTENSION, + normalizeCodeLanguage, +} from './codeLanguages' export type { ConsentCategory, ConsentConfig, @@ -33,6 +39,8 @@ export type { PageNavigationLinks, } from './navMenu-types' export type { + PageHeadConfigInput, + PageHeadConfigTemplate, PageHeadTemplate, PageInfo, PageTemplate, @@ -56,7 +64,11 @@ export type { PreviewConfig, RobotsConfig, SiteConfig, + SiteConfigContributions, SiteConfigInput, + SiteConfigResolution, + SiteConfigResolver, + SiteExtensions, SiteHtmlConfig, SiteMdxConfig, SitemapConfig, @@ -64,9 +76,12 @@ export type { SiteStyleConfig, SiteStyleConfigInput, } from './site-config-types' +export type { SiteSearch } from './site-search' export type { SiteStyles, WriteStylesResult } from './site-styles' export type { PageContext, PageOutlineItem, + RuntimeEmbedPosition, + RuntimeEmbeds, TsSsgNavigation, } from './ts-ssg-context' diff --git a/packages/ts-common/src/page-template-types.ts b/packages/ts-common/src/page-template-types.ts index 837375ff..7d7894f0 100644 --- a/packages/ts-common/src/page-template-types.ts +++ b/packages/ts-common/src/page-template-types.ts @@ -20,16 +20,24 @@ export interface PageTranslationInfo { relPath: string } -export interface PageTemplateInput { +export interface PageHeadConfigInput { + site: SiteConfig + pageInfo: PageInfo +} + +/** Supplies head defaults before explicit page frontmatter overrides. */ +export type PageHeadConfigTemplate = ( + input: PageHeadConfigInput, +) => BasicHeadConfig | Promise + +export interface PageTemplateInput extends PageHeadConfigInput { head: TSNode<'head'> bodyHtml: string headConfig?: BasicHeadConfig styleLinks?: ThemeStylesheetLink[] templateName: string - site: SiteConfig navigation?: PageNavigation outline?: PageOutlineItem[] - pageInfo: PageInfo headerHtml?: string footerHtml?: string } diff --git a/packages/ts-common/src/site-config-types.ts b/packages/ts-common/src/site-config-types.ts index 9f4e4280..d3c0174a 100644 --- a/packages/ts-common/src/site-config-types.ts +++ b/packages/ts-common/src/site-config-types.ts @@ -9,6 +9,8 @@ import type { SiteLogoConfig } from './logo-types' import type { NavigationConfig } from './navMenu-types' export interface SiteConfig { + /** Plugin-owned settings, normalized by their configuration contributions. */ + extensions: SiteExtensions rootDir: string contentDir: string outDir: string @@ -33,6 +35,11 @@ export interface SiteConfig { mdx: SiteMdxConfig } +/** Plugins may augment this interface with optional, typed namespaces. */ +export interface SiteExtensions { + [plugin: string]: unknown +} + export interface DocLayoutConfig { /** CSS length reserved above desktop navigation and TOC sidebars. */ sidebarTop: string @@ -147,3 +154,20 @@ export type SiteStyleConfigInput = DeepPartial< export type SiteConfigInput = DeepPartial> & { style?: SiteStyleConfigInput } + +/** Inputs for a pure configuration contribution, before build factories run. */ +export interface SiteConfigResolution { + config: SiteConfig + input: SiteConfigInput + file: SiteConfigInput +} + +export type SiteConfigResolver = ( + source: SiteConfigResolution, +) => DeepPartial + +/** All overrides resolve before any plugin derives dependent settings. */ +export interface SiteConfigContributions { + resolve?: SiteConfigResolver + finalize?: SiteConfigResolver +} diff --git a/packages/ts-common/src/site-search.ts b/packages/ts-common/src/site-search.ts new file mode 100644 index 00000000..412d5b61 --- /dev/null +++ b/packages/ts-common/src/site-search.ts @@ -0,0 +1,11 @@ +/** Search output owned by a plugin; the builder manages output invalidation. */ +export interface SiteSearch { + /** Whether a decoded, base-path-relative URL belongs to this search provider. */ + ownsPath(pathname: string): boolean + write(input: { + outDir: string + excludeFiles: Iterable + /** Only these rendered pages are available during lazy development. */ + onlyFiles?: Iterable + }): Promise +} diff --git a/packages/ts-common/src/site-styles.ts b/packages/ts-common/src/site-styles.ts index f5aeeb06..2bb24833 100644 --- a/packages/ts-common/src/site-styles.ts +++ b/packages/ts-common/src/site-styles.ts @@ -7,7 +7,7 @@ export interface WriteStylesResult { signature: string } -/** A plugin's stylesheet output, initialized before component factories. */ +/** A site's stylesheet output, initialized after config contributions and before component factories. */ export interface SiteStyles { readonly revision: number links(cacheKey?: string): ThemeStylesheetLink[] diff --git a/packages/ts-common/src/ts-ssg-context.ts b/packages/ts-common/src/ts-ssg-context.ts index d9c4c242..af8ec37f 100644 --- a/packages/ts-common/src/ts-ssg-context.ts +++ b/packages/ts-common/src/ts-ssg-context.ts @@ -13,6 +13,9 @@ export interface PageOutlineItem { children?: PageOutlineItem[] } +export type RuntimeEmbedPosition = 'body' | 'head' +export type RuntimeEmbeds = ReadonlyMap + export interface TsSsgNavigation { folder?: string root?: string @@ -35,5 +38,5 @@ export interface PageContext { resolvePublicHref: (href: string) => string recordScriptEntrypoint: (sourceRelPath: string) => void resolveScriptPublicPath?: (sourceRelPath: string) => string - recordRuntimeEmbed: (name: string, position: 'body' | 'head') => void + recordRuntimeEmbed: (name: string, position: RuntimeEmbedPosition) => void } diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index f2995ab0..d7391297 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index bef6f421..e1bde2f4 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-css/src/baseStyle.ts b/packages/ts-css/src/baseStyle.ts index 864c51c6..0dcb0814 100644 --- a/packages/ts-css/src/baseStyle.ts +++ b/packages/ts-css/src/baseStyle.ts @@ -6,13 +6,24 @@ import { hyphenizeCss } from './hyphenize' export abstract class RootStyle { static nextId = 1 - static revision = 0 + #revision = { value: 0 } + /** Changes anywhere in this style tree, independent of other trees. */ + get revision() { + return this.#revision.value + } + protected changed() { + this.#revision.value += 1 + } + protected shareRevision(child: RootStyle) { + this.#revision.value += child.#revision.value + child.#revision = this.#revision + } id: number selector: string constructor(selector?: string) { this.id = BaseStyle.nextId++ this.selector = selector ?? '' - RootStyle.revision += 1 + this.changed() } abstract toCSS(): string } @@ -23,7 +34,11 @@ export class BaseStyle extends RootStyle { #createT: (selector?: string) => T constructor(createT: (selector?: string) => T, selector?: string) { super(selector) - this.#createT = createT + this.#createT = (selector) => { + const child = createT(selector) + this.shareRevision(child) + return child + } } set(key: string, value: string | number) { @@ -31,7 +46,7 @@ export class BaseStyle extends RootStyle { value = autoVar(value) if (this.props.get(key) === value) return this this.props.set(key, value) - RootStyle.revision += 1 + this.changed() return this } @@ -92,7 +107,7 @@ export class BaseStyle extends RootStyle { this.#asBaseStyle(css).props.forEach((value, key) => { if (this.props.get(key) === value) return this.props.set(key, value) - RootStyle.revision += 1 + this.changed() }) return this } diff --git a/packages/ts-highlight/LICENSE b/packages/ts-highlight/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-highlight/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-highlight/README.md b/packages/ts-highlight/README.md new file mode 100644 index 00000000..324925d0 --- /dev/null +++ b/packages/ts-highlight/README.md @@ -0,0 +1,31 @@ +# @purestack/ts-highlight + +Syntax-highlighting backends and the default PureStack highlighting plugin. + +```ts +import { highlightPlugin } from '@purestack/ts-highlight' + +const plugin = highlightPlugin() +``` + +Included automatically by `ts-ssg`. The plugin selects `mdx.highlighter`, +initializes the selected engine once per build/session, and respects +`mdx.disableHighlighter`. Fenced, inline, imported, and Regor markup examples +all use the same highlighter. Language aliases, inferred file extensions, +tagged TypeScript templates, and light/dark Shiki themes are preserved. + +Pass `createShiki(options)` or `createHighlightJs()` to customize the native +backend. Both may return an instance synchronously or asynchronously; the +unused factory is never called. The Shiki options contain the default +languages and themes. For highlight.js, prefer a new instance with explicitly +registered grammars to avoid changing the shared registry. + +`createMdxHighlighter`, `createHljsHighlighter`, `DEFAULT_MDX_CODE_LANGS`, and +`DEFAULT_MDX_CODE_THEMES` are exported for standalone use. The shared +`MdxCodeHighlighter` contract is exported by `ts-common` and `ts-ssg`. + +To replace the backend entirely, supply `markdown.createHighlighter(config)` +from an ordinary plugin. It takes precedence over the default provider, which +is not initialized. Per-language `markdown.resolveHighlighter` callbacks take +precedence over either provider and remain available when default highlighting +is disabled. Disable automatic plugins to omit this provider altogether. diff --git a/packages/ts-highlight/package.json b/packages/ts-highlight/package.json new file mode 100644 index 00000000..6ef46416 --- /dev/null +++ b/packages/ts-highlight/package.json @@ -0,0 +1,46 @@ +{ + "name": "@purestack/ts-highlight", + "version": "1.1.15", + "packageManager": "yarn@4.9.2", + "module": "./dist/ts-highlight.mjs", + "types": "./dist/ts-highlight.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-highlight" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-highlight.d.mts", + "import": "./dist/ts-highlight.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:^", + "highlight.js": "^11.11.1", + "shiki": "^3.22.0" + } +} diff --git a/packages/ts-ssg/src/mdx/codeLanguages.test.ts b/packages/ts-highlight/src/codeLanguages.test.ts similarity index 91% rename from packages/ts-ssg/src/mdx/codeLanguages.test.ts rename to packages/ts-highlight/src/codeLanguages.test.ts index de6eec75..2f2c16b1 100644 --- a/packages/ts-ssg/src/mdx/codeLanguages.test.ts +++ b/packages/ts-highlight/src/codeLanguages.test.ts @@ -1,8 +1,11 @@ +import { LANGUAGE_BY_EXTENSION } from '@purestack/ts-common' +import { + createHljsHighlighter, + createMdxHighlighter, + DEFAULT_MDX_CODE_LANGS, +} from '@purestack/ts-highlight' import hljs from 'highlight.js' import { describe, expect, it, vi } from 'vitest' -import { LANGUAGE_BY_EXTENSION } from './codeLanguages' -import { createMdxHighlighter, DEFAULT_MDX_CODE_LANGS } from './highlight' -import { createHljsHighlighter } from './highlightjs' describe('MDX code languages', () => { it('renders every inferred language with the default Shiki grammars', async () => { diff --git a/packages/ts-ssg/src/mdx/highlightjs.ts b/packages/ts-highlight/src/highlightjs.ts similarity index 91% rename from packages/ts-ssg/src/mdx/highlightjs.ts rename to packages/ts-highlight/src/highlightjs.ts index f0cac45c..1be2330f 100644 --- a/packages/ts-ssg/src/mdx/highlightjs.ts +++ b/packages/ts-highlight/src/highlightjs.ts @@ -1,8 +1,9 @@ +import { + type MdxCodeHighlighter, + normalizeCodeLanguage, +} from '@purestack/ts-common' import hljs, { type HLJSApi } from 'highlight.js' -import { normalizeCodeLanguage } from './codeLanguages' -import type { MdxCodeHighlighter } from './highlight' - // Use highlight.js names, or the closest grammar for Shiki-only languages. const hljsLanguages = new Map([ ['jsonc', 'json'], diff --git a/packages/ts-highlight/src/index.ts b/packages/ts-highlight/src/index.ts new file mode 100644 index 00000000..42f832c9 --- /dev/null +++ b/packages/ts-highlight/src/index.ts @@ -0,0 +1,35 @@ +import type { SiteMdxConfig } from '@purestack/ts-common' +import type { HLJSApi } from 'highlight.js' +import { createHljsHighlighter } from './highlightjs' +import { createMdxHighlighter, type MdxShikiFactory } from './shiki' + +export { createHljsHighlighter } from './highlightjs' +export { + createMdxHighlighter, + DEFAULT_MDX_CODE_LANGS, + DEFAULT_MDX_CODE_THEMES, + type MdxCodeLangs, + type MdxCodeThemes, + type MdxShikiFactory, +} from './shiki' + +export interface HighlightPluginOptions { + createShiki?: MdxShikiFactory + createHighlightJs?: () => HLJSApi | Promise +} + +/** The standard highlighter, replaceable through markdown.createHighlighter. */ +export function highlightPlugin(options: HighlightPluginOptions = {}) { + return { + name: 'purestack:highlight', + defaults: true, + markdown: { + async createHighlighter(config: Partial) { + if (config.disableHighlighter) return undefined + if (config.highlighter === 'highlightjs') + return createHljsHighlighter(await options.createHighlightJs?.()) + return createMdxHighlighter(undefined, undefined, options.createShiki) + }, + }, + } +} diff --git a/packages/ts-highlight/src/plugin.test.ts b/packages/ts-highlight/src/plugin.test.ts new file mode 100644 index 00000000..3a313928 --- /dev/null +++ b/packages/ts-highlight/src/plugin.test.ts @@ -0,0 +1,122 @@ +import hljs from 'highlight.js' +import typescript from 'highlight.js/lib/languages/typescript' +import { + createHighlighter, + type Highlighter, + type LanguageRegistration, +} from 'shiki' +import { describe, expect, it } from 'vitest' +import { highlightPlugin } from './index' + +describe('highlightPlugin', () => { + it.each(['sync', 'async'])( + 'uses a %s Shiki factory with default options and a custom grammar', + async (mode) => { + let created: Highlighter | undefined + let calls = 0 + const grammar = { + name: 'creator-test', + scopeName: 'source.creator-test', + aliases: ['creator-test-alias'], + repository: {}, + patterns: [ + { name: 'keyword.control.creator-test', match: '\\blaunch\\b' }, + ], + } satisfies LanguageRegistration + try { + if (mode === 'sync') + created = await createHighlighter({ + themes: ['github-light', 'github-dark'], + langs: ['ts-tags', grammar], + }) + const plugin = highlightPlugin({ + createShiki(defaults) { + calls++ + expect(defaults.langs).toContain('ts-tags') + expect(defaults.themes).toEqual(['github-light', 'github-dark']) + if (created) return created + return createHighlighter({ + ...defaults, + langs: [...defaults.langs, grammar], + }).then((instance) => { + created = instance + return instance + }) + }, + createHighlightJs: () => { + throw new Error('Unused backend.') + }, + }) + const highlighter = await plugin.markdown.createHighlighter({ + highlighter: 'shiki', + }) + expect(calls).toBe(1) + expect( + highlighter?.codeToHtml('launch', 'creator-test-alias'), + ).toContain('--shiki-light:') + expect( + highlighter?.codeToHtml('launch again', 'creator-test'), + ).toContain('launch') + expect(highlighter?.codeToHtml('const answer = 42', 'ts')).toContain( + 'answer', + ) + } finally { + created?.dispose() + } + }, + ) + + it.each(['sync', 'async'])( + 'uses a %s highlight.js factory without changing the shared registry', + async (mode) => { + let calls = 0 + const plugin = highlightPlugin({ + createHighlightJs() { + calls++ + const engine = hljs.newInstance() + engine.registerLanguage('typescript', typescript) + engine.registerLanguage('creator-test', () => ({ + keywords: 'launch', + aliases: ['creator-test-alias'], + contains: [], + })) + engine.configure({ classPrefix: 'custom-' }) + return mode === 'async' ? Promise.resolve(engine) : engine + }, + createShiki: () => { + throw new Error('Unused backend.') + }, + }) + const highlighter = await plugin.markdown.createHighlighter({ + highlighter: 'highlightjs', + }) + expect(calls).toBe(1) + expect(highlighter?.codeToHtml('launch', 'creator-test-alias')).toContain( + 'launch', + ) + expect(highlighter?.codeToHtml('const answer = 42', 'ts')).toContain( + 'custom-keyword', + ) + expect(hljs.getLanguage('creator-test')).toBeUndefined() + const defaults = await highlightPlugin().markdown.createHighlighter({ + highlighter: 'highlightjs', + }) + expect(defaults?.codeToHtml('const answer = 42', 'ts')).toContain( + 'hljs-keyword', + ) + }, + ) + + it('skips both backend factories when highlighting is disabled', async () => { + const fail = () => { + throw new Error('Disabled backend.') + } + const plugin = highlightPlugin({ + createShiki: fail, + createHighlightJs: fail, + }) + expect( + await plugin.markdown.createHighlighter({ disableHighlighter: true }), + ).toBeUndefined() + }) +}) diff --git a/packages/ts-ssg/src/mdx/highlight.ts b/packages/ts-highlight/src/shiki.ts similarity index 90% rename from packages/ts-ssg/src/mdx/highlight.ts rename to packages/ts-highlight/src/shiki.ts index 35ea765f..1cd411e7 100644 --- a/packages/ts-ssg/src/mdx/highlight.ts +++ b/packages/ts-highlight/src/shiki.ts @@ -1,9 +1,9 @@ +import { + DEFAULT_CODE_LANGUAGES, + type MdxCodeHighlighter, + normalizeCodeLanguage, +} from '@purestack/ts-common' import { createHighlighter, type Highlighter } from 'shiki' -import { DEFAULT_CODE_LANGUAGES, normalizeCodeLanguage } from './codeLanguages' - -export interface MdxCodeHighlighter { - codeToHtml: (code: string, lang?: string) => string | Promise -} export type MdxShikiFactory = ( options: Parameters[0], diff --git a/packages/ts-highlight/tsconfig.json b/packages/ts-highlight/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-highlight/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-html/package.json b/packages/ts-html/package.json index e3171641..54e076cd 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 73014985..b51d1290 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/README.md b/packages/ts-page-scripts/README.md index c7485dfc..6b9f8fb9 100644 --- a/packages/ts-page-scripts/README.md +++ b/packages/ts-page-scripts/README.md @@ -16,9 +16,29 @@ also be imported from this package: import { pageScriptsPlugin } from '@purestack/ts-page-scripts' ``` -It preserves site configuration, page layout gates, consent-aware analytics, +It preserves site configuration, page layout gates, and the site's base path. Theme startup belongs to `@purestack/ts-theme`; -component-specific Tabs/Modal embeds remain managed by the renderer. +the plugin adds component-specific Tabs/Modal embeds through `onPageDocument`. Recorded component placement overrides frontmatter, and Tabs remains before Modal when both target the same parent. To omit the page runtimes, set `defaultPlugins: false` in `purestack.config.ts` or build options and list the other plugins you need. + +Three separate plugins from this package are also enabled by default: + +| Factory | Plugin name | Responsibility | +| --- | --- | --- | +| `authStateHintPlugin()` | `purestack:auth-state-hint` | Apply the signed-in hint before stylesheets load, when auth is enabled | +| `consentPlugin()` | `purestack:consent` | Initialize the consent browser runtime when consent is enabled | +| `analyticsPlugin()` | `purestack:analytics` | Add direct GA4 scripts without consent, or contribute the consent-managed GA4 service during configuration resolution | + +Each factory can be selected independently of `pageScriptsPlugin()`. Omitting +analytics removes automatic GA4 service registration as well as direct scripts; +explicit user-defined consent services are preserved. When consent is enabled, +analytics never bypasses it, including when the consent runtime plugin is omitted. +The raw `resolveSiteConfig` result contains only explicitly configured consent +services; automatic GA4 registration runs in `config.finalize`, after all +configuration overrides and before +styles, components, highlighters, search providers, and config hooks initialize. +Omitting the analytics plugin also omits this contribution. + +For standalone `renderApp` calls, use `appendRuntimeScripts(document, runtimeEmbeds, frontmatter.embed)` from the renderer's `onRendered(document, runtimeEmbeds)` callback. The renderer itself has no knowledge of individual runtimes. diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 4bc5d034..8e74968c 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.14", + "version": "1.1.15", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", @@ -41,5 +41,9 @@ "dependencies": { "@purestack/ts-common": "workspace:^", "@purestack/ts-html": "workspace:^" + }, + "devDependencies": { + "@purestack/ts-minidom": "workspace:^", + "@purestack/ts-render": "workspace:^" } } diff --git a/packages/ts-page-scripts/src/analyticsPlugin.ts b/packages/ts-page-scripts/src/analyticsPlugin.ts new file mode 100644 index 00000000..14bc855a --- /dev/null +++ b/packages/ts-page-scripts/src/analyticsPlugin.ts @@ -0,0 +1,75 @@ +import type { + ConsentCategory, + ConsentService, + Ga4Config, + PageTemplateInput, + SiteConfigResolution, +} from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { buildGa4Script } from './buildGa4Script' + +/** Owns direct GA4 scripts and its optional consent-managed service. */ +export function analyticsPlugin() { + return { + name: 'purestack:analytics', + config: { + finalize({ config }: SiteConfigResolution) { + return { + consent: { + services: config.consent.enabled + ? appendGa4ConsentService( + config.consent.services, + config.consent.categories, + config.analytics.ga4, + ) + : config.consent.services, + }, + } + }, + }, + head({ head, site }: PageTemplateInput) { + const ga4 = site.analytics.ga4 + if (ga4.enabled && ga4.measurementId && !site.consent.enabled) { + const payload = buildGa4Script(ga4) + head.push( + h('script').attr({ async: 'true', src: payload.src }), + h('script').raw(payload.init), + ) + } + return head + }, + } +} + +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), + }, + ] +} + +function buildGa4ConsentScripts(config: Ga4Config) { + const payload = buildGa4Script(config) + return [{ src: payload.src, async: true }, { content: payload.init }] +} diff --git a/packages/ts-page-scripts/src/authStateHintPlugin.ts b/packages/ts-page-scripts/src/authStateHintPlugin.ts new file mode 100644 index 00000000..8c900700 --- /dev/null +++ b/packages/ts-page-scripts/src/authStateHintPlugin.ts @@ -0,0 +1,23 @@ +import type { PageTemplateInput } from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { buildAuthStateHintScript } from './buildAuthStateHintScript' + +export function authStateHintPlugin() { + return { + name: 'purestack:auth-state-hint', + head({ head, site }: PageTemplateInput) { + if (site.auth.enabled && site.auth.signedInStorageKey) { + const hint = buildAuthStateHintScript(site.auth.signedInStorageKey) + if (hint) { + const script = h('script').raw(hint) + if ( + !head.insertBefore('link', script, { rel: 'stylesheet' }) && + !head.insertBefore('script', script) + ) + head.push(script) + } + } + return head + }, + } +} diff --git a/packages/ts-page-scripts/src/consentPlugin.ts b/packages/ts-page-scripts/src/consentPlugin.ts new file mode 100644 index 00000000..555ed919 --- /dev/null +++ b/packages/ts-page-scripts/src/consentPlugin.ts @@ -0,0 +1,14 @@ +import type { PageTemplateInput } from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { buildConsentScript } from './buildConsentScript' + +export function consentPlugin() { + return { + name: 'purestack:consent', + head({ head, site }: PageTemplateInput) { + if (site.consent.enabled) + head.push(h('script').raw(buildConsentScript(site.consent))) + return head + }, + } +} diff --git a/packages/ts-page-scripts/src/index.ts b/packages/ts-page-scripts/src/index.ts index ebc90493..2d95cba2 100644 --- a/packages/ts-page-scripts/src/index.ts +++ b/packages/ts-page-scripts/src/index.ts @@ -1,6 +1,9 @@ +export { analyticsPlugin } from './analyticsPlugin' +export { authStateHintPlugin } from './authStateHintPlugin' export { buildAuthStateHintScript } from './buildAuthStateHintScript' export { buildConsentScript } from './buildConsentScript' export { buildThemeSwitchScript } from './buildThemeSwitchScript' +export { consentPlugin } from './consentPlugin' export { buildCodeCopyScript } from './embed/codeCopy.embed' export { buildMenuRuntimeScript } from './embed/menuRuntime.embed' export { buildModalScript } from './embed/modal.embed' @@ -16,3 +19,4 @@ export type { TsSsgPageTocApi, TsSsgTabsApi, } from './runtimeGlobals' +export { appendRuntimeScripts } from './runtimeScripts' diff --git a/packages/ts-page-scripts/src/plugin.ts b/packages/ts-page-scripts/src/plugin.ts index 32d2a32a..0fe56057 100644 --- a/packages/ts-page-scripts/src/plugin.ts +++ b/packages/ts-page-scripts/src/plugin.ts @@ -1,17 +1,35 @@ +import type { PageFrontmatter, RuntimeEmbeds } from '@purestack/ts-common' import { isTocEnabled, type PageTemplateInput } from '@purestack/ts-common' import { h } from '@purestack/ts-html' -import { buildAuthStateHintScript } from './buildAuthStateHintScript' -import { buildConsentScript } from './buildConsentScript' -import { buildGa4Script } from './buildGa4Script' import { buildCodeCopyScript } from './embed/codeCopy.embed' import { buildMenuRuntimeScript } from './embed/menuRuntime.embed' import { buildNavMenuScript } from './embed/navMenu.embed' import { buildPagefindSearchScript } from './embed/pagefindSearch.embed' import { buildPageTocScript } from './embed/pageToc.embed' +import { appendRuntimeScripts } from './runtimeScripts' /** The browser behaviors that accompany the default PureStack components. */ export function pageScriptsPlugin() { - return { name: 'purestack:page-scripts', head: renderScriptsHead } + return { + name: 'purestack:page-scripts', + head: renderScriptsHead, + hooks: { + onPageDocument( + _context: unknown, + page: { + document: Document + runtimeEmbeds: RuntimeEmbeds + frontmatter: PageFrontmatter + }, + ) { + appendRuntimeScripts( + page.document, + page.runtimeEmbeds, + page.frontmatter.embed, + ) + }, + }, + } } function renderScriptsHead({ @@ -20,28 +38,6 @@ function renderScriptsHead({ pageInfo, outline, }: PageTemplateInput) { - if (config.auth.enabled && config.auth.signedInStorageKey) { - const hint = buildAuthStateHintScript(config.auth.signedInStorageKey) - if (hint) { - const auth = h('script').raw(hint) - // The hint must run before stylesheet loading can delay other scripts. - if ( - !head.insertBefore('link', auth, { rel: 'stylesheet' }) && - !head.insertBefore('script', auth) - ) - head.push(auth) - } - } - if (config.consent.enabled) - head.push(h('script').raw(buildConsentScript(config.consent))) - const ga4 = config.analytics.ga4 - if (ga4.enabled && ga4.measurementId && !config.consent.enabled) { - const payload = buildGa4Script(ga4) - head.push( - h('script').attr({ async: 'true', src: payload.src }), - h('script').raw(payload.init), - ) - } head.push(h('script').raw(buildMenuRuntimeScript())) if (pageInfo.frontmatter.layout.showNav !== false) head.push(h('script').raw(buildNavMenuScript())) diff --git a/packages/ts-page-scripts/src/runtimeScripts.test.ts b/packages/ts-page-scripts/src/runtimeScripts.test.ts new file mode 100644 index 00000000..b652321a --- /dev/null +++ b/packages/ts-page-scripts/src/runtimeScripts.test.ts @@ -0,0 +1,52 @@ +import type { PageContext } from '@purestack/ts-common' +import { runInDom } from '@purestack/ts-minidom' +import { renderApp } from '@purestack/ts-render' +import { describe, expect, it } from 'vitest' +import { appendRuntimeScripts } from './runtimeScripts' + +describe('appendRuntimeScripts', () => { + it('supports standalone rendering through the generic renderer callback', async () => { + const context: Partial = { recordRuntimeEmbed: () => {} } + const output = await renderApp( + '

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 { * Runs on the rendered document, before it becomes HTML. It may be async: * the document stays this render's own until it finishes. */ - onRendered?: (document: Document) => void | Promise + onRendered?: ( + document: Document, + runtimeEmbeds: RuntimeEmbeds, + ) => void | Promise } /** @@ -61,62 +63,10 @@ export const renderApp = ( element: document.body, }, ) - appendEmbeddedScriptsToDom(runtimeEmbeds, pageContext) - await options.onRendered?.(document) + await options.onRendered?.(document, runtimeEmbeds) if (isDocument) { return `${document.documentElement?.outerHTML ?? ''}` } return document.body?.innerHTML ?? '' }) } - -function appendEmbeddedScriptsToDom( - runtimeEmbeds: Map, - context: PageContext, -) { - appendRuntimeScript( - resolveTabsEmbedPosition(runtimeEmbeds, context), - buildTabsScript, - ) - appendRuntimeScript( - resolveModalEmbedPosition(runtimeEmbeds, context), - buildModalScript, - ) -} - -function appendRuntimeScript( - position: 'body' | 'head' | undefined, - scriptBuilder: () => string, -) { - if (!position) return - const targetParent = ensureScriptParent(position) - if (!targetParent) return - const script = document.createElement('script') - script.textContent = scriptBuilder() - targetParent.appendChild(script) -} - -function ensureScriptParent(position: 'body' | 'head') { - if (position === 'body') { - return document.body - } - return document.head -} - -function resolveTabsEmbedPosition( - runtimeEmbeds: Map, - context: PageContext, -) { - const runtimePosition = runtimeEmbeds.get('tabs') - if (runtimePosition) return runtimePosition - return context?.pageInfo?.frontmatter?.embed?.tabs -} - -function resolveModalEmbedPosition( - runtimeEmbeds: Map, - context: PageContext, -) { - const runtimePosition = runtimeEmbeds.get('modal') - if (runtimePosition) return runtimePosition - return context?.pageInfo?.frontmatter?.embed?.modal -} diff --git a/packages/ts-seo/LICENSE b/packages/ts-seo/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-seo/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-seo/README.md b/packages/ts-seo/README.md new file mode 100644 index 00000000..8aca2d3e --- /dev/null +++ b/packages/ts-seo/README.md @@ -0,0 +1,28 @@ +# @purestack/ts-seo + +Default social preview metadata, canonical URLs, and translated alternate links. + +```ts +import { seoPlugin } from '@purestack/ts-seo' + +const plugin = seoPlugin() +``` + +Enabled automatically by `@purestack/ts-ssg`. Uses site and page `preview` +settings, page titles and descriptions, `sitemap.baseUrl`, `basePath`, and the +page's translations to generate Open Graph, Twitter, canonical, and hreflang +defaults. Relative preview images honor the site's base path; absolute image +URLs are preserved. Page-specific images use their own dimensions. + +The plugin contributes `headConfig` before head markup and page templates run. +Explicit frontmatter `head` values override these defaults, including nested +Open Graph and Twitter fields. Templates receive the completed `headConfig`. + +To omit automatic preview metadata, set `defaultPlugins: false` in +`purestack.config.ts` or build options and list the other plugins you need. +Basic titles, descriptions, explicit `head` settings, and `index: false` +enforcement remain available in core. Sitemap and robots.txt generation remain +in `@purestack/ts-ssg`. + +`resolvePreviewConfig(frontmatter, options)` is also exported for custom metadata +plugins. It returns metadata defaults without applying explicit `head` overrides. diff --git a/packages/ts-seo/package.json b/packages/ts-seo/package.json new file mode 100644 index 00000000..fc9de86e --- /dev/null +++ b/packages/ts-seo/package.json @@ -0,0 +1,46 @@ +{ + "name": "@purestack/ts-seo", + "version": "1.1.15", + "packageManager": "yarn@4.9.2", + "module": "./dist/ts-seo.mjs", + "types": "./dist/ts-seo.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-seo" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-seo.d.mts", + "import": "./dist/ts-seo.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:^", + "@purestack/ts-html": "workspace:^", + "@purestack/ts-util": "workspace:^" + } +} diff --git a/packages/ts-seo/src/index.ts b/packages/ts-seo/src/index.ts new file mode 100644 index 00000000..e0cdbcf8 --- /dev/null +++ b/packages/ts-seo/src/index.ts @@ -0,0 +1,21 @@ +import type { PageHeadConfigInput } from '@purestack/ts-common' +import { resolvePreviewConfig } from './preview' + +export { type PreviewHeadOptions, resolvePreviewConfig } from './preview' + +/** Supplies metadata defaults before explicit page head overrides and head rendering. */ +export function seoPlugin() { + return { + name: 'purestack:seo', + headConfig({ site, pageInfo }: PageHeadConfigInput) { + return resolvePreviewConfig(pageInfo.frontmatter, { + siteTitle: site.siteTitle, + sitePreview: site.preview, + basePath: site.basePath, + baseUrl: site.sitemap.baseUrl, + urlPath: pageInfo.urlPath, + translations: pageInfo.translations, + }) + }, + } +} diff --git a/packages/ts-seo/src/preview.test.ts b/packages/ts-seo/src/preview.test.ts new file mode 100644 index 00000000..38e99796 --- /dev/null +++ b/packages/ts-seo/src/preview.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { resolvePreviewConfig } from './preview' + +describe('social preview defaults', () => { + it.each([ + [' https://example.com/// ', 'https://example.com/guide/'], + [`https://example.com/${'/'.repeat(20_000)}`, 'https://example.com/guide/'], + [ + `https://example.com/${'/'.repeat(20_000)}tail`, + 'https://example.com/guide/', + ], + ['/'.repeat(20_000), undefined], + [' ', undefined], + ])( + 'normalizes base URLs with whitespace and repeated slashes (%#)', + (baseUrl, expected) => { + expect( + resolvePreviewConfig({}, { baseUrl, urlPath: '/guide/' }).canonicalUrl, + ).toBe(expected) + }, + ) + + it('preserves title fallbacks, absolute URLs, image dimensions, and Twitter defaults', () => { + const head = resolvePreviewConfig( + { title: 'Getting Started', description: 'Build your first thing.' }, + { + 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', + }, + basePath: '/docs', + baseUrl: 'https://example.com', + urlPath: '/guide/', + }, + ) + 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('gives page preview fields precedence over site defaults', () => { + const head = resolvePreviewConfig( + { + title: 'Usage', + description: 'Page description.', + preview: { + title: 'Usage Preview', + description: 'Preview description.', + image: '/usage.png', + imageAlt: 'Usage preview', + imageWidth: 640, + imageHeight: 320, + type: 'article', + twitterCreator: '@writer', + }, + }, + { + siteTitle: 'Acme Docs', + sitePreview: { + title: 'Acme', + image: '/site.png', + imageWidth: 1200, + imageHeight: 630, + twitterCreator: '@site', + }, + baseUrl: 'https://example.com', + urlPath: '/usage/', + }, + ) + expect(head.openGraph).toMatchObject({ + title: 'Usage Preview', + description: 'Preview 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: 'Preview description.', + image: 'https://example.com/usage.png', + }) + }) + + it('does not reuse site dimensions for a page-specific image', () => { + const head = resolvePreviewConfig( + { title: 'Usage', preview: { image: '/page.png' } }, + { + sitePreview: { image: '/site.png', imageWidth: 1200, imageHeight: 630 }, + baseUrl: 'https://example.com', + urlPath: '/usage/', + }, + ) + expect(head.openGraph?.image).toBe('https://example.com/page.png') + expect(head.openGraph?.imageWidth).toBeUndefined() + expect(head.openGraph?.imageHeight).toBeUndefined() + }) + + it('keeps absolute images unchanged and supports relative images without a base URL', () => { + const absolute = resolvePreviewConfig( + { preview: { image: 'https://cdn.example.com/home.png' } }, + { basePath: '/docs' }, + ) + expect(absolute.openGraph?.image).toBe('https://cdn.example.com/home.png') + const relative = resolvePreviewConfig( + { preview: { image: 'preview.png' } }, + { basePath: '/docs', urlPath: '/usage/' }, + ) + expect(relative.openGraph?.image).toBe('/docs/preview.png') + expect(relative.canonicalUrl).toBeUndefined() + }) + + it('preserves localized alternates and suppresses duplicate-only alternates', () => { + const options = { + baseUrl: 'https://example.com', + basePath: '/docs', + translations: [ + { locale: 'en', urlPath: '/en/guide/', relPath: 'en/guide.mdx' }, + { locale: 'de', urlPath: '/de/guide/', relPath: 'de/guide.mdx' }, + ], + } + expect(resolvePreviewConfig({}, options).alternates).toEqual([ + { hrefLang: 'en', href: 'https://example.com/docs/en/guide/' }, + { hrefLang: 'de', href: 'https://example.com/docs/de/guide/' }, + ]) + expect( + resolvePreviewConfig( + {}, + { ...options, translations: options.translations.slice(0, 1) }, + ).alternates, + ).toBeUndefined() + }) +}) diff --git a/packages/ts-seo/src/preview.ts b/packages/ts-seo/src/preview.ts new file mode 100644 index 00000000..e7696813 --- /dev/null +++ b/packages/ts-seo/src/preview.ts @@ -0,0 +1,185 @@ +import type { + PageFrontmatter, + PageTranslationInfo, + PreviewConfig, +} from '@purestack/ts-common' +import type { BasicHeadConfig } from '@purestack/ts-html' +import { withBasePath } from '@purestack/ts-util' + +export interface PreviewHeadOptions { + siteTitle?: string + sitePreview?: PreviewConfig + basePath?: string + baseUrl?: string + urlPath?: string + translations?: PageTranslationInfo[] +} + +export function resolvePreviewConfig( + frontmatter: Pick, + options: PreviewHeadOptions, +): 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 } : {}), + } +} + +function resolveAlternateLinks( + options: PreviewHeadOptions, +): 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: PreviewHeadOptions, +): 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: PreviewHeadOptions, +): 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: PreviewHeadOptions): 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() + let end = trimmed.length + while (end > 0 && trimmed[end - 1] === '/') end-- + return end > 0 ? trimmed.slice(0, end) : 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 + } +} diff --git a/packages/ts-seo/tsconfig.json b/packages/ts-seo/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-seo/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-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index aed15015..bf11fbaf 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.14", + "version": "1.1.15", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 3adfae9c..3b9e15ea 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -314,19 +314,29 @@ order, inherit into child folders, and skip external URLs. A plugin is a named bundle of extensions: skins, components, templates, Markdown transforms, generated pages, build hooks, and dev server middleware. The [Plugins guide](https://purestack.studio/guides/extending/plugins/) walks through each part. -Three plugins provide the standard site features and are enabled automatically for builds, publishing, and the development server: +Nine plugins provide the standard site features and are enabled automatically for builds, publishing, and the development server: | Package | Factory | Features | | --- | --- | --- | | `@purestack/ts-components` | `componentsPlugin()` | Standard Regor components, SVG icons, and component styles | -| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, and theme switching | -| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, code copying, search UI, and table of contents runtimes | +| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, theme switching, generated favicons, and Markdown table presentation | +| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Menus, code copying, search UI, table of contents, Tabs, and Modal runtimes | +| `@purestack/ts-page-scripts` | `authStateHintPlugin()` | Early signed-in state hint | +| `@purestack/ts-page-scripts` | `consentPlugin()` | Consent browser runtime | +| `@purestack/ts-page-scripts` | `analyticsPlugin()` | Direct or consent-managed GA4 integration | +| `@purestack/ts-seo` | `seoPlugin()` | Social previews, canonical URLs, and translated alternate links | +| `@purestack/ts-highlight` | `highlightPlugin()` | Shiki and highlight.js backends and configured default selection | +| `@purestack/ts-pagefind` | `pagefindPlugin()` | Full-build and lazy development search indexing | These factories and `createDefaultPlugins()` are also exported from `@purestack/ts-ssg` and `purestack`. The stylesheet provider initializes before component factories. Components and templates resolve before every plugin's `onConfigResolved` hook runs in plugin order. Names do not change that order. Components are scoped to each build; config hooks can override them through `context.components`. Plugins can supply a `head` template, with the same input as a page template and a `TSNode<'head'>` result. Head templates compose in plugin order before the page template, so page templates can inspect or change the complete head. The lifecycle hook set is unchanged. -The theme plugin supplies `styles(config)`, which returns the `SiteStyles` writer used for full builds and lazy development assets. Only one plugin can supply it. Component and template contributions marked `defaults: true` may be overridden by ordinary contributions; this flag controls merge precedence and never schedules hooks or factories. Conflicts within either group remain errors. +Metadata defaults compose through `headConfig({ site, pageInfo })` in plugin order, before explicit frontmatter `head` overrides and before head markup is rendered. It returns `BasicHeadConfig` or a promise. The SEO plugin uses this contribution for Open Graph, Twitter, canonical URLs, and translated alternate links; page templates receive the completed `headConfig`. Basic titles, descriptions, explicit head settings, and `index: false` enforcement remain in core. + +Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. The analytics plugin contributes automatic GA4 consent services during configuration resolution, before any factory or lifecycle hook runs. Omitting it removes automatic registration and direct tracking scripts. Explicitly configured consent services remain available. Analytics never bypasses enabled consent when the consent runtime plugin is omitted. + +The theme plugin supplies `styles(config)`, which returns the `SiteStyles` writer used for full builds and lazy development assets. Only one plugin can supply it. Component, template, and default highlighter contributions marked `defaults: true` may be overridden by ordinary contributions; this flag controls merge precedence and never schedules hooks or factories. Conflicts within either group remain errors. To choose the integrations explicitly, set `defaultPlugins: false` in `purestack.config.ts` (or `options.defaultPlugins` programmatically): @@ -339,7 +349,7 @@ export default defineConfig({ }) ``` -This keeps the standard layouts and components while omitting the page runtimes. Theme switching stays with the theme plugin, and component-specific Tabs/Modal embeds still follow each page's `embed` settings. Without the theme plugin, provide templates through another plugin. Extension templates and components can still override the standard ones. Explicitly listing a standard plugin while defaults are enabled replaces that plugin by name without adding a second instance; it retains its initialization position. Duplicate names within the supplied plugin list are errors. +This keeps the standard layouts and components while omitting automatic social metadata, page runtimes, code highlighting, and search indexing. Theme switching, generated favicons, and Markdown table wrappers stay with the theme plugin. Tabs and Modal embeds belong to the page scripts plugin and are omitted with it. Without the theme plugin, provide templates through another plugin. Extension templates and components can still override the standard ones. Explicitly listing a standard plugin while defaults are enabled replaces that plugin by name without adding a second instance; it retains its initialization position. Duplicate names within the supplied plugin list are errors. A site lists its plugins in `purestack.config.ts`, next to `siteConfig.json`, and the CLI loads it: @@ -390,13 +400,58 @@ Plugins apply in order: - **Skins** are available while the site config resolves, so `style.theme.skin` can select one; they are removed afterwards and never leak into another build. A plugin skin cannot reuse a built-in skin's name, such as `standard`. - **Components** are built from the resolved site config and may replace built-in components by name. - **Templates** may replace the built-in `doc` and `splash` templates by name. -- **Markdown** remark and rehype plugins run in plugin order on every page and shared header or footer: remark on the Markdown tree, then rehype on the HTML tree, before outline collection and code highlighting. Regor markup reaches them as raw HTML nodes, and `file.path` is the content path with forward slashes. +- **Markdown** remark and rehype plugins run in plugin order on every page and shared header or footer: remark on the Markdown tree, then rehype on the HTML tree, followed by `rehypePostPlugins` for final presentation, before outline collection and code highlighting. Regor markup reaches them as raw HTML nodes, and `file.path` is the content path with forward slashes. - **Pages** generators return `{ path, source }` pages that build like files at those content paths. `source` is the text, or a function PureStack calls whenever it needs the text, without keeping what it returns. They see the site's content files, run on every build, and run again in the dev server whenever content or assets change; pages no longer generated are removed. - **Hooks** run every plugin's handler for each lifecycle event, one after another. An error names the plugin it came from. - **Dev middleware** sees each dev server request, except live reload, before the site; the first plugin to send headers handles it. Builds ignore it. Each plugin is checked when the build starts. An unknown field or hook name, such as `onPageRender`, a value of the wrong type, two plugins sharing a name, or two plugins defining the same skin, component, template, or generated page fails the build with the plugin's name. For a single hook, pass an inline plugin: `{ name: 'site', hooks: { … } }`. +### Plugin configuration + +Plugins contribute configuration through `config.resolve({ config, input, file })` and `config.finalize({ config, input, file })`. Both are optional and synchronous. Each returns an object to merge into the config; do not mutate the inputs. All resolvers run first in plugin order, applying configuration overrides. Then all finalizers run in plugin order, deriving dependent settings from the completed overrides. Finalizers should derive settings rather than change resolver inputs. Both phases finish before highlighters, Markdown processors, styles, search providers, components, and templates initialize. The analytics plugin finalizes its automatic GA4 service here, so later site resolvers can enable analytics, change its ID or category, or provide a manual service. The existing `onConfigResolved` hook still runs after factories, so it can customize initialized components and templates. Publishing applies its output-directory and minification overrides after configuration composition. + +Custom site settings belong under `extensions` in `siteConfig.json`. File values merge with programmatic input, which takes precedence. The standard JSON schema allows plugin namespaces here while continuing to reject unknown top-level fields. Each plugin owns validation, defaults, and normalization for its namespace. Other namespaces are preserved. + +```json +{ "extensions": { "brand": { "label": "Acme" } } } +``` + +Use TypeScript module augmentation for typed settings: + +```ts +import { definePlugin } from 'purestack' + +declare module '@purestack/ts-common' { + interface SiteExtensions { + brand?: { label: string } + } +} + +export const brandPlugin = definePlugin({ + name: 'brand', + config: { + resolve({ config }) { + const brand = config.extensions.brand + if (brand !== undefined && + (brand === null || typeof brand !== 'object' || + typeof brand.label !== 'string')) { + throw new Error('extensions.brand requires a string label.') + } + return { extensions: { brand: { label: brand?.label ?? 'Docs' } } } + }, + }, + components(config) { + // config.extensions.brand contains the normalized settings here. + return {} + }, +}) +``` + +Raw `resolveSiteConfig(input)` resolves core settings and extension input. CLI and programmatic builds additionally compose their selected plugins' configuration contributions. Configuration contributions are synchronous and may run whenever a project configuration is resolved; keep file writes and other side effects in lifecycle hooks. + +Builder style state is isolated across asynchronous operations, including concurrent sites. Its owned theme options remain linked to `context.config.style.theme`, so in-place config-hook changes and `themes.setOptions` update page colors, CSS, and generated favicons consistently. Head, metadata, template, Markdown, provider, and lifecycle errors identify their owning plugin and preserve the original cause. Markdown wrappers preserve unified presets, duplicate-plugin option merging, and callback transforms. + ### Code highlighting Use `markdown.resolveHighlighter(lang)` to select a code-to-HTML callback for a language: @@ -417,57 +472,44 @@ The resolver can be synchronous or asynchronous and receives the trimmed, lowerc Custom callbacks also work when `mdx.disableHighlighter` is `true`. In that case, if all resolvers return `null`, the source is HTML-escaped and displayed in a plain `
` 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('') @@ -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('
Option
') - expect(html).toContain('
') + expect(html).not.toContain('table-scroll') expect(html).toContain('
') expect(html).toContain('') expect(html).toContain('') 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( + '
Bus