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
2 changes: 1 addition & 1 deletion frontend/components/studioBrand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { defineComponent, html, type RefOrValue } from 'regor'

export interface StudioBrand {
config: LogoConfig
config?: LogoConfig
size?: RefOrValue<LogoSize>
}

Expand Down
90 changes: 86 additions & 4 deletions frontend/purestack.studio/guides/extending/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,49 @@ A plugin is a named object that extends how PureStack builds your site. Each of

## Add your first plugin

To use an installed plugin without writing TypeScript, list its package in
`siteConfig.json`. For example, install `@purestack/ts-images`, then enable image
optimization:

```json
{
"plugins": ["@purestack/ts-images#imagesPlugin"],
"extensions": {
"images": { "widths": [480, 960, 1440] }
}
}
```

Use `package#exportName` to select a named export, for example
`"@purestack/ts-images#imagesPlugin"`. Without a suffix, PureStack selects the
default export. Each selected export must be a plugin object or a no-argument
factory. Packages can expose multiple plugins; list each export separately:

```json
{
"plugins": [
"@purestack/ts-page-scripts#consentPlugin",
"@purestack/ts-page-scripts#analyticsPlugin"
]
}
```

The suffix is the JavaScript export name, not the plugin's `name`. Package
subpaths also work: `package/subpath#exportName`. PureStack loads selections for
builds, publishing, and development, and reloads them when this JSON file changes.
Each plugin owns its settings; listing `imagesPlugin` enables
its defaults even without `extensions.images`. Set `extensions.images.enabled`
to `false` to disable optimization. An explicit plugin instance in
`purestack.config.ts` replaces a JSON-selected plugin with the same name.

Both config formats use PureStack's own copy of a package when available so
shared registries stay together; other packages resolve from the site.
Plugin declarations execute installed JavaScript with the build process's
permissions, so treat changes to `plugins` as code changes even when a CMS edits
other site settings.

For local plugins and custom callbacks, use the TypeScript setup below.

Keep plugins in a folder of their own, beside the content folder:

```text
Expand Down Expand Up @@ -111,7 +154,23 @@ Metadata defaults compose through `headConfig({ site, pageInfo })` in plugin ord

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:
To choose the standard integrations yourself, disable the automatic set and list the plugins you want in `siteConfig.json`:

```json
{
"defaultPlugins": false,
"plugins": [
"@purestack/ts-theme#themePlugin",
"@purestack/ts-components#componentsPlugin"
]
}
```

Omitting `defaultPlugins` keeps the automatic set enabled. An explicit
TypeScript/programmatic `defaultPlugins` value overrides the JSON flag.
Development reloads changes to either setting.

The same selection is available in `purestack.config.ts`. The factories are exported from `purestack`, `@purestack/ts-ssg`, and their individual packages:

```ts
import { componentsPlugin, defineConfig, themePlugin } from 'purestack'
Expand All @@ -132,10 +191,14 @@ The Pagefind plugin supplies a `search` provider. To use another engine, replace

## 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.
`resolveSiteConfig(input)` returns `DefaultSiteConfig` for the untouched standard set. Calls supplying a `plugins` array return `SiteConfig`, even when automatic defaults remain enabled, because a supplied plugin can replace a standard owner by name.

Plugins contribute configuration through optional, synchronous `config.resolve`, `config.normalize`, and `config.finalize` functions, each receiving `{ config, input, file }` and returning an object to merge into config. Do not mutate the inputs. All resolvers run first in plugin order to supply defaults and overrides. All normalizers then validate and canonicalize the completed overrides. Finally, all finalizers derive dependent settings from the normalized config. Finalizers should not change the inputs used by other finalizers. Every phase finishes before highlighters, Markdown processors, styles, search providers, components, and templates initialize. Analytics derives its automatic GA4 consent service only after every normalizer finishes, regardless of plugin order. The existing `onConfigResolved` hook still runs after factories. 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.

`resolve` and `normalize` receive `SiteConfigDraft`: core fields are ready, while plugin settings can still be partial, including nested extension values. `finalize` receives normalized `SiteConfig` after all selected owners finish normalization. Factories run afterwards. Importing a plugin's setting types does not install its owner; select that plugin before depending on its normalized settings.

```json
{ "extensions": { "brand": { "label": "Acme" } } }
```
Expand Down Expand Up @@ -171,10 +234,29 @@ export const brandPlugin = definePlugin({
})
```

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.
`resolveSiteConfig(input, { plugins, defaultPlugins })` composes the supplied plugin instances with the standard plugins by default. This synchronous helper does not apply JSON plugin selection (`plugins` or `defaultPlugins`) or load `purestack.config.ts`. Plugin-owned sections (`style`, `docLayout`, `pageToc`, `logo`, `auth`, `consent`, `analytics`, `pagefind`, and `preview`) are optional in `SiteConfig`: an omitted plugin does not supply defaults or validate its unused input. A replacement plugin reads its settings from `input` and `file`, then contributes normalized config. Analytics also resolves explicitly configured consent when its browser runtime is omitted, so enabled consent is never bypassed. 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.

## Optimize images

Image optimization is optional. Install `@purestack/ts-images` alongside PureStack and add it to your existing plugin list:

```ts
import { defineConfig } from 'purestack'
import { imagesPlugin } from '@purestack/ts-images'

export default defineConfig({
plugins: [imagesPlugin({ widths: [480, 960, 1440] })],
})
```

Ordinary Markdown images and rendered HTML images receive responsive AVIF/WebP sources, JPEG/PNG fallbacks, dimensions, and loading defaults. Originals remain available for links and downloads. Remote images, SVGs, animations, existing `picture` markup, and images with `srcset` pass through. Use `data-image-preset="hero"` for eager priority images, `data-image-preset="screenshot"` for lossless output, or `data-image-optimize="false"` to keep an individual image unchanged. Set `sizes` for your actual image layout.

The plugin owns its `extensions.images` settings, persistent cache, and encoder limits. It uses the generic asset contribution: `assets(config)` returns a per-site processor with a unique namespace and `processPage(input)`. This runs after all `onPageDocument` hooks, before URL rewriting. `resolveSource(element, url)` resolves local inputs and registers dependencies; `dependOn(relPath)` registers additional inputs; `emit(fileName, bytes)` atomically writes an owned file and returns its URL. Use content-addressed names and await all writes. Core handles base paths, incremental invalidation, shared references, manifest persistence, and cleanup when pages or processors disappear. The builder contains no image format or encoder selection.

Asset processors run in order within each page, and different page requests can run them concurrently. Keep page-local state inside `processPage`; coordinate shared caches and queues within the processor instance. Await source resolution and emission before returning. Input methods then close, and detached work cannot emit files after page completion. Later processors see earlier DOM edits, while `resolveSource` continues to resolve original content files only. Combine transforms inside one processor when they need intermediate generated bytes. Ownership receipts and processor instances remain private to the builder.

## 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 @@ -586,7 +668,7 @@ 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 |
| `config` | `{ resolve?: SiteConfigResolver, normalize?: SiteConfigResolver, finalize?: SiteConfigFinalizer }`; overrides, normalization, 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 |
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.15",
"version": "1.1.16",
"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.15",
"version": "1.1.16",
"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.15'
export const version: string = '1.1.16'

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.15",
"version": "1.1.16",
"packageManager": "yarn@4.9.2",
"module": "./dist/ts-common.mjs",
"types": "./dist/ts-common.d.mts",
Expand Down
8 changes: 8 additions & 0 deletions packages/ts-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ export {
resolvePageContext,
tryResolvePageContext,
} from './resolvePageContext'
export type {
AssetSource,
PageAssetInput,
SiteAssets,
} from './site-assets'
export type {
AnalyticsConfig,
AuthConfig,
CoreSiteConfig,
DocLayoutConfig,
Ga4Config,
I18nConfig,
Expand All @@ -65,6 +71,8 @@ export type {
RobotsConfig,
SiteConfig,
SiteConfigContributions,
SiteConfigDraft,
SiteConfigFinalizer,
SiteConfigInput,
SiteConfigResolution,
SiteConfigResolver,
Expand Down
29 changes: 29 additions & 0 deletions packages/ts-common/src/site-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** A local source selected through the site's URL and locale resolution rules. */
export interface AssetSource {
relPath: string
absPath: string
/** Root-relative URL, without the site's base path. */
publicPath: string
}

export interface PageAssetInput {
document: Document
sourceRelPath: string
/** Resolves original content assets and records dependencies. Generated outputs are not resolver inputs. */
resolveSource: (
element: Element,
url: string,
) => Promise<AssetSource | undefined>
/** Records another content-root-relative input, including a file not yet created. */
dependOn: (relPath: string) => void
/** Await writes before returning. Use immutable content-addressed names within this namespace. */
emit: (fileName: string, data: Uint8Array) => Promise<string>
}

/** Each build/session owns its processors; core handles generated files and invalidation. */
export interface SiteAssets {
/** A unique path segment under /_assets/. */
namespace: string
/** Ordered within a page; different pages may run concurrently. Input methods expire when this call completes. */
processPage: (input: PageAssetInput) => void | Promise<void>
}
53 changes: 35 additions & 18 deletions packages/ts-common/src/site-config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,33 +8,37 @@ import type { ConsentConfig } from './consent-types'
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
/** Settings normalized by the builder before plugin configuration runs. */
export interface CoreSiteConfig {
rootDir: string
contentDir: string
outDir: string
publishDir: string
basePath: string
siteTitle: string
favicon?: string
logo: SiteLogoConfig
docLayout: DocLayoutConfig
style: SiteStyleConfig
html: SiteHtmlConfig
navigation: NavigationConfig
pageToc: PageTocConfig
auth: AuthConfig
scripts: SiteScriptsConfig
sitemap: SitemapConfig
consent: ConsentConfig
analytics: AnalyticsConfig
pagefind: PagefindConfig
preview: PreviewConfig
i18n: I18nConfig
mdx: SiteMdxConfig
}

export interface SiteConfig extends CoreSiteConfig {
/** Plugin-owned settings, normalized by the installed owners. */
extensions: SiteExtensions
favicon?: string
logo?: SiteLogoConfig
docLayout?: DocLayoutConfig
style?: SiteStyleConfig
pageToc?: PageTocConfig
auth?: AuthConfig
consent?: ConsentConfig
analytics?: AnalyticsConfig
pagefind?: PagefindConfig
preview?: PreviewConfig
}

/** Plugins may augment this interface with optional, typed namespaces. */
export interface SiteExtensions {
[plugin: string]: unknown
Expand Down Expand Up @@ -140,7 +144,8 @@ export interface I18nConfig {
}

export interface SiteMdxConfig {
highlighter: 'shiki' | 'highlightjs'
/** An optional provider-owned backend identifier. Core does not select an engine. */
highlighter?: string
disableHighlighter: boolean
compileMdAsMdx: boolean
}
Expand All @@ -155,9 +160,15 @@ export type SiteConfigInput = DeepPartial<Omit<SiteConfig, 'style'>> & {
style?: SiteStyleConfigInput
}

/** Core fields are ready; plugin sections may still contain partial input. */
export type SiteConfigDraft = CoreSiteConfig &
Omit<SiteConfigInput, keyof CoreSiteConfig | 'extensions'> & {
extensions: DeepPartial<SiteExtensions>
}

/** Inputs for a pure configuration contribution, before build factories run. */
export interface SiteConfigResolution {
config: SiteConfig
export interface SiteConfigResolution<TConfig = SiteConfigDraft> {
config: TConfig
input: SiteConfigInput
file: SiteConfigInput
}
Expand All @@ -166,8 +177,14 @@ export type SiteConfigResolver = (
source: SiteConfigResolution,
) => DeepPartial<SiteConfig>

/** All overrides resolve before any plugin derives dependent settings. */
/** Runs after every installed owner's normalizer has completed. */
export type SiteConfigFinalizer = (
source: SiteConfigResolution<SiteConfig>,
) => DeepPartial<SiteConfig>

/** Overrides, normalization, then dependent settings; all run before factories. */
export interface SiteConfigContributions {
resolve?: SiteConfigResolver
finalize?: SiteConfigResolver
normalize?: SiteConfigResolver
finalize?: SiteConfigFinalizer
}
2 changes: 1 addition & 1 deletion packages/ts-common/src/ts-ssg-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface PageContext {
pageInfo: PageInfo
navigation?: TsSsgNavigation
outline?: PageOutlineItem[]
theme: ThemeOptions
theme?: ThemeOptions
basePath: string
locale?: string
locales: string[]
Expand Down
2 changes: 2 additions & 0 deletions packages/ts-components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ Extension component factories and config hooks can override standard components
through `context.components` and register styles. When choosing plugins explicitly with `defaultPlugins: false`,
include `@purestack/ts-theme` to emit CSS, or supply your own style writer through
`context.styles`.

The components plugin owns the site `logo` defaults and normalization. Without it, a custom plugin may contribute `logo`; components also accept an absent site logo config.
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.15",
"version": "1.1.16",
"packageManager": "yarn@4.9.2",
"module": "./dist/ts-components.mjs",
"types": "./dist/ts-components.d.mts",
Expand Down
Loading
Loading