Skip to content

Commit a59b4d0

Browse files
committed
Move integration-specific configuration into its owning plugins and add optional image optimization through @purestack/ts-images. Preserve existing default site output while making plugin configuration and asset lifecycle contracts explicit.
1 parent 19b6869 commit a59b4d0

89 files changed

Lines changed: 5747 additions & 1120 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎frontend/components/studioBrand.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
import { defineComponent, html, type RefOrValue } from 'regor'
77

88
export interface StudioBrand {
9-
config: LogoConfig
9+
config?: LogoConfig
1010
size?: RefOrValue<LogoSize>
1111
}
1212

‎frontend/purestack.studio/guides/extending/plugins.mdx‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,14 @@ The Pagefind plugin supplies a `search` provider. To use another engine, replace
132132

133133
## Configure a plugin
134134

135-
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.
135+
`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.
136+
137+
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.
136138

137139
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.
138140

141+
`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.
142+
139143
```json
140144
{ "extensions": { "brand": { "label": "Acme" } } }
141145
```
@@ -171,10 +175,29 @@ export const brandPlugin = definePlugin({
171175
})
172176
```
173177

174-
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.
178+
`resolveSiteConfig(input, { plugins, defaultPlugins })` composes the same selected plugins as a build, including the standard plugins by default. 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.
175179

176180
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.
177181

182+
## Optimize images
183+
184+
Image optimization is optional. Install `@purestack/ts-images` alongside PureStack and add it to your existing plugin list:
185+
186+
```ts
187+
import { defineConfig } from 'purestack'
188+
import { imagesPlugin } from '@purestack/ts-images'
189+
190+
export default defineConfig({
191+
plugins: [imagesPlugin({ widths: [480, 960, 1440] })],
192+
})
193+
```
194+
195+
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.
196+
197+
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.
198+
199+
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.
200+
178201
## How the config loads
179202

180203
- **Where it lives.** The CLI looks for `purestack.config.ts` in the folder passed to `--content`. It must default-export `defineConfig({ plugins: [...] })`.
@@ -586,7 +609,7 @@ Every field except `name` is optional.
586609
| `name` | `string`, unique among the site's plugins |
587610
| `defaults` | `boolean`; makes component and template contributions overridable defaults without changing initialization order |
588611
| `skins` | Skins by name, each `{ create(): ThemeSkinPair }` |
589-
| `config` | `{ resolve?: SiteConfigResolver, finalize?: SiteConfigResolver }`; overrides, then derived settings, before factories |
612+
| `config` | `{ resolve?: SiteConfigResolver, normalize?: SiteConfigResolver, finalize?: SiteConfigFinalizer }`; overrides, normalization, then derived settings, before factories |
590613
| `components` | `(config) => ({ [name]: component })` |
591614
| `templates` | Templates by name, each `(input) => TSNode<'html'>` |
592615
| `head` | `(input) => TSNode<'head'>`, or a promise; composes head markup before the page template |

‎packages/ts-common/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,15 @@ export {
5252
resolvePageContext,
5353
tryResolvePageContext,
5454
} from './resolvePageContext'
55+
export type {
56+
AssetSource,
57+
PageAssetInput,
58+
SiteAssets,
59+
} from './site-assets'
5560
export type {
5661
AnalyticsConfig,
5762
AuthConfig,
63+
CoreSiteConfig,
5864
DocLayoutConfig,
5965
Ga4Config,
6066
I18nConfig,
@@ -65,6 +71,8 @@ export type {
6571
RobotsConfig,
6672
SiteConfig,
6773
SiteConfigContributions,
74+
SiteConfigDraft,
75+
SiteConfigFinalizer,
6876
SiteConfigInput,
6977
SiteConfigResolution,
7078
SiteConfigResolver,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/** A local source selected through the site's URL and locale resolution rules. */
2+
export interface AssetSource {
3+
relPath: string
4+
absPath: string
5+
/** Root-relative URL, without the site's base path. */
6+
publicPath: string
7+
}
8+
9+
export interface PageAssetInput {
10+
document: Document
11+
sourceRelPath: string
12+
/** Resolves original content assets and records dependencies. Generated outputs are not resolver inputs. */
13+
resolveSource: (
14+
element: Element,
15+
url: string,
16+
) => Promise<AssetSource | undefined>
17+
/** Records another content-root-relative input, including a file not yet created. */
18+
dependOn: (relPath: string) => void
19+
/** Await writes before returning. Use immutable content-addressed names within this namespace. */
20+
emit: (fileName: string, data: Uint8Array) => Promise<string>
21+
}
22+
23+
/** Each build/session owns its processors; core handles generated files and invalidation. */
24+
export interface SiteAssets {
25+
/** A unique path segment under /_assets/. */
26+
namespace: string
27+
/** Ordered within a page; different pages may run concurrently. Input methods expire when this call completes. */
28+
processPage: (input: PageAssetInput) => void | Promise<void>
29+
}

‎packages/ts-common/src/site-config-types.ts‎

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,37 @@ import type { ConsentConfig } from './consent-types'
88
import type { SiteLogoConfig } from './logo-types'
99
import type { NavigationConfig } from './navMenu-types'
1010

11-
export interface SiteConfig {
12-
/** Plugin-owned settings, normalized by their configuration contributions. */
13-
extensions: SiteExtensions
11+
/** Settings normalized by the builder before plugin configuration runs. */
12+
export interface CoreSiteConfig {
1413
rootDir: string
1514
contentDir: string
1615
outDir: string
1716
publishDir: string
1817
basePath: string
1918
siteTitle: string
20-
favicon?: string
21-
logo: SiteLogoConfig
22-
docLayout: DocLayoutConfig
23-
style: SiteStyleConfig
2419
html: SiteHtmlConfig
2520
navigation: NavigationConfig
26-
pageToc: PageTocConfig
27-
auth: AuthConfig
2821
scripts: SiteScriptsConfig
2922
sitemap: SitemapConfig
30-
consent: ConsentConfig
31-
analytics: AnalyticsConfig
32-
pagefind: PagefindConfig
33-
preview: PreviewConfig
3423
i18n: I18nConfig
3524
mdx: SiteMdxConfig
3625
}
3726

27+
export interface SiteConfig extends CoreSiteConfig {
28+
/** Plugin-owned settings, normalized by the installed owners. */
29+
extensions: SiteExtensions
30+
favicon?: string
31+
logo?: SiteLogoConfig
32+
docLayout?: DocLayoutConfig
33+
style?: SiteStyleConfig
34+
pageToc?: PageTocConfig
35+
auth?: AuthConfig
36+
consent?: ConsentConfig
37+
analytics?: AnalyticsConfig
38+
pagefind?: PagefindConfig
39+
preview?: PreviewConfig
40+
}
41+
3842
/** Plugins may augment this interface with optional, typed namespaces. */
3943
export interface SiteExtensions {
4044
[plugin: string]: unknown
@@ -140,7 +144,8 @@ export interface I18nConfig {
140144
}
141145

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

163+
/** Core fields are ready; plugin sections may still contain partial input. */
164+
export type SiteConfigDraft = CoreSiteConfig &
165+
Omit<SiteConfigInput, keyof CoreSiteConfig | 'extensions'> & {
166+
extensions: DeepPartial<SiteExtensions>
167+
}
168+
158169
/** Inputs for a pure configuration contribution, before build factories run. */
159-
export interface SiteConfigResolution {
160-
config: SiteConfig
170+
export interface SiteConfigResolution<TConfig = SiteConfigDraft> {
171+
config: TConfig
161172
input: SiteConfigInput
162173
file: SiteConfigInput
163174
}
@@ -166,8 +177,14 @@ export type SiteConfigResolver = (
166177
source: SiteConfigResolution,
167178
) => DeepPartial<SiteConfig>
168179

169-
/** All overrides resolve before any plugin derives dependent settings. */
180+
/** Runs after every installed owner's normalizer has completed. */
181+
export type SiteConfigFinalizer = (
182+
source: SiteConfigResolution<SiteConfig>,
183+
) => DeepPartial<SiteConfig>
184+
185+
/** Overrides, normalization, then dependent settings; all run before factories. */
170186
export interface SiteConfigContributions {
171187
resolve?: SiteConfigResolver
172-
finalize?: SiteConfigResolver
188+
normalize?: SiteConfigResolver
189+
finalize?: SiteConfigFinalizer
173190
}

‎packages/ts-common/src/ts-ssg-context.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface PageContext {
2929
pageInfo: PageInfo
3030
navigation?: TsSsgNavigation
3131
outline?: PageOutlineItem[]
32-
theme: ThemeOptions
32+
theme?: ThemeOptions
3333
basePath: string
3434
locale?: string
3535
locales: string[]

‎packages/ts-components/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ Extension component factories and config hooks can override standard components
2121
through `context.components` and register styles. When choosing plugins explicitly with `defaultPlugins: false`,
2222
include `@purestack/ts-theme` to emit CSS, or supply your own style writer through
2323
`context.styles`.
24+
25+
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.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { SiteLogoConfig } from '@purestack/ts-common'
2+
import { pickSemanticTone } from '@purestack/ts-style'
3+
import type { DeepPartial } from '@purestack/ts-util'
4+
import {
5+
resolveConfigUrl,
6+
resolveOptionalString,
7+
resolveString,
8+
} from '@purestack/ts-util'
9+
10+
export function resolveSiteLogoConfig(
11+
input?: DeepPartial<SiteLogoConfig>,
12+
file?: DeepPartial<SiteLogoConfig>,
13+
): SiteLogoConfig {
14+
const href = input?.href !== undefined ? input.href : file?.href
15+
return {
16+
brand: resolveString(input?.brand, file?.brand, 'Pure Stack'),
17+
letterColors: resolveOptionalString(
18+
input?.letterColors ?? file?.letterColors,
19+
),
20+
subtitleLetterColors: resolveOptionalString(
21+
input?.subtitleLetterColors ?? file?.subtitleLetterColors,
22+
),
23+
brandSizeSm: resolveOptionalString(input?.brandSizeSm ?? file?.brandSizeSm),
24+
brandSizeMd: resolveOptionalString(input?.brandSizeMd ?? file?.brandSizeMd),
25+
brandSizeLg: resolveOptionalString(input?.brandSizeLg ?? file?.brandSizeLg),
26+
brandSizeXl: resolveOptionalString(input?.brandSizeXl ?? file?.brandSizeXl),
27+
subtitleSizeSm: resolveOptionalString(
28+
input?.subtitleSizeSm ?? file?.subtitleSizeSm,
29+
),
30+
subtitleSizeMd: resolveOptionalString(
31+
input?.subtitleSizeMd ?? file?.subtitleSizeMd,
32+
),
33+
subtitleSizeLg: resolveOptionalString(
34+
input?.subtitleSizeLg ?? file?.subtitleSizeLg,
35+
),
36+
subtitleSizeXl: resolveOptionalString(
37+
input?.subtitleSizeXl ?? file?.subtitleSizeXl,
38+
),
39+
iconSize: resolveOptionalString(input?.iconSize ?? file?.iconSize),
40+
iconSizeSm: resolveOptionalString(input?.iconSizeSm ?? file?.iconSizeSm),
41+
iconSizeMd: resolveOptionalString(input?.iconSizeMd ?? file?.iconSizeMd),
42+
iconSizeLg: resolveOptionalString(input?.iconSizeLg ?? file?.iconSizeLg),
43+
iconSizeXl: resolveOptionalString(input?.iconSizeXl ?? file?.iconSizeXl),
44+
subtitleInset: resolveOptionalString(
45+
input?.subtitleInset ?? file?.subtitleInset,
46+
),
47+
subtitleInsetSm: resolveOptionalString(
48+
input?.subtitleInsetSm ?? file?.subtitleInsetSm,
49+
),
50+
subtitleInsetMd: resolveOptionalString(
51+
input?.subtitleInsetMd ?? file?.subtitleInsetMd,
52+
),
53+
subtitleInsetLg: resolveOptionalString(
54+
input?.subtitleInsetLg ?? file?.subtitleInsetLg,
55+
),
56+
subtitleInsetXl: resolveOptionalString(
57+
input?.subtitleInsetXl ?? file?.subtitleInsetXl,
58+
),
59+
component: resolveOptionalString(input?.component ?? file?.component),
60+
colors: (input?.colors ?? file?.colors)?.filter(
61+
(color): color is string => typeof color === 'string',
62+
),
63+
logoBackground: input?.logoBackground ?? file?.logoBackground,
64+
logoForeground: input?.logoForeground ?? file?.logoForeground,
65+
href:
66+
href === null || href === ''
67+
? href
68+
: resolveConfigUrl(resolveString(href, '/')),
69+
subtitle: resolveOptionalString(input?.subtitle ?? file?.subtitle),
70+
suffix: resolveOptionalString(input?.suffix ?? file?.suffix),
71+
ariaLabel: resolveOptionalString(input?.ariaLabel ?? file?.ariaLabel),
72+
icon: resolveOptionalString(input?.icon ?? file?.icon),
73+
imageSrc: resolveConfigUrl(
74+
resolveOptionalString(input?.imageSrc ?? file?.imageSrc),
75+
),
76+
imageSrcDark: resolveConfigUrl(
77+
resolveOptionalString(input?.imageSrcDark ?? file?.imageSrcDark),
78+
),
79+
monogram: resolveOptionalString(input?.monogram ?? file?.monogram),
80+
brandColor: resolveOptionalString(input?.brandColor ?? file?.brandColor),
81+
accentColor: resolveOptionalString(input?.accentColor ?? file?.accentColor),
82+
markBackground: resolveOptionalString(
83+
input?.markBackground ?? file?.markBackground,
84+
),
85+
markColor: resolveOptionalString(input?.markColor ?? file?.markColor),
86+
brandSize: resolveOptionalString(input?.brandSize ?? file?.brandSize),
87+
subtitleSize: resolveOptionalString(
88+
input?.subtitleSize ?? file?.subtitleSize,
89+
),
90+
markSize: resolveOptionalString(input?.markSize ?? file?.markSize),
91+
gap: resolveOptionalString(input?.gap ?? file?.gap),
92+
layout: resolveLogoChoice(input?.layout ?? file?.layout, [
93+
'horizontal',
94+
'stacked',
95+
'wordmark',
96+
'mark',
97+
] as const),
98+
size: resolveLogoChoice(input?.size ?? file?.size, [
99+
'sm',
100+
'md',
101+
'lg',
102+
'xl',
103+
] as const),
104+
appearance: resolveLogoChoice(input?.appearance ?? file?.appearance, [
105+
'plain',
106+
'badge',
107+
'outline',
108+
] as const),
109+
markStyle: resolveLogoChoice(input?.markStyle ?? file?.markStyle, [
110+
'plain',
111+
'soft',
112+
'solid',
113+
'outline',
114+
] as const),
115+
wordmarkStyle: resolveLogoChoice(
116+
input?.wordmarkStyle ?? file?.wordmarkStyle,
117+
['plain', 'accent', 'gradient'] as const,
118+
),
119+
shape: resolveLogoChoice(input?.shape ?? file?.shape, [
120+
'rounded',
121+
'square',
122+
'circle',
123+
] as const),
124+
tone: pickSemanticTone(input?.tone, file?.tone),
125+
}
126+
}
127+
128+
function resolveLogoChoice<T extends string>(
129+
value: unknown,
130+
choices: readonly T[],
131+
): T | undefined {
132+
return typeof value === 'string' && choices.includes(value as T)
133+
? (value as T)
134+
: undefined
135+
}

‎packages/ts-components/src/plugin.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
1+
import type { SiteConfigResolution } from '@purestack/ts-common'
12
import { registerSemanticToneUtilityStyles } from '@purestack/ts-style'
23
import { getSvgIcon } from '@purestack/ts-svg-icons'
4+
import { merge } from '@purestack/ts-util'
35
import { defineComponents } from './defineComponents'
6+
import { resolveSiteLogoConfig } from './logoConfig'
47
import { registerStyles } from './registerStyles'
58

69
/** The standard server-rendered components and their shared CSS. */
710
export function componentsPlugin() {
811
return {
912
name: 'purestack:components',
1013
defaults: true,
14+
config: {
15+
resolve({ config, input, file }: SiteConfigResolution) {
16+
return {
17+
logo: merge(
18+
resolveSiteLogoConfig(input.logo, file.logo),
19+
config.logo,
20+
),
21+
}
22+
},
23+
normalize({ config }: SiteConfigResolution) {
24+
return { logo: resolveSiteLogoConfig(config.logo) }
25+
},
26+
},
1127
components(): Record<string, object> {
1228
registerStyles()
1329
registerSemanticToneUtilityStyles()

0 commit comments

Comments
 (0)