Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions frontend/purestack.studio/guides/extending/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [...] })`.
Expand Down Expand Up @@ -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 <a href="https://github.com/remarkjs/remark" target="_blank" rel="noopener">remark</a> plugins, which change the Markdown tree, and <a href="https://github.com/rehypejs/rehype" target="_blank" rel="noopener">rehype</a> plugins, which change the HTML tree. Every page, header, and footer goes through these steps:

1. PureStack reads the Markdown. Regor markup, such as `<Badge tone="success">`, stays raw HTML.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/purestack/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/purestack/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const version: string = '1.1.14'
export const version: string = '1.1.15'

export * from '@purestack/ts-ssg'
2 changes: 1 addition & 1 deletion packages/ts-common/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/ts-common/src/code-highlighter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Converts source code to highlighted pre/code HTML. */
export interface MdxCodeHighlighter {
codeToHtml: (code: string, lang?: string) => string | Promise<string>
}
15 changes: 15 additions & 0 deletions packages/ts-common/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export type { MdxCodeHighlighter } from './code-highlighter'
export {
DEFAULT_CODE_LANGUAGES,
LANGUAGE_BY_EXTENSION,
normalizeCodeLanguage,
} from './codeLanguages'
export type {
ConsentCategory,
ConsentConfig,
Expand Down Expand Up @@ -33,6 +39,8 @@ export type {
PageNavigationLinks,
} from './navMenu-types'
export type {
PageHeadConfigInput,
PageHeadConfigTemplate,
PageHeadTemplate,
PageInfo,
PageTemplate,
Expand All @@ -56,17 +64,24 @@ export type {
PreviewConfig,
RobotsConfig,
SiteConfig,
SiteConfigContributions,
SiteConfigInput,
SiteConfigResolution,
SiteConfigResolver,
SiteExtensions,
SiteHtmlConfig,
SiteMdxConfig,
SitemapConfig,
SiteScriptsConfig,
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'
14 changes: 11 additions & 3 deletions packages/ts-common/src/page-template-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BasicHeadConfig>

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
}
Expand Down
24 changes: 24 additions & 0 deletions packages/ts-common/src/site-config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -147,3 +154,20 @@ export type SiteStyleConfigInput = DeepPartial<
export type SiteConfigInput = DeepPartial<Omit<SiteConfig, 'style'>> & {
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<SiteConfig>

/** All overrides resolve before any plugin derives dependent settings. */
export interface SiteConfigContributions {
resolve?: SiteConfigResolver
finalize?: SiteConfigResolver
}
11 changes: 11 additions & 0 deletions packages/ts-common/src/site-search.ts
Original file line number Diff line number Diff line change
@@ -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<string>
/** Only these rendered pages are available during lazy development. */
onlyFiles?: Iterable<string>
}): Promise<void>
}
2 changes: 1 addition & 1 deletion packages/ts-common/src/site-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
5 changes: 4 additions & 1 deletion packages/ts-common/src/ts-ssg-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export interface PageOutlineItem {
children?: PageOutlineItem[]
}

export type RuntimeEmbedPosition = 'body' | 'head'
export type RuntimeEmbeds = ReadonlyMap<string, RuntimeEmbedPosition>

export interface TsSsgNavigation {
folder?: string
root?: string
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion packages/ts-components/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/ts-css/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 20 additions & 5 deletions packages/ts-css/src/baseStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -23,15 +34,19 @@ export class BaseStyle<T extends RootStyle> 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) {
key = hyphenizeCss(key)
value = autoVar(value)
if (this.props.get(key) === value) return this
this.props.set(key, value)
RootStyle.revision += 1
this.changed()
return this
}

Expand Down Expand Up @@ -92,7 +107,7 @@ export class BaseStyle<T extends RootStyle> 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
}
Expand Down
21 changes: 21 additions & 0 deletions packages/ts-highlight/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading