diff --git a/frontend/components/studioBrand.ts b/frontend/components/studioBrand.ts index 67abdcd0..fef130da 100644 --- a/frontend/components/studioBrand.ts +++ b/frontend/components/studioBrand.ts @@ -6,7 +6,7 @@ import { import { defineComponent, html, type RefOrValue } from 'regor' export interface StudioBrand { - config: LogoConfig + config?: LogoConfig size?: RefOrValue } diff --git a/frontend/purestack.studio/guides/extending/plugins.mdx b/frontend/purestack.studio/guides/extending/plugins.mdx index 8d717094..5f335f44 100644 --- a/frontend/purestack.studio/guides/extending/plugins.mdx +++ b/frontend/purestack.studio/guides/extending/plugins.mdx @@ -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 @@ -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' @@ -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" } } } ``` @@ -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: [...] })`. @@ -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 | diff --git a/package.json b/package.json index a5d4ccdd..d9b35c63 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 0bfee04a..50f09052 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -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", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index e76b268c..2c4f7b73 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.15' +export const version: string = '1.1.16' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index d0e62d1d..d447ea5f 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -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", diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index 495ac42a..6a9b806d 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -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, @@ -65,6 +71,8 @@ export type { RobotsConfig, SiteConfig, SiteConfigContributions, + SiteConfigDraft, + SiteConfigFinalizer, SiteConfigInput, SiteConfigResolution, SiteConfigResolver, diff --git a/packages/ts-common/src/site-assets.ts b/packages/ts-common/src/site-assets.ts new file mode 100644 index 00000000..edbe07af --- /dev/null +++ b/packages/ts-common/src/site-assets.ts @@ -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 + /** 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 +} + +/** 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 +} diff --git a/packages/ts-common/src/site-config-types.ts b/packages/ts-common/src/site-config-types.ts index d3c0174a..e773ae20 100644 --- a/packages/ts-common/src/site-config-types.ts +++ b/packages/ts-common/src/site-config-types.ts @@ -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 @@ -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 } @@ -155,9 +160,15 @@ export type SiteConfigInput = DeepPartial> & { style?: SiteStyleConfigInput } +/** Core fields are ready; plugin sections may still contain partial input. */ +export type SiteConfigDraft = CoreSiteConfig & + Omit & { + extensions: DeepPartial + } + /** Inputs for a pure configuration contribution, before build factories run. */ -export interface SiteConfigResolution { - config: SiteConfig +export interface SiteConfigResolution { + config: TConfig input: SiteConfigInput file: SiteConfigInput } @@ -166,8 +177,14 @@ export type SiteConfigResolver = ( source: SiteConfigResolution, ) => DeepPartial -/** All overrides resolve before any plugin derives dependent settings. */ +/** Runs after every installed owner's normalizer has completed. */ +export type SiteConfigFinalizer = ( + source: SiteConfigResolution, +) => DeepPartial + +/** Overrides, normalization, then dependent settings; all run before factories. */ export interface SiteConfigContributions { resolve?: SiteConfigResolver - finalize?: SiteConfigResolver + normalize?: SiteConfigResolver + finalize?: SiteConfigFinalizer } diff --git a/packages/ts-common/src/ts-ssg-context.ts b/packages/ts-common/src/ts-ssg-context.ts index af8ec37f..6f1b36e4 100644 --- a/packages/ts-common/src/ts-ssg-context.ts +++ b/packages/ts-common/src/ts-ssg-context.ts @@ -29,7 +29,7 @@ export interface PageContext { pageInfo: PageInfo navigation?: TsSsgNavigation outline?: PageOutlineItem[] - theme: ThemeOptions + theme?: ThemeOptions basePath: string locale?: string locales: string[] diff --git a/packages/ts-components/README.md b/packages/ts-components/README.md index 8462f613..3eae7ecc 100644 --- a/packages/ts-components/README.md +++ b/packages/ts-components/README.md @@ -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. diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index d7391297..35f03586 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -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", diff --git a/packages/ts-components/src/logoConfig.ts b/packages/ts-components/src/logoConfig.ts new file mode 100644 index 00000000..e5d0094f --- /dev/null +++ b/packages/ts-components/src/logoConfig.ts @@ -0,0 +1,135 @@ +import type { SiteLogoConfig } from '@purestack/ts-common' +import { pickSemanticTone } from '@purestack/ts-style' +import type { DeepPartial } from '@purestack/ts-util' +import { + resolveConfigUrl, + resolveOptionalString, + resolveString, +} from '@purestack/ts-util' + +export function resolveSiteLogoConfig( + input?: DeepPartial, + file?: DeepPartial, +): SiteLogoConfig { + const href = input?.href !== undefined ? input.href : file?.href + return { + brand: resolveString(input?.brand, file?.brand, 'Pure Stack'), + letterColors: resolveOptionalString( + input?.letterColors ?? file?.letterColors, + ), + subtitleLetterColors: resolveOptionalString( + input?.subtitleLetterColors ?? file?.subtitleLetterColors, + ), + brandSizeSm: resolveOptionalString(input?.brandSizeSm ?? file?.brandSizeSm), + brandSizeMd: resolveOptionalString(input?.brandSizeMd ?? file?.brandSizeMd), + brandSizeLg: resolveOptionalString(input?.brandSizeLg ?? file?.brandSizeLg), + brandSizeXl: resolveOptionalString(input?.brandSizeXl ?? file?.brandSizeXl), + subtitleSizeSm: resolveOptionalString( + input?.subtitleSizeSm ?? file?.subtitleSizeSm, + ), + subtitleSizeMd: resolveOptionalString( + input?.subtitleSizeMd ?? file?.subtitleSizeMd, + ), + subtitleSizeLg: resolveOptionalString( + input?.subtitleSizeLg ?? file?.subtitleSizeLg, + ), + subtitleSizeXl: resolveOptionalString( + input?.subtitleSizeXl ?? file?.subtitleSizeXl, + ), + iconSize: resolveOptionalString(input?.iconSize ?? file?.iconSize), + iconSizeSm: resolveOptionalString(input?.iconSizeSm ?? file?.iconSizeSm), + iconSizeMd: resolveOptionalString(input?.iconSizeMd ?? file?.iconSizeMd), + iconSizeLg: resolveOptionalString(input?.iconSizeLg ?? file?.iconSizeLg), + iconSizeXl: resolveOptionalString(input?.iconSizeXl ?? file?.iconSizeXl), + subtitleInset: resolveOptionalString( + input?.subtitleInset ?? file?.subtitleInset, + ), + subtitleInsetSm: resolveOptionalString( + input?.subtitleInsetSm ?? file?.subtitleInsetSm, + ), + subtitleInsetMd: resolveOptionalString( + input?.subtitleInsetMd ?? file?.subtitleInsetMd, + ), + subtitleInsetLg: resolveOptionalString( + input?.subtitleInsetLg ?? file?.subtitleInsetLg, + ), + subtitleInsetXl: resolveOptionalString( + input?.subtitleInsetXl ?? file?.subtitleInsetXl, + ), + component: resolveOptionalString(input?.component ?? file?.component), + colors: (input?.colors ?? file?.colors)?.filter( + (color): color is string => typeof color === 'string', + ), + logoBackground: input?.logoBackground ?? file?.logoBackground, + logoForeground: input?.logoForeground ?? file?.logoForeground, + href: + href === null || href === '' + ? href + : resolveConfigUrl(resolveString(href, '/')), + subtitle: resolveOptionalString(input?.subtitle ?? file?.subtitle), + suffix: resolveOptionalString(input?.suffix ?? file?.suffix), + ariaLabel: resolveOptionalString(input?.ariaLabel ?? file?.ariaLabel), + icon: resolveOptionalString(input?.icon ?? file?.icon), + imageSrc: resolveConfigUrl( + resolveOptionalString(input?.imageSrc ?? file?.imageSrc), + ), + imageSrcDark: resolveConfigUrl( + resolveOptionalString(input?.imageSrcDark ?? file?.imageSrcDark), + ), + monogram: resolveOptionalString(input?.monogram ?? file?.monogram), + brandColor: resolveOptionalString(input?.brandColor ?? file?.brandColor), + accentColor: resolveOptionalString(input?.accentColor ?? file?.accentColor), + markBackground: resolveOptionalString( + input?.markBackground ?? file?.markBackground, + ), + markColor: resolveOptionalString(input?.markColor ?? file?.markColor), + brandSize: resolveOptionalString(input?.brandSize ?? file?.brandSize), + subtitleSize: resolveOptionalString( + input?.subtitleSize ?? file?.subtitleSize, + ), + markSize: resolveOptionalString(input?.markSize ?? file?.markSize), + gap: resolveOptionalString(input?.gap ?? file?.gap), + layout: resolveLogoChoice(input?.layout ?? file?.layout, [ + 'horizontal', + 'stacked', + 'wordmark', + 'mark', + ] as const), + size: resolveLogoChoice(input?.size ?? file?.size, [ + 'sm', + 'md', + 'lg', + 'xl', + ] as const), + appearance: resolveLogoChoice(input?.appearance ?? file?.appearance, [ + 'plain', + 'badge', + 'outline', + ] as const), + markStyle: resolveLogoChoice(input?.markStyle ?? file?.markStyle, [ + 'plain', + 'soft', + 'solid', + 'outline', + ] as const), + wordmarkStyle: resolveLogoChoice( + input?.wordmarkStyle ?? file?.wordmarkStyle, + ['plain', 'accent', 'gradient'] as const, + ), + shape: resolveLogoChoice(input?.shape ?? file?.shape, [ + 'rounded', + 'square', + 'circle', + ] as const), + tone: pickSemanticTone(input?.tone, file?.tone), + } +} + +function resolveLogoChoice( + value: unknown, + choices: readonly T[], +): T | undefined { + return typeof value === 'string' && choices.includes(value as T) + ? (value as T) + : undefined +} diff --git a/packages/ts-components/src/plugin.ts b/packages/ts-components/src/plugin.ts index 44854193..c44b88a6 100644 --- a/packages/ts-components/src/plugin.ts +++ b/packages/ts-components/src/plugin.ts @@ -1,6 +1,9 @@ +import type { SiteConfigResolution } from '@purestack/ts-common' import { registerSemanticToneUtilityStyles } from '@purestack/ts-style' import { getSvgIcon } from '@purestack/ts-svg-icons' +import { merge } from '@purestack/ts-util' import { defineComponents } from './defineComponents' +import { resolveSiteLogoConfig } from './logoConfig' import { registerStyles } from './registerStyles' /** The standard server-rendered components and their shared CSS. */ @@ -8,6 +11,19 @@ export function componentsPlugin() { return { name: 'purestack:components', defaults: true, + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + logo: merge( + resolveSiteLogoConfig(input.logo, file.logo), + config.logo, + ), + } + }, + normalize({ config }: SiteConfigResolution) { + return { logo: resolveSiteLogoConfig(config.logo) } + }, + }, components(): Record { registerStyles() registerSemanticToneUtilityStyles() diff --git a/packages/ts-components/src/standard/consent/consent.ts b/packages/ts-components/src/standard/consent/consent.ts index 9babf2e3..33111689 100644 --- a/packages/ts-components/src/standard/consent/consent.ts +++ b/packages/ts-components/src/standard/consent/consent.ts @@ -15,7 +15,7 @@ export interface ConsentCategoryState extends ConsentCategory { inputId?: string } -export interface Consent extends ConsentConfig { +export interface Consent extends Partial { settingsTeleport?: string categories: ConsentCategoryState[] tone?: RefOrValue @@ -153,7 +153,7 @@ function resolveConsent( props: Partial, ): Consent { const consent = context.site.consent - const categories = consent.categories.map((category) => ({ + const categories = (consent?.categories ?? []).map((category) => ({ id: category.id, label: category.label, description: category.description, diff --git a/packages/ts-components/src/standard/topBar/topBar.ts b/packages/ts-components/src/standard/topBar/topBar.ts index 9594b656..610ffda9 100644 --- a/packages/ts-components/src/standard/topBar/topBar.ts +++ b/packages/ts-components/src/standard/topBar/topBar.ts @@ -17,7 +17,7 @@ import { } from '../componentVariant' export interface TopBar { - siteLogo: SiteLogoConfig + siteLogo?: SiteLogoConfig logoComponent?: RefOrValue resolvedLogoComponent?: ComputedRef tone?: RefOrValue @@ -41,7 +41,9 @@ function resolveTopBar(context: PageContext, props: TopBar): TopBar { siteLogo: context.site.logo, resolvedLogoComponent: computed( () => - unref(props.logoComponent) ?? context.site.logo.component ?? 'SiteLogo', + unref(props.logoComponent) ?? + context.site.logo?.component ?? + 'SiteLogo', ), searchEnabled: computed( () => unref(props.search) ?? context.site.pagefind?.enabled === true, diff --git a/packages/ts-components/src/test/testContext.ts b/packages/ts-components/src/test/testContext.ts index 84efe89d..86f77ab3 100644 --- a/packages/ts-components/src/test/testContext.ts +++ b/packages/ts-components/src/test/testContext.ts @@ -99,7 +99,7 @@ export function createTestContext( pageInfo, navigation: options.navigation, outline: options.outline, - theme: site.style.theme, + theme: site.style?.theme, basePath: site.basePath, locale: pageInfo.locale, locales: site.i18n.locales, diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index e1bde2f4..67fa2152 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-highlight/README.md b/packages/ts-highlight/README.md index 324925d0..b46c634f 100644 --- a/packages/ts-highlight/README.md +++ b/packages/ts-highlight/README.md @@ -14,6 +14,12 @@ initializes the selected engine once per build/session, and respects all use the same highlighter. Language aliases, inferred file extensions, tagged TypeScript templates, and light/dark Shiki themes are preserved. +The plugin owns the `highlightjs` default and accepts `highlightjs` or `shiki` +when its factory is selected. Core treats `mdx.highlighter` as an optional, +provider-owned identifier. Omitting this plugin does not select or normalize an +engine. Custom providers can use their own identifiers; replacing the default +factory avoids initializing or validating the standard backends. + 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 diff --git a/packages/ts-highlight/package.json b/packages/ts-highlight/package.json index 6ef46416..fbf0b247 100644 --- a/packages/ts-highlight/package.json +++ b/packages/ts-highlight/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-highlight", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-highlight.mjs", "types": "./dist/ts-highlight.d.mts", diff --git a/packages/ts-highlight/src/codeLanguages.test.ts b/packages/ts-highlight/src/codeLanguages.test.ts index 2f2c16b1..2f96b270 100644 --- a/packages/ts-highlight/src/codeLanguages.test.ts +++ b/packages/ts-highlight/src/codeLanguages.test.ts @@ -3,13 +3,30 @@ import { createHljsHighlighter, createMdxHighlighter, DEFAULT_MDX_CODE_LANGS, + type MdxShikiFactory, } from '@purestack/ts-highlight' import hljs from 'highlight.js' -import { describe, expect, it, vi } from 'vitest' +import { createHighlighter, type Highlighter } from 'shiki' +import { afterEach, describe, expect, it, vi } from 'vitest' describe('MDX code languages', () => { + const nativeHighlighters = new Set() + const createTestShiki: MdxShikiFactory = async (options) => { + const highlighter = await createHighlighter(options) + nativeHighlighters.add(highlighter) + return highlighter + } + afterEach(() => { + for (const highlighter of nativeHighlighters) highlighter.dispose() + nativeHighlighters.clear() + }) + it('renders every inferred language with the default Shiki grammars', async () => { - const highlighter = await createMdxHighlighter() + const highlighter = await createMdxHighlighter( + undefined, + undefined, + createTestShiki, + ) const languages = new Set([ ...DEFAULT_MDX_CODE_LANGS, ...Object.values(LANGUAGE_BY_EXTENSION), @@ -48,7 +65,11 @@ describe('MDX code languages', () => { ['gql', 'graphql', 'query { user { id } }'], ])('resolves %s with both highlighters', async (alias, language, code) => { // Loading by the alias must also work when defaults are overridden. - const shiki = await createMdxHighlighter(undefined, [alias]) + const shiki = await createMdxHighlighter( + undefined, + [alias], + createTestShiki, + ) expect(await shiki.codeToHtml(code, alias)).toBe( await shiki.codeToHtml(code, language), ) diff --git a/packages/ts-highlight/src/index.ts b/packages/ts-highlight/src/index.ts index 42f832c9..3497e428 100644 --- a/packages/ts-highlight/src/index.ts +++ b/packages/ts-highlight/src/index.ts @@ -1,4 +1,4 @@ -import type { SiteMdxConfig } from '@purestack/ts-common' +import type { SiteConfigResolution, SiteMdxConfig } from '@purestack/ts-common' import type { HLJSApi } from 'highlight.js' import { createHljsHighlighter } from './highlightjs' import { createMdxHighlighter, type MdxShikiFactory } from './shiki' @@ -23,13 +23,32 @@ export function highlightPlugin(options: HighlightPluginOptions = {}) { return { name: 'purestack:highlight', defaults: true, + config: { + normalize({ config }: SiteConfigResolution) { + return { + mdx: { highlighter: backendIdentifier(config.mdx.highlighter) }, + } + }, + }, markdown: { async createHighlighter(config: Partial) { if (config.disableHighlighter) return undefined - if (config.highlighter === 'highlightjs') + const backend = backendIdentifier(config.highlighter) + if (backend === 'highlightjs') return createHljsHighlighter(await options.createHighlightJs?.()) - return createMdxHighlighter(undefined, undefined, options.createShiki) + if (backend === 'shiki') + return createMdxHighlighter(undefined, undefined, options.createShiki) + throw new Error( + `The standard highlighting plugin does not support backend "${backend}". Supply a custom markdown.createHighlighter provider.`, + ) }, }, } } + +function backendIdentifier(value: unknown): string { + if (value === undefined) return 'highlightjs' + if (typeof value !== 'string' || !value.trim()) + throw new Error('mdx.highlighter must be a non-empty backend identifier.') + return value.trim() +} diff --git a/packages/ts-highlight/src/plugin.test.ts b/packages/ts-highlight/src/plugin.test.ts index 3a313928..45573674 100644 --- a/packages/ts-highlight/src/plugin.test.ts +++ b/packages/ts-highlight/src/plugin.test.ts @@ -9,6 +9,17 @@ import { describe, expect, it } from 'vitest' import { highlightPlugin } from './index' describe('highlightPlugin', () => { + it('owns its default selection and validates identifiers only when its backend is used', async () => { + const plugin = highlightPlugin() + const fallback = await plugin.markdown.createHighlighter({}) + expect(fallback?.codeToHtml('const answer = 42', 'ts')).toContain( + 'hljs-keyword', + ) + await expect( + plugin.markdown.createHighlighter({ highlighter: 'another-provider' }), + ).rejects.toThrow('Supply a custom markdown.createHighlighter provider') + }) + it.each(['sync', 'async'])( 'uses a %s Shiki factory with default options and a custom grammar', async (mode) => { diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 54e076cd..17aa21e2 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-images/LICENSE b/packages/ts-images/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-images/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-images/README.md b/packages/ts-images/README.md new file mode 100644 index 00000000..70d7ba26 --- /dev/null +++ b/packages/ts-images/README.md @@ -0,0 +1,179 @@ +# `@purestack/ts-images` + +Optional, build-time image optimization for PureStack. Ordinary Markdown and +rendered HTML images gain responsive variants without a browser runtime or a +special component. This package is **not** part of the default plugin set. + +Install it alongside PureStack: + +```sh +npm install @purestack/ts-images +``` + +Enable it in `siteConfig.json`; no TypeScript config is required: + +```json +{ + "plugins": ["@purestack/ts-images#imagesPlugin"], + "extensions": { + "images": { + "widths": [480, 960, 1440], + "formats": ["avif", "webp"] + } + } +} +``` + +Listing `imagesPlugin` enables its defaults, so `extensions.images` is optional. +Use `extensions.images.enabled: false` to turn optimization off while retaining +the declaration, or remove it from `plugins`. Build, publish, and the development +server all load the JSON declaration; development reloads changes to this file. + +For callbacks or custom factory defaults, use `purestack.config.ts`: + +```ts +import { defineConfig } from '@purestack/ts-ssg' +import { imagesPlugin } from '@purestack/ts-images' + +export default defineConfig({ + plugins: [imagesPlugin({ widths: [480, 960, 1440], formats: ['avif', 'webp'] })], +}) +``` + +An explicit TypeScript/plugin instance with the same plugin name takes precedence +over a JSON declaration. This package exports the named `imagesPlugin` factory; +use `package#exportName` to select named plugins from installed packages. + +```md +![Dashboard overview](./images/dashboard.png) +``` + +The plugin produces `` sources for the configured modern formats and +an `` with a JPEG/PNG fallback, `srcset`, `sizes`, and missing dimensions. +Generated URLs live under `/_assets/images/`, with the site's base path applied +by PureStack. Filenames contain the hash of the **encoded output bytes**. +Widths are deduplicated, sorted, and capped at the source's oriented dimensions. +If `formats: []` is selected, only the responsive fallback is emitted, without a +`` wrapper. + +Original assets are always retained for links, downloads, CSS backgrounds, +posters, and preview metadata. Source files are never edited. Only generated +files whose last referencing page disappears or changes are removed. + +## Configuration + +Factory options supply defaults that `siteConfig.json` and programmatic input +can override. Settings are also available under typed `extensions.images`: + +```json +{ + "extensions": { + "images": { + "widths": [480, 960, 1440], + "formats": ["avif", "webp"], + "sizes": "(max-width: 960px) 100vw, 960px", + "exclude": ["downloads/**"], + "quality": { "avif": 50, "webp": 80, "jpeg": 82 } + } + } +} +``` + +| Setting | Default / behavior | +| --- | --- | +| `enabled` | `true`; `false` leaves image output untouched | +| `widths` | `[480, 960, 1440]`; 1–16 widths, each an integer from 1 to 16384; no upscaling | +| `formats` | `['avif', 'webp']`; order determines `` preference; JPEG/PNG fallback is automatic | +| `quality` | AVIF 50, WebP 80, JPEG 82; each 1–100 | +| `sizes` | `100vw`; configure it for the image's actual layout slot | +| `loading` | `lazy`; may be `eager` or `false` to omit the default | +| `decoding` | `async`; may be `sync`, `auto`, or `false` | +| `priority` | `false`; fills missing `loading="eager"` and `fetchpriority="high"` when enabled | +| `lossless` | `false`; `true` enables lossless modern encoders and a PNG fallback | +| `metadata` | `strip`; auto-orient first, convert to sRGB, then omit metadata; `preserve` retains metadata with orientation normalized | +| `include` / `exclude` | `['**/*']` / `[]`; glob patterns on content-root-relative paths using `/` | +| `cacheDir` | OS temporary cache, namespaced by content root; custom paths resolve from `rootDir` and must be outside content and output directories | +| `cacheMaxAgeDays` | `30`; entries unused for this many days expire; `0` disables automatic cleanup | +| `concurrency` | `2`; per-site limit from 1–16, including encoding and output emission | +| `maxInputPixels` | 40 million | +| `maxSourceBytes` | 50 MiB | +| `onError` | `error`; `warn` retains the original image when optimization fails; invalid content URLs still obey core URL validation | +| `presets` | Named per-image settings, inheriting the resolved global image settings | + +The plugin owns normalization in the configuration pipeline. All resolver +overrides finish before normalization and processor creation. Omitting the +plugin leaves its input available to other plugins without validating it. + +## Per-image choices + +```html +The workshop +Editor settings +A portrait +Source diagram +Cover +``` + +Built-in presets are `content` (global settings), `hero` (960/1440/1920 widths +and eager/high priority), `thumbnail` (160/320/640 widths and a thumbnail +`sizes` value), and `screenshot` (lossless WebP plus PNG fallback). Override or +add presets with `presets: { name: { widths, formats, quality, sizes, loading, +decoding, priority, lossless, metadata } }`. + +Existing `alt`, IDs, classes, styles, links, dimensions, `sizes`, loading, +decoding, and fetch priority are preserved. If only one dimension is supplied, +the other is derived from the oriented aspect ratio. Author-supplied loading +and priority attributes take precedence over defaults. The plugin cannot +infer which images are above the fold; mark important images explicitly. + +Existing `` and `srcset` markup is left alone, as are external/data/blob +URLs, SVG, animated GIF/WebP/AVIF, and APNG. Local JPEG, PNG, WebP, AVIF, +single-frame GIF, and TIFF references are supported. CSS backgrounds, posters, +social metadata images, and arbitrary string URLs inside client JavaScript +are not rewritten. The `` wrapper changes the image's parent element; +use the opt-out where direct-child CSS selectors require the original structure. + +## Builds and development + +Sharp is loaded only when a selected local raster image is processed. The +package requires Node 22.20+ and uses Sharp's platform-specific native codecs. +No image dependency is added to PureStack's standard installation. + +The plugin processes the rendered document after document hooks and before URL +resolution, including server-rendered components, shared headers/footers, and +template content. It uses core's content URL and locale fallback rules rather +than resolving paths independently. + +Full builds process referenced images. Lazy development processes only requested +pages, and generated image files exist before the page is served. Core records +image dependencies separately from content imports, so optimization does not +suppress original asset copies. Image edits invalidate dependent pages; changes +to the discovered route/asset inventory may also invalidate other pages under +the normal URL-resolution rules. Dependencies survive manifest reuse and missing +images remain tracked for error-page recovery. + +The persistent cache survives cleaned output directories. Keys include input +bytes, pixel settings, and codec versions; markup-only choices such as `alt`, +loading, and `sizes` reuse encoded files. Cache contents are verified by hash, +and incomplete or corrupted entries are rebuilt. Concurrent identical work is +shared within a site; writes use atomic replacement. + +Cache reads renew an entry's last-use timestamp. During image processing, cleanup +runs at most once an hour per processor and removes entries unused for +`cacheMaxAgeDays`, including those in a custom `cacheDir`. Completed entries are +published atomically so independent builds can safely share the cache. Cleanup +also removes abandoned staging entries after the retention period and leaves +unrelated files and symlinked entries alone. Expiry affects only the encoding +cache, never source images or published assets. No cleanup runs while the plugin +is idle; `cacheMaxAgeDays: 0` keeps entries until you remove them manually. + +Optimization emits diagnostics with the referring page, source, cache result, +input bytes, total generated bytes, variant count, and duration. Generated bytes +are the total of all variants, not the bytes a particular browser downloads. +Small images and lossless variants can be larger than the input; tune formats, +quality, presets, or exclusions using these measurements. Encoding deliberately +prioritizes the configured quality/fidelity rather than promising a fixed saving. + +For custom reporting, pass `onDiagnostic(diagnostic)` to `imagesPlugin()`. +Remote downloads, SVG optimization, animated transcoding, placeholders, and +automatic cropping are outside this release's scope. diff --git a/packages/ts-images/package.json b/packages/ts-images/package.json new file mode 100644 index 00000000..4ca2df95 --- /dev/null +++ b/packages/ts-images/package.json @@ -0,0 +1,50 @@ +{ + "name": "@purestack/ts-images", + "version": "1.1.16", + "packageManager": "yarn@4.9.2", + "module": "./dist/ts-images.mjs", + "types": "./dist/ts-images.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-images" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-images.d.mts", + "import": "./dist/ts-images.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-util": "workspace:^", + "logpot": "^1.0.7", + "sharp": "^0.35.5" + }, + "engines": { + "node": ">=22.20.0" + } +} diff --git a/packages/ts-images/src/cache.test.ts b/packages/ts-images/src/cache.test.ts new file mode 100644 index 00000000..60888ac7 --- /dev/null +++ b/packages/ts-images/src/cache.test.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { pruneImageCache } from './cache' + +describe('image cache retention', () => { + let root: string + const old = new Date(Date.now() - 60 * 86_400_000) + beforeEach(async () => { + root = await fs.mkdtemp( + path.join(os.tmpdir(), 'purestack-cache-retention-'), + ) + }) + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(root, { recursive: true, force: true }) + }) + async function entry(name: string, date = old) { + const directory = path.join(root, name) + await fs.mkdir(directory) + await fs.writeFile( + path.join(directory, 'manifest.json'), + JSON.stringify({ version: 1, key: name, variants: [] }), + ) + await fs.utimes(path.join(directory, 'manifest.json'), date, date) + return directory + } + + it('expires unused owned entries while retaining fresh, active, foreign, and symlinked paths', async () => { + const expired = await entry('a'.repeat(64)) + await entry('b'.repeat(64), new Date()) + await entry('c'.repeat(64)) + await entry('notes') + const foreign = await entry('d'.repeat(64)) + await fs.writeFile( + path.join(foreign, 'manifest.json'), + '{"key":"unrelated"}', + ) + await fs.utimes(path.join(foreign, 'manifest.json'), old, old) + await fs.writeFile(path.join(root, 'keep.txt'), 'Keep') + const alias = path.join(root, 'e'.repeat(64)) + await fs.symlink( + path.join(root, 'notes'), + alias, + process.platform === 'win32' ? 'junction' : 'dir', + ) + // Two sites/processors can sweep the same shared cache concurrently. + await Promise.all( + Array.from({ length: 2 }, () => + pruneImageCache(root, 30, new Set(['c'.repeat(64)])), + ), + ) + await expect(fs.stat(expired)).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await fs.readdir(root)).sort()).toEqual( + [ + 'b'.repeat(64), + 'c'.repeat(64), + 'd'.repeat(64), + 'e'.repeat(64), + 'keep.txt', + 'notes', + ].sort(), + ) + expect(await fs.readFile(path.join(root, 'keep.txt'), 'utf8')).toBe('Keep') + }) + + it('allows automatic expiry to be disabled', async () => { + await entry('a'.repeat(64)) + await pruneImageCache(root, 0, new Set()) + expect(await fs.readdir(root)).toEqual(['a'.repeat(64)]) + }) + + it('rechecks expiry after reading a manifest to respect concurrent cache hits', async () => { + const directory = await entry('a'.repeat(64)) + const read = fs.readFile.bind(fs) + vi.spyOn(fs, 'readFile').mockImplementation( + async (...args: Parameters) => { + const value = await read(...args) + const now = new Date() + await fs.utimes(path.join(directory, 'manifest.json'), now, now) + return value + }, + ) + await pruneImageCache(root, 30, new Set()) + expect(await fs.readdir(root)).toEqual(['a'.repeat(64)]) + }) + + it('cleans abandoned staging directories without touching fresh staging work', async () => { + const stale = '.purestack-images-12345678-1234-1234-1234-123456789abc.tmp' + const fresh = '.purestack-images-12345678-1234-1234-1234-123456789abd.tmp' + const retired = + '.purestack-images-12345678-1234-1234-1234-123456789abe.trash' + for (const name of [stale, fresh, retired]) { + await fs.mkdir(path.join(root, name)) + await fs.writeFile(path.join(root, name, 'partial.png'), 'Incomplete') + if (name !== fresh) await fs.utimes(path.join(root, name), old, old) + } + await pruneImageCache(root, 30, new Set()) + expect(await fs.readdir(root)).toEqual([fresh]) + }) +}) diff --git a/packages/ts-images/src/cache.ts b/packages/ts-images/src/cache.ts new file mode 100644 index 00000000..85bc153f --- /dev/null +++ b/packages/ts-images/src/cache.ts @@ -0,0 +1,87 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { isPlainObject } from '@purestack/ts-util' + +const entryName = /^[a-f0-9]{64}$/ +const temporaryName = + /^\.purestack-images-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\.(?:tmp|trash)$/ + +/** Retire by rename: readers either finish reading or treat a vanished entry as a miss. */ +export async function retireCacheEntry(root: string, key: string) { + if (!entryName.test(key)) throw new Error('Invalid image cache key.') + const directory = path.join(root, key) + const stat = await fs.lstat(directory).catch(missing) + if (!stat) return + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error('Image cache entries must be directories, not symlinks.') + const retired = path.join(root, `.purestack-images-${randomUUID()}.trash`) + try { + await fs.rename(directory, retired) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + // Windows can report EPERM when another process has already moved the directory. + if ( + (error as NodeJS.ErrnoException).code === 'EPERM' && + !(await fs.lstat(directory).catch(missing)) + ) + return + throw error + } + await fs.rm(retired, { recursive: true, force: true }).catch(cleanupError) +} + +/** Only our expired entries are collected; a custom cache directory may contain other files. */ +export async function pruneImageCache( + root: string, + maxAgeDays: number, + activeKeys: ReadonlySet, +) { + if (maxAgeDays === 0) return + const cutoff = Date.now() - maxAgeDays * 86_400_000 + for (const entry of await fs.readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue + const directory = path.join(root, entry.name) + try { + if (temporaryName.test(entry.name)) { + const stat = await fs.lstat(directory) + if (stat.mtimeMs < cutoff) + await fs.rm(directory, { recursive: true, force: true }) + continue + } + if (!entryName.test(entry.name) || activeKeys.has(entry.name)) continue + const manifest = path.join(directory, 'manifest.json') + const stat = await fs.lstat(manifest) + if (!stat.isFile() || stat.size > 64 * 1024 || stat.mtimeMs >= cutoff) + continue + const value: unknown = JSON.parse(await fs.readFile(manifest, 'utf8')) + if ( + !isPlainObject(value) || + value.version !== 1 || + value.key !== entry.name || + !Array.isArray(value.variants) + ) + continue + // A concurrent cache hit may have renewed this entry while we read it. + if ((await fs.stat(manifest)).mtimeMs < cutoff) + await retireCacheEntry(root, entry.name) + } catch (error) { + if (error instanceof SyntaxError) continue + cleanupError(error) + } + } +} + +function missing(error: NodeJS.ErrnoException): undefined { + if (error.code !== 'ENOENT') throw error +} + +function cleanupError(error: unknown) { + // Another process can retire the entry or still hold a Windows file handle. + if ( + !['ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ENOTEMPTY'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) + throw error +} diff --git a/packages/ts-images/src/config.ts b/packages/ts-images/src/config.ts new file mode 100644 index 00000000..2150f887 --- /dev/null +++ b/packages/ts-images/src/config.ts @@ -0,0 +1,345 @@ +import { createHash } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import type { SiteConfig } from '@purestack/ts-common' +import { isPlainObject, merge } from '@purestack/ts-util' + +export type ImageFormat = 'avif' | 'webp' +export interface ImageQuality { + avif: number + webp: number + jpeg: number +} +export interface ImagePreset { + widths?: number[] + formats?: ImageFormat[] + quality?: Partial + sizes?: string + loading?: 'lazy' | 'eager' | false + decoding?: 'async' | 'sync' | 'auto' | false + priority?: boolean + lossless?: boolean + metadata?: 'strip' | 'preserve' +} +export interface ResolvedImagePreset + extends Required> { + quality: ImageQuality +} +export interface ImagesOptions extends ImagePreset { + enabled?: boolean + include?: string[] + exclude?: string[] + cacheDir?: string + /** Remove entries unused for this many days; zero disables automatic cleanup. */ + cacheMaxAgeDays?: number + concurrency?: number + maxInputPixels?: number + maxSourceBytes?: number + onError?: 'error' | 'warn' + presets?: Record +} +export interface ImagesConfig extends ResolvedImagePreset { + enabled: boolean + include: string[] + exclude: string[] + cacheDir: string + cacheMaxAgeDays: number + concurrency: number + maxInputPixels: number + maxSourceBytes: number + onError: 'error' | 'warn' + presets: Record +} + +declare module '@purestack/ts-common' { + interface SiteExtensions { + images?: ImagesConfig + } +} + +const defaults: ResolvedImagePreset = { + widths: [480, 960, 1440], + formats: ['avif', 'webp'], + quality: { avif: 50, webp: 80, jpeg: 82 }, + sizes: '100vw', + loading: 'lazy', + decoding: 'async', + priority: false, + lossless: false, + metadata: 'strip', +} +const builtInPresets: Record = { + content: {}, + hero: { widths: [960, 1440, 1920], priority: true, loading: 'eager' }, + thumbnail: { + widths: [160, 320, 640], + sizes: '(max-width: 640px) 50vw, 320px', + }, + screenshot: { lossless: true, formats: ['webp'] }, +} +const presetFields = [ + 'widths', + 'formats', + 'quality', + 'sizes', + 'loading', + 'decoding', + 'priority', + 'lossless', + 'metadata', +] + +export function resolveImagesConfig( + value: unknown, + site: Pick, +): ImagesConfig { + const input = object(value ?? {}, 'images') + fields( + input, + [ + ...presetFields, + 'enabled', + 'include', + 'exclude', + 'cacheDir', + 'cacheMaxAgeDays', + 'concurrency', + 'maxInputPixels', + 'maxSourceBytes', + 'onError', + 'presets', + ], + 'images', + ) + const preset = resolvePreset(input, defaults) + const rawPresets = object(input.presets ?? {}, 'images.presets') + for (const name of Object.keys(rawPresets)) { + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) + throw new Error(`Invalid images preset name "${name}".`) + } + const presets: Record = {} + for (const [name, options] of Object.entries( + merge(builtInPresets, rawPresets), + )) { + if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) + throw new Error(`Invalid images preset name "${name}".`) + const optionsObject = object(options, `images.presets.${name}`) + fields(optionsObject, presetFields, `images.presets.${name}`) + presets[name] = resolvePreset(optionsObject, preset) + } + const cacheDir = + input.cacheDir === undefined + ? path.join( + os.tmpdir(), + 'purestack-images', + createHash('sha256') + .update(path.resolve(site.contentDir)) + .digest('hex') + .slice(0, 24), + ) + : path.resolve(site.rootDir, string(input.cacheDir, 'images.cacheDir')) + if ( + within(site.contentDir, cacheDir) || + within(site.outDir, cacheDir) || + within(site.publishDir, cacheDir) + ) { + throw new Error( + 'images.cacheDir must be outside the content and output directories.', + ) + } + return { + ...preset, + enabled: boolean(input.enabled, true, 'images.enabled'), + include: patterns(input.include, ['**/*'], 'images.include'), + exclude: patterns(input.exclude, [], 'images.exclude'), + cacheDir, + cacheMaxAgeDays: integer( + input.cacheMaxAgeDays, + 30, + 0, + 36500, + 'images.cacheMaxAgeDays', + ), + concurrency: integer(input.concurrency, 2, 1, 16, 'images.concurrency'), + maxInputPixels: integer( + input.maxInputPixels, + 40_000_000, + 1, + 268_402_689, + 'images.maxInputPixels', + ), + maxSourceBytes: integer( + input.maxSourceBytes, + 50 * 1024 * 1024, + 1, + Number.MAX_SAFE_INTEGER, + 'images.maxSourceBytes', + ), + onError: choice( + input.onError, + ['error', 'warn'], + 'error', + 'images.onError', + ), + presets, + } +} + +function resolvePreset( + input: Record, + base: ResolvedImagePreset, +): ResolvedImagePreset { + const widths = input.widths ?? base.widths + if ( + !Array.isArray(widths) || + widths.length === 0 || + widths.length > 16 || + widths.some( + (value) => !Number.isInteger(value) || value < 1 || value > 16384, + ) + ) { + throw new Error( + 'images.widths must contain 1 to 16 integer widths between 1 and 16384.', + ) + } + const formats = input.formats ?? base.formats + if ( + !Array.isArray(formats) || + formats.some((format) => format !== 'avif' && format !== 'webp') + ) + throw new Error( + 'images.formats supports avif and webp; fallback formats are generated automatically.', + ) + const quality = object(input.quality ?? {}, 'images.quality') + fields(quality, ['avif', 'webp', 'jpeg'], 'images.quality') + return { + widths: [...new Set(widths)].sort((a, b) => a - b), + formats: [...new Set(formats)], + quality: { + avif: integer( + quality.avif, + base.quality.avif, + 1, + 100, + 'images.quality.avif', + ), + webp: integer( + quality.webp, + base.quality.webp, + 1, + 100, + 'images.quality.webp', + ), + jpeg: integer( + quality.jpeg, + base.quality.jpeg, + 1, + 100, + 'images.quality.jpeg', + ), + }, + sizes: + input.sizes === undefined + ? base.sizes + : string(input.sizes, 'images.sizes'), + loading: choice( + input.loading, + ['lazy', 'eager', false], + base.loading, + 'images.loading', + ), + decoding: choice( + input.decoding, + ['async', 'sync', 'auto', false], + base.decoding, + 'images.decoding', + ), + priority: boolean(input.priority, base.priority, 'images.priority'), + lossless: boolean(input.lossless, base.lossless, 'images.lossless'), + metadata: choice( + input.metadata, + ['strip', 'preserve'], + base.metadata, + 'images.metadata', + ), + } +} + +export function imagePreset( + config: ImagesConfig, + name: string | null, +): ResolvedImagePreset { + if (!name) return config + const preset = Object.hasOwn(config.presets, name) + ? config.presets[name] + : undefined + if (!preset) throw new Error(`Unknown image preset "${name}".`) + return preset +} + +function object(value: unknown, label: string): Record { + if (!isPlainObject(value)) throw new Error(`${label} must be an object.`) + return value +} +function fields( + input: Record, + allowed: string[], + label: string, +) { + for (const name of Object.keys(input)) + if (!allowed.includes(name)) + throw new Error(`Unknown ${label} setting "${name}".`) +} +function integer( + value: unknown, + fallback: number, + min: number, + max: number, + label: string, +) { + if (value === undefined) return fallback + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < min || + value > max + ) + throw new Error(`${label} must be an integer between ${min} and ${max}.`) + return value +} +function boolean(value: unknown, fallback: boolean, label: string) { + if (value === undefined) return fallback + if (typeof value !== 'boolean') throw new Error(`${label} must be a boolean.`) + return value +} +function string(value: unknown, label: string) { + if (typeof value !== 'string' || !value.trim()) + throw new Error(`${label} must be a non-empty string.`) + return value.trim() +} +function choice( + value: unknown, + allowed: readonly T[], + fallback: T, + label: string, +): T { + if (value === undefined) return fallback + if (!allowed.includes(value as T)) + throw new Error(`${label} must be one of ${allowed.join(', ')}.`) + return value as T +} +function patterns(value: unknown, fallback: string[], label: string) { + if (value === undefined) return [...fallback] + if (!Array.isArray(value)) + throw new Error(`${label} must be a list of glob patterns.`) + return value.map((value) => string(value, label).replaceAll('\\', '/')) +} +function within(root: string, candidate: string) { + const relative = path.relative(path.resolve(root), candidate) + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && + relative !== '..' && + !path.isAbsolute(relative)) + ) +} diff --git a/packages/ts-images/src/index.ts b/packages/ts-images/src/index.ts new file mode 100644 index 00000000..51d640bf --- /dev/null +++ b/packages/ts-images/src/index.ts @@ -0,0 +1,276 @@ +import path from 'node:path' +import type { + AssetSource, + PageAssetInput, + SiteAssets, + SiteConfig, + SiteConfigResolution, +} from '@purestack/ts-common' +import { isPlainObject, merge, urlNormalizer } from '@purestack/ts-util' +import { getLogger } from 'logpot' +import { type ImagesOptions, imagePreset, resolveImagesConfig } from './config' +import { ImageProcessor, type ProcessedImage } from './processor' + +export type { + ImageFormat, + ImagePreset, + ImageQuality, + ImagesConfig, + ImagesOptions, + ResolvedImagePreset, +} from './config' + +export interface ImageDiagnostic { + source: string + page: string + cache: 'hit' | 'miss' | 'shared' | 'skipped' + sourceBytes: number + outputBytes: number + variants: number + durationMs: number +} +export interface ImagesPluginOptions extends ImagesOptions { + onDiagnostic?: (diagnostic: ImageDiagnostic) => void +} + +/** Optional local raster optimization. No renderer, theme, or browser runtime dependency. */ +export function imagesPlugin(options: ImagesPluginOptions = {}) { + const { onDiagnostic, ...settings } = options + if (onDiagnostic !== undefined && typeof onDiagnostic !== 'function') + throw new Error('images.onDiagnostic must be a function.') + return { + name: 'purestack:images', + config: { + resolve({ config }: SiteConfigResolution) { + if ( + config.extensions.images !== undefined && + !isPlainObject(config.extensions.images) + ) + throw new Error('extensions.images must be an object.') + return { + extensions: { images: merge(settings, config.extensions.images) }, + } + }, + normalize({ config }: SiteConfigResolution) { + return { + extensions: { + images: resolveImagesConfig(config.extensions.images, config), + }, + } + }, + }, + assets(site: SiteConfig): SiteAssets | undefined { + const config = site.extensions.images + if (!config) + throw new Error('Images requires resolved extensions.images settings.') + if (!config.enabled) return undefined + const processor = new ImageProcessor(config, [ + site.contentDir, + site.outDir, + site.publishDir, + ]) + return { + namespace: 'images', + async processPage(input) { + const results = await Promise.allSettled( + imageElements(input.document).map(async (image) => { + const url = image.getAttribute('src') + if ( + !url || + insidePicture(image) || + image.hasAttribute('srcset') || + !selectedUrl(url) || + disabled(image) + ) + return + let source: AssetSource | undefined + try { + source = await input.resolveSource(image, url) + if (!source) return + const relPath = source.relPath + if ( + !config.include.some((pattern) => + path.posix.matchesGlob(relPath, pattern), + ) || + config.exclude.some((pattern) => + path.posix.matchesGlob(relPath, pattern), + ) + ) + return + const preset = imagePreset( + config, + image.getAttribute('data-image-preset'), + ) + const started = performance.now() + const result = await processor.process( + source, + preset, + (result) => + presentImage( + image, + input, + result, + preset, + urlNormalizer.splitSuffix(url).suffix, + ), + ) + if (!result) { + onDiagnostic?.({ + source: source.relPath, + page: input.sourceRelPath, + cache: 'skipped', + sourceBytes: 0, + outputBytes: 0, + variants: 0, + durationMs: performance.now() - started, + }) + return + } + const diagnostic: ImageDiagnostic = { + source: source.relPath, + page: input.sourceRelPath, + cache: result.cache, + sourceBytes: result.sourceBytes, + outputBytes: result.variants.reduce( + (sum, variant) => sum + variant.data.length, + 0, + ), + variants: result.variants.length, + durationMs: Math.round(performance.now() - started), + } + if (onDiagnostic) onDiagnostic(diagnostic) + else getLogger().info('image optimized', { ...diagnostic }) + } catch (error) { + if (config.onError === 'error') throw error + getLogger().warn('image optimization skipped', { + page: input.sourceRelPath, + source: source?.relPath ?? url, + error: error instanceof Error ? error.message : String(error), + }) + } + }), + ) + const failure = results.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') throw failure.reason + }, + } + }, + } +} + +function selectedUrl(url: string) { + if (urlNormalizer.isSpecialHref(url)) return false + return /\.(?:jpe?g|png|webp|avif|gif|tiff?)$/i.test( + urlNormalizer.splitSuffix(url).base, + ) +} + +function insidePicture(image: Element) { + for (let parent = image.parentElement; parent; parent = parent.parentElement) + if (parent.tagName === 'PICTURE') return true + return false +} + +function imageElements(parent: ParentNode): Element[] { + const images: Element[] = [] + for (const node of parent.childNodes) { + if (node.nodeType !== 1) continue + const element = node as Element + if (element.tagName === 'IMG') images.push(element) + images.push( + ...imageElements( + element.tagName === 'TEMPLATE' + ? (element as HTMLTemplateElement).content + : element, + ), + ) + } + return images +} +function disabled(image: Element) { + const value = image.getAttribute('data-image-optimize') + if (value !== null && value !== '' && value !== 'true' && value !== 'false') + throw new Error('data-image-optimize must be true or false.') + return value === 'false' +} + +async function presentImage( + image: Element, + input: PageAssetInput, + result: ProcessedImage, + preset: ReturnType, + suffix: string, +) { + const priorityValue = image.getAttribute('data-image-priority') + if (priorityValue !== null && !['', 'true', 'false'].includes(priorityValue)) + throw new Error('data-image-priority must be true or false.') + const emitted: Array = + [] + for (const variant of result.variants) + emitted.push({ + ...variant, + url: + (await input.emit(`${variant.hash}.${variant.file}`, variant.data)) + + suffix, + }) + const fallback = emitted.filter( + (variant) => variant.format === result.fallback, + ) + const largest = fallback[fallback.length - 1] + const sizes = image.getAttribute('sizes') ?? preset.sizes + const srcset = (variants: typeof emitted) => + variants.map((variant) => `${variant.url} ${variant.width}w`).join(', ') + const picture = input.document.createElement('picture') + for (const format of preset.formats) { + const source = input.document.createElement('source') + source.setAttribute('type', `image/${format}`) + source.setAttribute( + 'srcset', + srcset(emitted.filter((variant) => variant.format === format)), + ) + source.setAttribute('sizes', sizes) + picture.appendChild(source) + } + image.setAttribute('src', largest.url) + image.setAttribute('srcset', srcset(fallback)) + image.setAttribute('sizes', sizes) + const hasWidth = image.hasAttribute('width'), + hasHeight = image.hasAttribute('height') + if (!hasWidth && !hasHeight) { + image.setAttribute('width', String(result.width)) + image.setAttribute('height', String(result.height)) + } else if (hasWidth && !hasHeight) { + const width = Number(image.getAttribute('width')) + if (Number.isFinite(width) && width > 0) + image.setAttribute( + 'height', + String(Math.max(1, Math.round((width * result.height) / result.width))), + ) + } else if (!hasWidth && hasHeight) { + const height = Number(image.getAttribute('height')) + if (Number.isFinite(height) && height > 0) + image.setAttribute( + 'width', + String( + Math.max(1, Math.round((height * result.width) / result.height)), + ), + ) + } + const priority = + priorityValue === null + ? preset.priority || image.getAttribute('fetchpriority') === 'high' + : priorityValue !== 'false' + if (!image.hasAttribute('loading') && (priority || preset.loading)) + image.setAttribute('loading', priority ? 'eager' : String(preset.loading)) + if (!image.hasAttribute('decoding') && preset.decoding) + image.setAttribute('decoding', preset.decoding) + if (!image.hasAttribute('fetchpriority') && priority) + image.setAttribute('fetchpriority', 'high') + if (preset.formats.length > 0 && image.parentNode) { + image.parentNode.insertBefore(picture, image) + picture.appendChild(image) + } + image.removeAttribute('data-image-preset') + image.removeAttribute('data-image-priority') + image.removeAttribute('data-image-optimize') +} diff --git a/packages/ts-images/src/processor.test.ts b/packages/ts-images/src/processor.test.ts new file mode 100644 index 00000000..ca96f506 --- /dev/null +++ b/packages/ts-images/src/processor.test.ts @@ -0,0 +1,298 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import sharp from 'sharp' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { imagePreset, resolveImagesConfig } from './config' +import { ImageProcessor } from './processor' + +describe('image processor and persistent cache', () => { + let root: string + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'purestack-image-test-')) + }) + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(root, { recursive: true, force: true }) + }) + const config = (options = {}) => + resolveImagesConfig( + { + widths: [32, 64, 128], + formats: ['webp'], + cacheDir: path.join(root, 'cache'), + ...options, + }, + { + rootDir: root, + contentDir: path.join(root, 'content'), + outDir: path.join(root, 'out'), + publishDir: path.join(root, 'publish'), + }, + ) + const source = () => ({ + relPath: 'image.png', + publicPath: '/image.png', + absPath: path.join(root, 'image.png'), + }) + + it('generates bounded widths with transparent lossless fallback and reuses verified cache bytes', async () => { + const original = await sharp({ + create: { + width: 80, + height: 40, + channels: 4, + background: { r: 20, g: 40, b: 60, alpha: 0.4 }, + }, + }) + .png() + .toBuffer() + await fs.writeFile(source().absPath, original) + const settings = config({ lossless: true }) + const processor = new ImageProcessor(settings) + const first = await processor.process(source(), settings) + expect(first?.variants.map((variant) => variant.width)).toEqual([ + 32, 32, 64, 64, 80, 80, + ]) + expect(first?.cache).toBe('miss') + const fallback = first?.variants.find( + (variant) => variant.width === 80 && variant.format === 'png', + ) + if (!fallback) throw new Error('Expected PNG fallback') + expect((await sharp(fallback.data).metadata()).hasAlpha).toBe(true) + expect(await sharp(fallback.data).raw().toBuffer()).toEqual( + await sharp(original).raw().toBuffer(), + ) + const cached = await new ImageProcessor(settings).process( + source(), + settings, + ) + expect(cached?.cache).toBe('hit') + expect(cached?.variants.map((variant) => variant.hash)).toEqual( + first?.variants.map((variant) => variant.hash), + ) + const file = path.join(settings.cacheDir, first?.key ?? '', fallback.file) + // Same-size corruption must still be detected by content verification. + await fs.writeFile(file, Buffer.alloc(fallback.data.length)) + expect( + (await new ImageProcessor(settings).process(source(), settings))?.cache, + ).toBe('miss') + }) + + it('rotates before stripping EXIF and derives oriented dimensions', async () => { + await sharp({ + create: { width: 80, height: 40, channels: 3, background: '#123456' }, + }) + .jpeg() + .withMetadata({ orientation: 6 }) + .toFile(source().absPath) + const settings = config({ widths: [32], metadata: 'strip' }) + const result = await new ImageProcessor(settings).process( + source(), + settings, + ) + expect([result?.width, result?.height]).toEqual([40, 80]) + for (const variant of result?.variants ?? []) { + const metadata = await sharp(variant.data).metadata() + expect([metadata.width, metadata.height]).toEqual([32, 64]) + expect(metadata.orientation).toBeUndefined() + expect(metadata.exif).toBeUndefined() + } + }) + + it('keeps animation intact rather than silently taking its first frame', async () => { + const frames = Buffer.alloc(16 * 32 * 4, 255) + frames.fill(0, 0, 16 * 16 * 4) + await sharp(frames, { + raw: { width: 16, height: 32, pageHeight: 16, channels: 4 }, + }) + .gif({ delay: [100, 200], loop: 0 }) + .toFile(source().absPath) + expect( + (await sharp(await fs.readFile(source().absPath)).metadata()).pages, + ).toBe(2) + const settings = config() + expect( + await new ImageProcessor(settings).process(source(), settings), + ).toBeUndefined() + }) + + it('detects APNG before libvips can flatten it', async () => { + const png = await sharp({ + create: { width: 10, height: 10, channels: 3, background: 'red' }, + }) + .png() + .toBuffer() + const animationChunk = Buffer.alloc(20) + animationChunk.writeUInt32BE(8, 0) + animationChunk.write('acTL', 4) + animationChunk.writeUInt32BE(2, 8) + const ihdrEnd = 8 + 12 + png.readUInt32BE(8) + await fs.writeFile( + source().absPath, + Buffer.concat([ + png.subarray(0, ihdrEnd), + animationChunk, + png.subarray(ihdrEnd), + ]), + ) + const settings = config() + expect( + await new ImageProcessor(settings).process(source(), settings), + ).toBeUndefined() + }) + + it('limits concurrent encoding and emission work, and releases slots after errors', async () => { + await sharp({ + create: { width: 32, height: 16, channels: 3, background: 'blue' }, + }) + .png() + .toFile(source().absPath) + const settings = config({ concurrency: 2, formats: [], widths: [16] }) + const processor = new ImageProcessor(settings) + let active = 0, + peak = 0 + let release: (() => void) | undefined + const barrier = new Promise((resolve) => { + release = resolve + }) + let began: (() => void) | undefined + const started = new Promise((resolve) => { + began = resolve + }) + const work = Array.from({ length: 4 }, () => + processor.process(source(), settings, async () => { + active++ + peak = Math.max(peak, active) + if (active === 2) began?.() + await barrier + active-- + }), + ) + await started + expect(peak).toBe(2) + release?.() + await Promise.all(work) + await expect( + processor.process(source(), settings, async () => { + throw new Error('Consumer failed') + }), + ).rejects.toThrow('Consumer failed') + expect((await processor.process(source(), settings))?.cache).toBe('hit') + }) + + it('uses pixel settings for cache keys while allowing markup-only changes to reuse results', async () => { + await sharp({ + create: { width: 64, height: 32, channels: 3, background: 'green' }, + }) + .png() + .toFile(source().absPath) + const settings = config() + const first = await new ImageProcessor(settings).process(source(), settings) + const markup = { ...settings, sizes: '50vw', loading: 'eager' as const } + expect( + (await new ImageProcessor(markup).process(source(), markup))?.key, + ).toBe(first?.key) + const pixels = { ...settings, quality: { ...settings.quality, webp: 30 } } + expect( + (await new ImageProcessor(pixels).process(source(), pixels))?.key, + ).not.toBe(first?.key) + expect(imagePreset(settings, 'screenshot').lossless).toBe(true) + }) + + it('rejects input byte and pixel limits with the source path', async () => { + await sharp({ + create: { width: 64, height: 32, channels: 3, background: 'green' }, + }) + .png() + .toFile(source().absPath) + const bytes = config({ maxSourceBytes: 10 }) + await expect( + new ImageProcessor(bytes).process(source(), bytes), + ).rejects.toThrow(/image.png.*maxSourceBytes/) + const pixels = config({ maxInputPixels: 16 }) + await expect( + new ImageProcessor(pixels).process(source(), pixels), + ).rejects.toThrow(/image.png/) + }) + + it('rejects a cache alias into content before writing any cache entries', async () => { + const content = path.join(root, 'content') + const alias = path.join(root, 'cache-alias') + await fs.mkdir(content) + await fs.symlink( + content, + alias, + process.platform === 'win32' ? 'junction' : 'dir', + ) + await sharp({ + create: { width: 8, height: 8, channels: 3, background: 'red' }, + }) + .png() + .toFile(source().absPath) + const settings = config({ cacheDir: path.join(alias, 'images') }) + await expect( + new ImageProcessor(settings, [content]).process(source(), settings), + ).rejects.toThrow('including symlink targets') + expect(await fs.readdir(content)).toEqual([]) + }) + + it('renews cache hits and prunes expired variants as source images change', async () => { + const settings = config({ widths: [16], formats: [], cacheMaxAgeDays: 1 }) + const write = (background: string) => + sharp({ create: { width: 32, height: 16, channels: 3, background } }) + .png() + .toFile(source().absPath) + await write('red') + const first = await new ImageProcessor(settings).process(source(), settings) + if (!first) throw new Error('Expected image') + const manifest = path.join(settings.cacheDir, first.key, 'manifest.json') + const old = new Date(Date.now() - 3 * 86_400_000) + await fs.utimes(manifest, old, old) + // A requested entry is reused and renewed even if its prior age exceeded the limit. + expect( + (await new ImageProcessor(settings).process(source(), settings))?.cache, + ).toBe('hit') + expect((await fs.stat(manifest)).mtimeMs).toBeGreaterThan(old.getTime()) + await fs.utimes(manifest, old, old) + await write('blue') + const next = await new ImageProcessor(settings).process(source(), settings) + expect(next?.key).not.toBe(first.key) + await expect(fs.stat(manifest)).rejects.toMatchObject({ code: 'ENOENT' }) + expect( + (await new ImageProcessor(settings).process(source(), settings))?.cache, + ).toBe('hit') + }) + + it('publishes complete entries when independent processors share a cache, including corruption repair', async () => { + await sharp({ + create: { width: 32, height: 16, channels: 3, background: 'green' }, + }) + .png() + .toFile(source().absPath) + const settings = config({ widths: [16], formats: ['webp'] }) + const processTogether = () => + Promise.all( + Array.from({ length: 4 }, () => + new ImageProcessor(settings).process(source(), settings), + ), + ) + const first = await processTogether() + expect(new Set(first.map((value) => value?.key)).size).toBe(1) + const value = first[0] + if (!value) throw new Error('Expected image') + await fs.writeFile( + path.join(settings.cacheDir, value.key, value.variants[0].file), + Buffer.alloc(value.variants[0].data.length), + ) + const repaired = await processTogether() + for (const result of repaired) + expect(result?.variants.map((variant) => variant.hash)).toEqual( + value.variants.map((variant) => variant.hash), + ) + expect( + (await new ImageProcessor(settings).process(source(), settings))?.cache, + ).toBe('hit') + expect(await fs.readdir(settings.cacheDir)).toEqual([value.key]) + }) +}) diff --git a/packages/ts-images/src/processor.ts b/packages/ts-images/src/processor.ts new file mode 100644 index 00000000..15506a98 --- /dev/null +++ b/packages/ts-images/src/processor.ts @@ -0,0 +1,465 @@ +import { createHash, randomUUID } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { setTimeout } from 'node:timers/promises' +import type { AssetSource } from '@purestack/ts-common' +import { isPlainObject } from '@purestack/ts-util' +import type sharpType from 'sharp' +import { pruneImageCache, retireCacheEntry } from './cache' +import type { ImagesConfig, ResolvedImagePreset } from './config' + +type OutputFormat = 'avif' | 'webp' | 'jpeg' | 'png' +export interface ImageVariant { + file: string + format: OutputFormat + width: number + height: number + data: Buffer + hash: string +} +export interface ProcessedImage { + key: string + width: number + height: number + fallback: OutputFormat + variants: ImageVariant[] + sourceBytes: number + cache: 'hit' | 'miss' | 'shared' +} +interface CachedVariant { + file: string + format: OutputFormat + width: number + height: number + hash: string + bytes: number +} +interface CacheManifest { + version: number + key: string + variants: CachedVariant[] +} + +class WorkQueue { + private active = 0 + private readonly waiting: Array<() => void> = [] + constructor(private readonly limit: number) {} + async run(work: () => Promise): Promise { + if (this.active >= this.limit) + await new Promise((resolve) => this.waiting.push(resolve)) + else this.active++ + try { + return await work() + } finally { + const next = this.waiting.shift() + if (next) next() + else this.active-- + } + } +} + +/** Per-site work limits and promises; cache entries are portable pixel results. */ +export class ImageProcessor { + private readonly queue: WorkQueue + private readonly inFlight = new Map>() + private backend: Promise | undefined + private cacheRoot: Promise | undefined + private cacheMaintenance: Promise | undefined + private nextCacheMaintenance = 0 + + constructor( + private readonly config: ImagesConfig, + private readonly protectedDirectories: readonly string[] = [], + ) { + this.queue = new WorkQueue(config.concurrency) + } + + process( + source: AssetSource, + preset: ResolvedImagePreset, + consume?: (image: ProcessedImage) => Promise, + ): Promise { + return this.queue + .run(async () => { + const result = await this.read(source, preset) + if (result && consume) await consume(result) + return result + }) + .catch((error) => { + throw new Error( + `Image "${source.relPath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + }) + } + + private async read( + source: AssetSource, + preset: ResolvedImagePreset, + ): Promise { + const stat = await fs.stat(source.absPath) + if (!stat.isFile() || stat.size > this.config.maxSourceBytes) + throw new Error( + `Image "${source.relPath}" exceeds images.maxSourceBytes or is not a file.`, + ) + const data = await fs.readFile(source.absPath) + if (data.length > this.config.maxSourceBytes) + throw new Error( + `Image "${source.relPath}" exceeds images.maxSourceBytes.`, + ) + if (animatedPng(data)) return undefined + this.backend ??= import('sharp').then((module) => module.default) + const sharp = await this.backend + const metadata = await sharp(data, { + limitInputPixels: this.config.maxInputPixels, + failOn: 'warning', + }).metadata() + if ( + (metadata.pages ?? 1) > 1 || + !['jpeg', 'png', 'webp', 'avif', 'heif', 'gif', 'tiff'].includes( + metadata.format, + ) + ) + return undefined + const { width, height } = metadata.autoOrient + if (!width || !height) + throw new Error(`Could not read dimensions for "${source.relPath}".`) + const fallback: OutputFormat = + preset.lossless || + metadata.hasAlpha || + metadata.format === 'png' || + metadata.format === 'gif' + ? 'png' + : 'jpeg' + const maxWidth = Math.min(width, preset.widths[preset.widths.length - 1]) + const widths = [ + ...preset.widths.filter((value) => value < maxWidth), + maxWidth, + ] + const formats: OutputFormat[] = [...preset.formats, fallback] + const pixels = { + widths, + formats, + quality: preset.quality, + lossless: preset.lossless, + metadata: preset.metadata, + } + const key = digest( + Buffer.concat([ + data, + Buffer.from( + JSON.stringify({ schema: 1, pixels, versions: sharp.versions }), + ), + ]), + ) + const existing = this.inFlight.get(key) + if (existing) return { ...(await existing), cache: 'shared' } + const task = this.produce( + sharp, + data, + key, + width, + height, + widths, + formats, + fallback, + preset, + ) + this.inFlight.set(key, task) + try { + return await task + } finally { + this.inFlight.delete(key) + } + } + + private async produce( + sharp: typeof sharpType, + data: Buffer, + key: string, + width: number, + height: number, + widths: number[], + formats: OutputFormat[], + fallback: OutputFormat, + preset: ResolvedImagePreset, + ): Promise { + this.cacheRoot ??= this.prepareCache().catch((error) => { + this.cacheRoot = undefined + throw error + }) + const root = await this.cacheRoot + if (!this.cacheMaintenance && Date.now() >= this.nextCacheMaintenance) { + this.nextCacheMaintenance = Date.now() + 3_600_000 + this.cacheMaintenance = pruneImageCache( + root, + this.config.cacheMaxAgeDays, + new Set([...this.inFlight.keys(), key]), + ) + .catch((error) => { + this.nextCacheMaintenance = 0 + throw error + }) + .finally(() => { + this.cacheMaintenance = undefined + }) + } + await this.cacheMaintenance + const directory = path.join(root, key) + const directoryStat = await optionalStat(directory) + if (directoryStat?.isSymbolicLink()) + throw new Error('Image cache entries must not be symlinks.') + const expected = widths.flatMap((outputWidth) => + formats.map((format) => ({ + width: outputWidth, + height: Math.max(1, Math.round((height * outputWidth) / width)), + format, + file: fileName(outputWidth, format), + })), + ) + const cached = await readCache(directory, key, expected) + if (cached) { + const now = new Date() + await fs + .utimes(path.join(directory, 'manifest.json'), now, now) + .catch((error) => { + if (error.code !== 'ENOENT') throw error + }) + return { + key, + width, + height, + fallback, + variants: cached, + sourceBytes: data.length, + cache: 'hit', + } + } + const variants: ImageVariant[] = [] + for (const item of expected) { + let pipeline = sharp(data, { + limitInputPixels: this.config.maxInputPixels, + failOn: 'warning', + }) + .autoOrient() + .resize({ width: item.width, withoutEnlargement: true }) + .toColorspace('srgb') + if (preset.metadata === 'preserve') + pipeline = pipeline.withMetadata({ orientation: 1 }) + if (item.format === 'avif') + pipeline = pipeline.avif({ + quality: preset.quality.avif, + lossless: preset.lossless, + effort: 4, + }) + else if (item.format === 'webp') + pipeline = pipeline.webp({ + quality: preset.quality.webp, + lossless: preset.lossless, + effort: 4, + }) + else if (item.format === 'jpeg') + pipeline = pipeline.jpeg({ + quality: preset.quality.jpeg, + mozjpeg: true, + }) + else pipeline = pipeline.png({ compressionLevel: 9, palette: false }) + const result = await pipeline + .timeout({ seconds: 30 }) + .toBuffer({ resolveWithObject: true }) + variants.push({ + file: item.file, + format: item.format, + width: result.info.width, + height: result.info.height, + data: result.data, + hash: digest(result.data), + }) + } + const manifest: CacheManifest = { + version: 1, + key, + variants: variants.map((variant) => ({ + file: variant.file, + format: variant.format, + width: variant.width, + height: variant.height, + bytes: variant.data.length, + hash: variant.hash, + })), + } + // Encoding happens outside the cache. Publish only a complete immutable entry, + // so pruning and other builds cannot interrupt individual variant writes. + const temporary = path.join(root, `.purestack-images-${randomUUID()}.tmp`) + await fs.mkdir(temporary) + try { + for (const variant of variants) + await fs.writeFile(path.join(temporary, variant.file), variant.data) + await fs.writeFile( + path.join(temporary, 'manifest.json'), + JSON.stringify(manifest), + ) + for (let attempt = 0; attempt < 3; attempt++) { + try { + await fs.rename(temporary, directory) + break + } catch (error) { + if ( + !['EEXIST', 'ENOTEMPTY', 'EPERM'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) + throw error + // Another build may have published the same key while we encoded. + if (await readCache(directory, key, expected)) break + const stat = await optionalStat(directory) + if (stat?.isSymbolicLink()) + throw new Error('Image cache entries must not be symlinks.') + try { + await retireCacheEntry(root, key) + } catch (error) { + if ( + !['EPERM', 'EACCES', 'EBUSY'].includes( + (error as NodeJS.ErrnoException).code ?? '', + ) + ) + throw error + // Windows readers can briefly hold the old directory open. Cache + // publication is best effort; the encoded output is already complete. + await setTimeout(10 * (attempt + 1)) + } + } + } + } finally { + await fs.rm(temporary, { recursive: true, force: true }) + } + return { + key, + width, + height, + fallback, + variants, + sourceBytes: data.length, + cache: 'miss', + } + } + + private async prepareCache() { + const directory = await canonicalPath(this.config.cacheDir) + for (const protectedDirectory of this.protectedDirectories) { + const root = await canonicalPath(protectedDirectory) + const relative = path.relative(root, directory) + if ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ) + throw new Error( + 'images.cacheDir must be outside the content and output directories, including symlink targets.', + ) + } + await fs.mkdir(directory, { recursive: true }) + return directory + } +} + +async function canonicalPath(directory: string): Promise { + try { + return await fs.realpath(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = path.dirname(directory) + if (parent === directory) throw error + return path.join(await canonicalPath(parent), path.basename(directory)) + } +} + +async function optionalStat(file: string) { + return fs.lstat(file).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined + throw error + }) +} + +function fileName(width: number, format: OutputFormat) { + return `${width}.${format === 'jpeg' ? 'jpg' : format}` +} +function digest(data: Uint8Array) { + return createHash('sha256').update(data).digest('hex') +} + +async function readCache( + directory: string, + key: string, + expected: Array<{ + file: string + width: number + height: number + format: OutputFormat + }>, +): Promise { + try { + const manifestStat = await optionalStat( + path.join(directory, 'manifest.json'), + ) + if (!manifestStat?.isFile() || manifestStat.size > 64 * 1024) + return undefined + const raw: unknown = JSON.parse( + await fs.readFile(path.join(directory, 'manifest.json'), 'utf8'), + ) + if ( + !isPlainObject(raw) || + raw.version !== 1 || + raw.key !== key || + !Array.isArray(raw.variants) || + raw.variants.length !== expected.length + ) + return undefined + const variants: ImageVariant[] = [] + for (const [index, item] of expected.entries()) { + const entry: unknown = raw.variants[index] + if ( + !isPlainObject(entry) || + entry.file !== item.file || + entry.format !== item.format || + entry.width !== item.width || + entry.height !== item.height || + typeof entry.hash !== 'string' + ) + return undefined + // Use computed names, never paths supplied by a cache manifest. + const file = path.join(directory, item.file) + const stat = await optionalStat(file) + if (!stat?.isFile() || stat.size !== entry.bytes) return undefined + const data = await fs.readFile(file) + if (data.length !== entry.bytes || digest(data) !== entry.hash) + return undefined + variants.push({ ...item, height: entry.height, data, hash: entry.hash }) + } + return variants + } catch (error) { + if ( + error instanceof SyntaxError || + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) + return undefined + throw error + } +} + +/** libvips treats APNG as a still PNG; detect its animation control chunk first. */ +function animatedPng(data: Buffer) { + if ( + !data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ) + return false + for (let offset = 8; offset + 12 <= data.length; ) { + const length = data.readUInt32BE(offset) + const type = data.toString('ascii', offset + 4, offset + 8) + if (type === 'acTL') return true + if (type === 'IDAT' || length > data.length - offset - 12) return false + offset += length + 12 + } + return false +} diff --git a/packages/ts-images/tsconfig.json b/packages/ts-images/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-images/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-minidom/package.json b/packages/ts-minidom/package.json index b51d1290..2f3b379f 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-minidom/src/minidom.test.ts b/packages/ts-minidom/src/minidom.test.ts index c0ba6c58..71144ebb 100644 --- a/packages/ts-minidom/src/minidom.test.ts +++ b/packages/ts-minidom/src/minidom.test.ts @@ -2,6 +2,29 @@ import { describe, expect, it } from 'vitest' import { parseFragment } from './minidom' describe('parseFragment', () => { + it.each([ + '/images/photo.png', + './photo.png', + 'https://example.com/photo.png', + '/images/', + ])('preserves slashes in unquoted attribute values: %s', (url) => { + const fragment = parseFragment(`Photo`) + expect(fragment.firstElementChild?.getAttribute('src')).toBe(url) + expect(fragment.firstElementChild?.getAttribute('alt')).toBe('Photo') + expect(fragment.firstElementChild?.attributes.length).toBe(2) + }) + + it('treats a trailing slash as part of an unquoted value and keeps explicit self-closing syntax', () => { + expect( + parseFragment('').firstElementChild?.getAttribute( + 'src', + ), + ).toBe('/image.png/') + const fragment = parseFragment('

After

') + expect(fragment.childNodes.length).toBe(2) + expect(fragment.firstElementChild?.getAttribute('src')).toBe('/image.png') + }) + it.each([ ['&lt;', '<'], ['&lt;', '<'], diff --git a/packages/ts-minidom/src/minidom.ts b/packages/ts-minidom/src/minidom.ts index 33d5846f..b2803698 100644 --- a/packages/ts-minidom/src/minidom.ts +++ b/packages/ts-minidom/src/minidom.ts @@ -1676,14 +1676,9 @@ function parseAttributes(input: string, start: number) { i = endQuote === -1 ? input.length : endQuote + 1 } else { const valueStart = i - while (i < input.length && /[^\s>/]/.test(input[i])) i += 1 + // In an unquoted value, '/' is URL data, including directly before '>'. + while (i < input.length && /[^\s>]/.test(input[i])) i += 1 value = decodeEntities(input.slice(valueStart, i)) - if (input[i] === '/' && input[i + 1] === '>') { - selfClosing = true - i += 2 - setParsedAttribute(attrs, name, value) - break - } } } if (nameStart === i) { diff --git a/packages/ts-page-scripts/README.md b/packages/ts-page-scripts/README.md index 6b9f8fb9..ee393980 100644 --- a/packages/ts-page-scripts/README.md +++ b/packages/ts-page-scripts/README.md @@ -35,10 +35,6 @@ 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. +The auth, consent, and analytics plugins own their corresponding configuration defaults and validation. Automatic GA4 registration runs in `config.finalize`, after every plugin's `config.normalize` phase and before factories or config hooks initialize. `resolveSiteConfig()` includes these plugins by default; pass `{ defaultPlugins: false, plugins: [...] }` as its second argument to choose them explicitly. Omitting the analytics plugin omits automatic GA4 registration. Analytics still respects explicitly configured consent when the consent runtime plugin is omitted. 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 8e74968c..d988aa77 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.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-page-scripts/src/analyticsConfig.ts b/packages/ts-page-scripts/src/analyticsConfig.ts new file mode 100644 index 00000000..4c979c92 --- /dev/null +++ b/packages/ts-page-scripts/src/analyticsConfig.ts @@ -0,0 +1,52 @@ +import type { AnalyticsConfig, Ga4Config } from '@purestack/ts-common' +import type { DeepPartial } from '@purestack/ts-util' +import { resolveOptionalString, resolveString } from '@purestack/ts-util' + +export function resolveAnalyticsConfig( + input?: DeepPartial, + file?: DeepPartial, +): AnalyticsConfig { + const ga4 = resolveGa4Config(input?.ga4, file?.ga4) + return { ga4 } +} + +function resolveGa4Config( + input?: DeepPartial, + file?: DeepPartial, +): Ga4Config { + const measurementId = resolveGa4MeasurementId( + input?.measurementId, + file?.measurementId, + ) + const enabled = input?.enabled ?? file?.enabled ?? Boolean(measurementId) + if (enabled && !measurementId) { + throw new Error( + 'analytics.ga4.enabled is true but analytics.ga4.measurementId is missing.', + ) + } + return { + enabled, + measurementId, + serviceId: resolveString(input?.serviceId, file?.serviceId, 'ga4'), + consentCategory: resolveString( + input?.consentCategory, + file?.consentCategory, + 'analytics', + ), + } +} + +function resolveGa4MeasurementId(...values: Array) { + for (const value of values) { + const raw = resolveOptionalString(value) + if (!raw) continue + const normalized = raw.toUpperCase() + if (!/^G-[A-Z0-9]+$/.test(normalized)) { + throw new Error( + `analytics.ga4.measurementId must look like "G-XXXXXXXX" but got "${raw}".`, + ) + } + return normalized + } + return undefined +} diff --git a/packages/ts-page-scripts/src/analyticsPlugin.ts b/packages/ts-page-scripts/src/analyticsPlugin.ts index 14bc855a..326116b1 100644 --- a/packages/ts-page-scripts/src/analyticsPlugin.ts +++ b/packages/ts-page-scripts/src/analyticsPlugin.ts @@ -3,33 +3,64 @@ import type { ConsentService, Ga4Config, PageTemplateInput, + SiteConfig, SiteConfigResolution, } from '@purestack/ts-common' import { h } from '@purestack/ts-html' +import { merge } from '@purestack/ts-util' +import { resolveAnalyticsConfig } from './analyticsConfig' import { buildGa4Script } from './buildGa4Script' +import { resolveConsentConfig } from './consentConfig' /** Owns direct GA4 scripts and its optional consent-managed service. */ export function analyticsPlugin() { return { name: 'purestack:analytics', config: { - finalize({ config }: SiteConfigResolution) { + resolve({ config, input, file }: SiteConfigResolution) { return { - consent: { - services: config.consent.enabled - ? appendGa4ConsentService( - config.consent.services, - config.consent.categories, - config.analytics.ga4, - ) - : config.consent.services, - }, + analytics: merge( + resolveAnalyticsConfig(input.analytics, file.analytics), + config.analytics, + ), + // Respect configured consent even when its browser runtime is omitted. + ...(!config.consent && (input.consent || file.consent) + ? { consent: resolveConsentConfig(input.consent, file.consent) } + : {}), + } + }, + normalize({ config }: SiteConfigResolution) { + return { + analytics: resolveAnalyticsConfig(config.analytics), + ...(config.consent + ? { consent: resolveConsentConfig(config.consent) } + : {}), + } + }, + finalize({ config }: SiteConfigResolution) { + const analytics = config.analytics + const consent = config.consent + return { + ...(consent + ? { + consent: { + ...consent, + services: consent.enabled + ? appendGa4ConsentService( + consent.services, + consent.categories, + analytics?.ga4, + ) + : consent.services, + }, + } + : {}), } }, }, head({ head, site }: PageTemplateInput) { - const ga4 = site.analytics.ga4 - if (ga4.enabled && ga4.measurementId && !site.consent.enabled) { + 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 }), diff --git a/packages/ts-page-scripts/src/authConfig.ts b/packages/ts-page-scripts/src/authConfig.ts new file mode 100644 index 00000000..8b90e608 --- /dev/null +++ b/packages/ts-page-scripts/src/authConfig.ts @@ -0,0 +1,18 @@ +import type { AuthConfig } from '@purestack/ts-common' +import type { DeepPartial } from '@purestack/ts-util' +import { pickBoolean, resolveString } from '@purestack/ts-util' + +export function resolveAuthConfig( + input?: DeepPartial, + file?: DeepPartial, +): AuthConfig { + return { + enabled: pickBoolean(input?.enabled, file?.enabled, false), + signUp: pickBoolean(input?.signUp, file?.signUp, true), + signedInStorageKey: resolveString( + input?.signedInStorageKey, + file?.signedInStorageKey, + 'signed-in-hint', + ), + } +} diff --git a/packages/ts-page-scripts/src/authStateHintPlugin.ts b/packages/ts-page-scripts/src/authStateHintPlugin.ts index 8c900700..70f1715e 100644 --- a/packages/ts-page-scripts/src/authStateHintPlugin.ts +++ b/packages/ts-page-scripts/src/authStateHintPlugin.ts @@ -1,12 +1,27 @@ -import type { PageTemplateInput } from '@purestack/ts-common' +import type { + PageTemplateInput, + SiteConfigResolution, +} from '@purestack/ts-common' import { h } from '@purestack/ts-html' +import { merge } from '@purestack/ts-util' +import { resolveAuthConfig } from './authConfig' import { buildAuthStateHintScript } from './buildAuthStateHintScript' export function authStateHintPlugin() { return { name: 'purestack:auth-state-hint', + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + auth: merge(resolveAuthConfig(input.auth, file.auth), config.auth), + } + }, + normalize({ config }: SiteConfigResolution) { + return { auth: resolveAuthConfig(config.auth) } + }, + }, head({ head, site }: PageTemplateInput) { - if (site.auth.enabled && site.auth.signedInStorageKey) { + if (site.auth?.enabled && site.auth.signedInStorageKey) { const hint = buildAuthStateHintScript(site.auth.signedInStorageKey) if (hint) { const script = h('script').raw(hint) diff --git a/packages/ts-page-scripts/src/consentConfig.ts b/packages/ts-page-scripts/src/consentConfig.ts new file mode 100644 index 00000000..9e5090cb --- /dev/null +++ b/packages/ts-page-scripts/src/consentConfig.ts @@ -0,0 +1,232 @@ +import type { + ConsentCategory, + ConsentConfig, + ConsentScript, + ConsentService, +} from '@purestack/ts-common' +import type { DeepPartial } from '@purestack/ts-util' +import { + isPlainObject, + resolveConfigUrl, + resolveOptionalString, + resolveString, +} from '@purestack/ts-util' + +const DEFAULT_CONSENT_CATEGORIES: ConsentCategory[] = [ + { + id: 'necessary', + label: 'Necessary', + description: 'Required for core site functionality.', + required: true, + }, + { + id: 'preferences', + label: 'Preferences', + description: 'Stores optional settings like personalized UX behavior.', + }, + { + id: 'analytics', + label: 'Analytics', + description: 'Helps improve the site by measuring usage.', + }, + { + id: 'marketing', + label: 'Marketing', + description: 'Used for advertising and campaign attribution.', + }, +] + +export function resolveConsentConfig( + input?: DeepPartial, + file?: DeepPartial, +): ConsentConfig { + const enabled = input?.enabled ?? file?.enabled ?? false + const categories = resolveConsentCategories( + input?.categories, + file?.categories, + ) + const services = resolveConsentServices( + input?.services, + file?.services, + categories, + ) + return { + enabled, + storageKey: resolveString( + input?.storageKey, + file?.storageKey, + 'ts-ssg-consent', + ), + policyVersion: resolveString( + input?.policyVersion, + file?.policyVersion, + '1', + ), + bannerTitle: resolveString( + input?.bannerTitle, + file?.bannerTitle, + 'Your privacy choices', + ), + bannerDescription: resolveString( + input?.bannerDescription, + file?.bannerDescription, + 'We use cookies and similar technologies to improve your experience. You can accept all, reject non-essential, or manage preferences.', + ), + privacyPolicyUrl: resolveConfigUrl( + resolveOptionalString(input?.privacyPolicyUrl ?? file?.privacyPolicyUrl), + ), + privacyPolicyLabel: resolveString( + input?.privacyPolicyLabel, + file?.privacyPolicyLabel, + 'Privacy Policy', + ), + acceptAllLabel: resolveString( + input?.acceptAllLabel, + file?.acceptAllLabel, + 'Accept all', + ), + rejectAllLabel: resolveString( + input?.rejectAllLabel, + file?.rejectAllLabel, + 'Reject non-essential', + ), + manageLabel: resolveString( + input?.manageLabel, + file?.manageLabel, + 'Manage preferences', + ), + saveLabel: resolveString(input?.saveLabel, file?.saveLabel, 'Save choices'), + settingsLabel: resolveString( + input?.settingsLabel, + file?.settingsLabel, + 'Privacy settings', + ), + categories, + services, + } +} + +function resolveConsentCategories( + input: Array> | undefined, + file: Array> | undefined, +) { + const source = Array.isArray(input) + ? input + : Array.isArray(file) + ? file + : DEFAULT_CONSENT_CATEGORIES + const categories: ConsentCategory[] = [] + const seen = new Set() + for (const entry of source) { + if (!isPlainObject(entry)) continue + const id = resolveOptionalString(entry.id) + if (!id) continue + if (seen.has(id)) { + throw new Error(`Duplicate consent category id "${id}".`) + } + const required = entry.required === true || id === 'necessary' + const label = resolveString(entry.label, toTitleCase(id)) + categories.push({ + id, + label, + description: resolveOptionalString(entry.description), + required, + }) + seen.add(id) + } + if (!seen.has('necessary')) { + categories.unshift({ + id: 'necessary', + label: 'Necessary', + description: 'Required for core site functionality.', + required: true, + }) + } + return categories +} + +function resolveConsentServices( + input: Array> | undefined, + file: Array> | undefined, + categories: ConsentCategory[], +) { + const source = Array.isArray(input) ? input : Array.isArray(file) ? file : [] + const categoryIds = new Set(categories.map((entry) => entry.id)) + const services: ConsentService[] = [] + const seenServiceIds = new Set() + for (const entry of source) { + if (!isPlainObject(entry)) continue + const id = resolveOptionalString(entry.id) + if (!id) continue + if (seenServiceIds.has(id)) { + throw new Error(`Duplicate consent service id "${id}".`) + } + const category = resolveOptionalString(entry.category) + if (!category || !categoryIds.has(category)) { + throw new Error( + `Consent service "${id}" references unknown category "${entry.category ?? ''}".`, + ) + } + const scripts = resolveConsentServiceScripts(entry.scripts, id) + services.push({ + id, + category, + ga4MeasurementId: resolveOptionalString(entry.ga4MeasurementId), + label: resolveOptionalString(entry.label), + description: resolveOptionalString(entry.description), + scripts, + }) + seenServiceIds.add(id) + } + return services +} + +function resolveConsentServiceScripts( + scripts: Array> | undefined, + serviceId: string, +) { + if (!Array.isArray(scripts) || scripts.length === 0) { + throw new Error( + `Consent service "${serviceId}" must define at least one script.`, + ) + } + const resolved: ConsentScript[] = [] + for (const entry of scripts) { + if (!isPlainObject(entry)) continue + const src = resolveConfigUrl(resolveOptionalString(entry.src)) + const content = resolveOptionalString(entry.content) + if (!src && !content) { + throw new Error( + `Consent service "${serviceId}" has a script without "src" or "content".`, + ) + } + resolved.push({ + src, + content, + type: resolveOptionalString(entry.type), + async: entry.async === true, + defer: entry.defer === true, + integrity: resolveOptionalString(entry.integrity), + nonce: resolveOptionalString(entry.nonce), + crossOrigin: resolveCrossOrigin(entry.crossOrigin), + referrerPolicy: resolveOptionalString(entry.referrerPolicy), + }) + } + if (resolved.length === 0) { + throw new Error(`Consent service "${serviceId}" has no valid scripts.`) + } + return resolved +} + +function resolveCrossOrigin( + value: unknown, +): 'anonymous' | 'use-credentials' | undefined { + if (value === 'anonymous' || value === 'use-credentials') return value + return undefined +} + +function toTitleCase(value: string) { + const normalized = value.replaceAll(/[-_]+/g, ' ').trim() + if (normalized.length === 0) return 'Consent' + return normalized.replaceAll(/\b\w/g, (char) => char.toUpperCase()) +} diff --git a/packages/ts-page-scripts/src/consentPlugin.ts b/packages/ts-page-scripts/src/consentPlugin.ts index 555ed919..8f4b79c7 100644 --- a/packages/ts-page-scripts/src/consentPlugin.ts +++ b/packages/ts-page-scripts/src/consentPlugin.ts @@ -1,12 +1,30 @@ -import type { PageTemplateInput } from '@purestack/ts-common' +import type { + PageTemplateInput, + SiteConfigResolution, +} from '@purestack/ts-common' import { h } from '@purestack/ts-html' +import { merge } from '@purestack/ts-util' import { buildConsentScript } from './buildConsentScript' +import { resolveConsentConfig } from './consentConfig' export function consentPlugin() { return { name: 'purestack:consent', + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + consent: merge( + resolveConsentConfig(input.consent, file.consent), + config.consent, + ), + } + }, + normalize({ config }: SiteConfigResolution) { + return { consent: resolveConsentConfig(config.consent) } + }, + }, head({ head, site }: PageTemplateInput) { - if (site.consent.enabled) + if (site.consent?.enabled) head.push(h('script').raw(buildConsentScript(site.consent))) return head }, diff --git a/packages/ts-page-scripts/src/plugin.ts b/packages/ts-page-scripts/src/plugin.ts index 0fe56057..a0e7a552 100644 --- a/packages/ts-page-scripts/src/plugin.ts +++ b/packages/ts-page-scripts/src/plugin.ts @@ -42,7 +42,7 @@ function renderScriptsHead({ if (pageInfo.frontmatter.layout.showNav !== false) head.push(h('script').raw(buildNavMenuScript())) head.push(h('script').raw(buildCodeCopyScript())) - if (config.pagefind.enabled) { + if (config.pagefind?.enabled) { if (config.basePath) head.push( h('script').raw( diff --git a/packages/ts-pagefind/README.md b/packages/ts-pagefind/README.md index 4007e345..ec145464 100644 --- a/packages/ts-pagefind/README.md +++ b/packages/ts-pagefind/README.md @@ -25,3 +25,5 @@ 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. + +The plugin owns `pagefind` defaults, exclusion-path normalization, and validation. Omitting it leaves `config.pagefind` absent unless another plugin contributes it; the standard browser scripts then omit Pagefind search initialization. diff --git a/packages/ts-pagefind/package.json b/packages/ts-pagefind/package.json index b41be1be..6fcb24a4 100644 --- a/packages/ts-pagefind/package.json +++ b/packages/ts-pagefind/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-pagefind", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-pagefind.mjs", "types": "./dist/ts-pagefind.d.mts", @@ -40,6 +40,7 @@ }, "dependencies": { "@purestack/ts-common": "workspace:^", + "@purestack/ts-util": "workspace:^", "logpot": "^1.0.7", "pagefind": "^1.4.0" } diff --git a/packages/ts-pagefind/src/config.ts b/packages/ts-pagefind/src/config.ts new file mode 100644 index 00000000..438c38b6 --- /dev/null +++ b/packages/ts-pagefind/src/config.ts @@ -0,0 +1,33 @@ +import type { PagefindConfig } from '@purestack/ts-common' + +export function resolvePagefindConfig( + input?: Partial, + file?: Partial, +): PagefindConfig { + return { + enabled: input?.enabled ?? file?.enabled ?? true, + excludePaths: normalizePagefindExcludePaths( + input?.excludePaths ?? file?.excludePaths, + ), + } +} + +function normalizePagefindExcludePaths(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const unique = new Set() + for (const entry of value) { + if (typeof entry !== 'string') continue + const trimmed = entry.trim() + if (!trimmed) continue + const normalized = normalizeExcludePath(trimmed) + unique.add(normalized) + } + return [...unique] +} + +function normalizeExcludePath(pathname: string): string { + const normalized = pathname.replaceAll('\\', '/').trim() + if (normalized === '/') return '/' + const withLeading = normalized.startsWith('/') ? normalized : `/${normalized}` + return withLeading.endsWith('/') ? withLeading : `${withLeading}/` +} diff --git a/packages/ts-pagefind/src/index.ts b/packages/ts-pagefind/src/index.ts index 6155323b..32f34710 100644 --- a/packages/ts-pagefind/src/index.ts +++ b/packages/ts-pagefind/src/index.ts @@ -1,4 +1,10 @@ -import type { SiteConfig, SiteSearch } from '@purestack/ts-common' +import type { + SiteConfig, + SiteConfigResolution, + SiteSearch, +} from '@purestack/ts-common' +import { merge } from '@purestack/ts-util' +import { resolvePagefindConfig } from './config' import { buildPagefindIndex } from './pagefind' export { type BuildPagefindResult, buildPagefindIndex } from './pagefind' @@ -7,16 +13,27 @@ export { type BuildPagefindResult, buildPagefindIndex } from './pagefind' export function pagefindPlugin() { return { name: 'purestack:pagefind', + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + pagefind: merge( + resolvePagefindConfig(input.pagefind, file.pagefind), + config.pagefind, + ), + } + }, + normalize({ config }: SiteConfigResolution) { + return { pagefind: resolvePagefindConfig(config.pagefind) } + }, + }, search(config: SiteConfig): SiteSearch { + const pagefind = config.pagefind + if (!pagefind) + throw new Error('Pagefind requires resolved search settings.') return { ownsPath: (pathname) => pathname.startsWith('/pagefind/'), async write({ outDir, excludeFiles, onlyFiles }) { - await buildPagefindIndex( - outDir, - config.pagefind, - excludeFiles, - onlyFiles, - ) + await buildPagefindIndex(outDir, pagefind, excludeFiles, onlyFiles) }, } }, diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index ac1487a4..e359a95a 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-seo/README.md b/packages/ts-seo/README.md index 8aca2d3e..ad7647c2 100644 --- a/packages/ts-seo/README.md +++ b/packages/ts-seo/README.md @@ -26,3 +26,5 @@ in `@purestack/ts-ssg`. `resolvePreviewConfig(frontmatter, options)` is also exported for custom metadata plugins. It returns metadata defaults without applying explicit `head` overrides. + +The plugin owns site `preview` defaults and normalization, including trimmed strings and positive integer image dimensions. Omitting it leaves `config.preview` absent unless another plugin contributes it. diff --git a/packages/ts-seo/package.json b/packages/ts-seo/package.json index fc9de86e..ab53fd28 100644 --- a/packages/ts-seo/package.json +++ b/packages/ts-seo/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-seo", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-seo.mjs", "types": "./dist/ts-seo.d.mts", diff --git a/packages/ts-seo/src/config.ts b/packages/ts-seo/src/config.ts new file mode 100644 index 00000000..d90bd4fa --- /dev/null +++ b/packages/ts-seo/src/config.ts @@ -0,0 +1,34 @@ +import type { PreviewConfig } from '@purestack/ts-common' +import type { DeepPartial } from '@purestack/ts-util' +import { resolveOptionalString } from '@purestack/ts-util' + +export function resolvePreviewOptions( + input?: DeepPartial, + file?: DeepPartial, +): PreviewConfig { + return { + title: resolveOptionalString(input?.title ?? file?.title), + description: resolveOptionalString(input?.description ?? file?.description), + image: resolveOptionalString(input?.image ?? file?.image), + imageAlt: resolveOptionalString(input?.imageAlt ?? file?.imageAlt), + imageWidth: normalizePreviewImageDimension( + input?.imageWidth ?? file?.imageWidth, + ), + imageHeight: normalizePreviewImageDimension( + input?.imageHeight ?? file?.imageHeight, + ), + siteName: resolveOptionalString(input?.siteName ?? file?.siteName), + type: resolveOptionalString(input?.type ?? file?.type), + locale: resolveOptionalString(input?.locale ?? file?.locale), + twitterCard: resolveOptionalString(input?.twitterCard ?? file?.twitterCard), + twitterSite: resolveOptionalString(input?.twitterSite ?? file?.twitterSite), + twitterCreator: resolveOptionalString( + input?.twitterCreator ?? file?.twitterCreator, + ), + } +} + +function normalizePreviewImageDimension(value: unknown) { + if (typeof value !== 'number' || !Number.isInteger(value)) return undefined + return value > 0 ? value : undefined +} diff --git a/packages/ts-seo/src/index.ts b/packages/ts-seo/src/index.ts index e0cdbcf8..1e1c3c89 100644 --- a/packages/ts-seo/src/index.ts +++ b/packages/ts-seo/src/index.ts @@ -1,4 +1,9 @@ -import type { PageHeadConfigInput } from '@purestack/ts-common' +import type { + PageHeadConfigInput, + SiteConfigResolution, +} from '@purestack/ts-common' +import { merge } from '@purestack/ts-util' +import { resolvePreviewOptions } from './config' import { resolvePreviewConfig } from './preview' export { type PreviewHeadOptions, resolvePreviewConfig } from './preview' @@ -7,6 +12,19 @@ export { type PreviewHeadOptions, resolvePreviewConfig } from './preview' export function seoPlugin() { return { name: 'purestack:seo', + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + preview: merge( + resolvePreviewOptions(input.preview, file.preview), + config.preview, + ), + } + }, + normalize({ config }: SiteConfigResolution) { + return { preview: resolvePreviewOptions(config.preview) } + }, + }, headConfig({ site, pageInfo }: PageHeadConfigInput) { return resolvePreviewConfig(pageInfo.frontmatter, { siteTitle: site.siteTitle, diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index bf11fbaf..fa6f355c 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.15", + "version": "1.1.16", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg-vscode/src/componentResolver.test.ts b/packages/ts-ssg-vscode/src/componentResolver.test.ts index cd523ff9..1dc0ceff 100644 --- a/packages/ts-ssg-vscode/src/componentResolver.test.ts +++ b/packages/ts-ssg-vscode/src/componentResolver.test.ts @@ -1,7 +1,16 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' import { clearComponentResolverCaches, getComponentSuggestions, @@ -9,6 +18,11 @@ import { resolveComponentTarget, } from './componentResolver' +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFileSync: vi.fn(actual.readFileSync) } +}) + const dependencyPackagePath = path.resolve( 'node_modules', '@purestack', @@ -40,6 +54,7 @@ describe('resolveComponentTarget dependency fallback', () => { afterAll(() => { if (!isolatedWorkspaceRoot) return + clearComponentResolverCaches(isolatedWorkspaceRoot) fs.rmSync(isolatedWorkspaceRoot, { force: true, recursive: true }) }) @@ -79,6 +94,127 @@ describe('resolveComponentTarget dependency fallback', () => { ?.source, ).toBe('dependency') }) + + it('reads each dependency source once for cold completion and performs no reads for cached completion', () => { + clearComponentResolverCaches(isolatedWorkspaceRoot) + const read = vi.mocked(fs.readFileSync) + read.mockClear() + const suggestions = getComponentSuggestions(isolatedWorkspaceRoot) + const packageRoot = path.join( + isolatedWorkspaceRoot, + 'node_modules', + '@purestack', + 'ts-components', + ) + const sourceReads = read.mock.calls + .map(([file]) => String(file)) + .filter( + (file) => + file.startsWith(`${packageRoot}${path.sep}`) && file.endsWith('.ts'), + ) + + expect(suggestions.some((item) => item.componentName === 'Btn')).toBe(true) + expect(sourceReads.length).toBeGreaterThan(0) + expect(sourceReads.length).toBe(new Set(sourceReads).size) + read.mockClear() + expect(getComponentSuggestions(isolatedWorkspaceRoot)).toBe(suggestions) + expect(read).not.toHaveBeenCalled() + }) +}) + +describe('dependency completion semantics', () => { + let root = '', + packageRoot = '' + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'purestack-completion-')) + packageRoot = path.join(root, 'node_modules', 'example-components') + fs.mkdirSync(path.join(packageRoot, 'src'), { recursive: true }) + fs.mkdirSync(path.join(root, 'src')) + fs.writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: 'example-components', + regorComponents: [ + 'Alpha', + 'beta-kebab', + 'PrivateOnly', + 'Missing', + 'Gamma', + ], + }), + ) + fs.writeFileSync( + path.join(packageRoot, 'src', 'a.ts'), + [ + 'export interface Alpha {}', + 'export interface BetaKebab {}', + 'interface PrivateOnly {}', + 'export interface Helper {}', + ].join('\n'), + ) + fs.writeFileSync( + path.join(packageRoot, 'src', 'z.ts'), + ['export interface Gamma {}', 'export interface BetaKebab {}'].join('\n'), + ) + fs.writeFileSync( + path.join(root, 'src', 'local.ts'), + [ + 'export interface Alpha {}', + 'const alpha = defineComponent()', + ].join('\n'), + ) + }) + afterEach(() => { + clearComponentResolverCaches(root) + fs.rmSync(root, { recursive: true, force: true }) + }) + + it('preserves workspace precedence, normalized manifest filtering, exported-only suggestions, and first-file locations', () => { + const suggestions = getComponentSuggestions(root) + expect(suggestions.map((item) => item.componentName)).toEqual([ + 'Alpha', + 'BetaKebab', + 'Gamma', + ]) + expect(suggestions[0]).toMatchObject({ + source: 'workspace', + filePath: path.join(root, 'src', 'local.ts'), + line: 0, + }) + expect(suggestions[1]).toEqual({ + componentName: 'BetaKebab', + source: 'dependency', + filePath: fs.realpathSync.native(path.join(packageRoot, 'src', 'a.ts')), + line: 1, + }) + expect(suggestions[2]).toEqual({ + componentName: 'Gamma', + source: 'dependency', + filePath: fs.realpathSync.native(path.join(packageRoot, 'src', 'z.ts')), + line: 0, + }) + expect(resolveComponentTarget(root, 'beta-kebab')).toEqual({ + filePath: suggestions[1].filePath, + line: suggestions[1].line, + }) + }) + + it('refreshes dependency sources and manifest names when caches are invalidated', () => { + const initial = getComponentSuggestions(root) + const manifestPath = path.join(packageRoot, 'package.json') + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + manifest.regorComponents.push('NewComponent') + fs.writeFileSync(manifestPath, JSON.stringify(manifest)) + fs.writeFileSync( + path.join(packageRoot, 'src', 'new.ts'), + 'export interface NewComponent {}', + ) + expect(getComponentSuggestions(root)).toBe(initial) + clearComponentResolverCaches(root) + expect( + getComponentSuggestions(root).map((item) => item.componentName), + ).toContain('NewComponent') + }) }) describe('resolveComponentTarget monorepo package workspace', () => { diff --git a/packages/ts-ssg-vscode/src/componentResolver.ts b/packages/ts-ssg-vscode/src/componentResolver.ts index 7aadccfa..698ff6d2 100644 --- a/packages/ts-ssg-vscode/src/componentResolver.ts +++ b/packages/ts-ssg-vscode/src/componentResolver.ts @@ -219,17 +219,26 @@ function collectComponentSuggestions(workspaceRoot: string) { for (const dependencyPackage of getDependencyComponentPackages( workspaceRoot, )) { - for (const normalizedName of dependencyPackage.componentNames) { - if (seenNames.has(normalizedName)) continue - - const target = resolveDependencyComponentSuggestion( - dependencyPackage, - normalizedName, - ) - if (!target) continue - - seenNames.add(normalizedName) - suggestions.push(target) + // Read each source once, rather than searching every file again for every + // registered name. This preserves first-file precedence and manifest filtering. + for (const filePath of dependencyPackage.files) { + let realPath: string | undefined + for (const definition of findComponentDefinitionsInFile(filePath)) { + const normalizedName = normalizeComponentName(definition.name) + if ( + seenNames.has(normalizedName) || + !dependencyPackage.componentNames.has(normalizedName) + ) + continue + seenNames.add(normalizedName) + realPath ??= resolveRealPath(filePath) + suggestions.push({ + componentName: definition.name, + filePath: realPath, + line: definition.line, + source: 'dependency', + }) + } } } @@ -238,28 +247,6 @@ function collectComponentSuggestions(workspaceRoot: string) { ) } -function resolveDependencyComponentSuggestion( - dependencyPackage: DependencyComponentPackage, - normalizedComponentName: string, -): ComponentSuggestion | undefined { - for (const filePath of dependencyPackage.files) { - const definition = findComponentDefinitionInFile( - filePath, - normalizedComponentName, - ) - if (!definition) continue - - return { - componentName: definition.name, - filePath: resolveRealPath(filePath), - line: definition.line, - source: 'dependency', - } - } - - return undefined -} - function resolveRealPath(filePath: string) { try { return fs.realpathSync.native(filePath) diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 3b9e15ea..4af47238 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -338,7 +338,19 @@ Auth hints, consent, and analytics are independent plugins in `ts-page-scripts`. 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): +To choose the integrations explicitly, set `defaultPlugins: false` in `siteConfig.json` and list the plugins you want: + +```json +{ + "defaultPlugins": false, + "plugins": [ + "@purestack/ts-theme#themePlugin", + "@purestack/ts-components#componentsPlugin" + ] +} +``` + +The same selection is available in `purestack.config.ts` (or `options.defaultPlugins` programmatically): ```ts import { componentsPlugin, defineConfig, themePlugin } from '@purestack/ts-ssg' @@ -404,15 +416,83 @@ Plugins apply in order: - **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. +- **Assets** processors inspect the completed page document, resolve local sources, and emit owned files. Core tracks their dependencies and output ownership across full builds and lazy development requests. 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: { … } }`. +### Optional image optimization + +Install `@purestack/ts-images` and list it in `siteConfig.json` to optimize local raster images. It is not included in the default set and no TypeScript config is required: + +```json +{ + "plugins": ["@purestack/ts-images#imagesPlugin"], + "extensions": { + "images": { "widths": [480, 960, 1440] } + } +} +``` + +For callbacks or custom factory defaults, use `purestack.config.ts`: + +```ts +import { defineConfig } from '@purestack/ts-ssg' +import { imagesPlugin } from '@purestack/ts-images' + +export default defineConfig({ + plugins: [imagesPlugin({ widths: [480, 960, 1440] })], +}) +``` + +Rendered images receive responsive AVIF/WebP sources, JPEG/PNG fallbacks, dimensions, and loading defaults. Author attributes are preserved, and original assets remain available. Remote images, SVGs, animations, and existing responsive markup pass through. See the [image plugin README](../ts-images/README.md) for presets, opt-outs, caching, limits, and diagnostics. + +### Asset processors + +An optional `assets(config)` factory returns a `SiteAssets` processor, or `undefined` to disable it. Each processor declares a unique lowercase `namespace` (letters, digits, `_`, and `-`) and a `processPage(input)` function. Processors run in plugin order after all `onPageDocument` hooks and before content URLs are rewritten. Their state belongs to one site. + +The factory creates one processor per build/session. A page awaits each processor in order, but lazy requests for different pages may invoke the same processor concurrently. Keep page-local state inside the call and coordinate shared queues or caches inside the processor. A config reload creates new processors. Full-build completion hooks do not run for individual lazy requests. + +`input.document` is the rendered DOM, including component output. `await input.resolveSource(element, url)` resolves local assets using shared-content provenance and locale fallback, records source dependencies, and returns `{ relPath, absPath, publicPath }`. It returns `undefined` for external URLs. Sources must remain inside the content root. `input.dependOn(relPath)` records additional content-relative dependencies, including missing files that may appear later. + +`await input.emit(fileName, bytes)` writes a `Uint8Array` under `/_assets//` and returns an encoded root-relative URL. Use immutable, content-addressed filenames: emitting different bytes under the same name is an error. Core applies the site's base path during URL rewriting. Await all emission work before `processPage` returns, and inspect template fragments explicitly when relevant; `querySelectorAll` does not traverse their content. + +Core verifies existing output bytes on first use in each build/session. Repeated emissions reuse successful verification while file identity, size, and modification/change timestamps remain unchanged; changed files are checked again and repaired when necessary. Different-sized files are replaced without reading their obsolete contents. Symlink checks still run for every emission, and pruning or a new build clears the verification state. + +Input methods expire when their processor call completes. Outstanding source-resolution or emission work fails the page; core waits for it to settle before cleanup. Saved handles cannot register dependencies or emit files later. Later processors see earlier DOM changes, but `resolveSource` resolves original content files only: an emitted URL is not implicitly added to the source inventory. Combine dependent transforms inside one processor when they need intermediate bytes. + +Core persists per-page dependencies and output references in the build manifest. Source edits invalidate dependent pages. Shared outputs remain until their final referencing page changes or disappears; failed renders release pending outputs. Removing a processor cleans up its recorded outputs. Source assets are retained, and generated outputs cannot overwrite them. Do not write these files directly from hooks: `emit` supplies ownership, safe paths, and atomic writes. + +Core registers actual compiled-script output paths, including cache-busted names, and rejects collisions regardless of which producer runs first. Processor instances, receipts, and ownership operations are private builder state; lifecycle hooks receive the public build context and page result. + ### 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. +`siteConfig.json` can select installed plugins through a `plugins` array. Use `package#exportName` to select a named JavaScript export, or just `package` for its default export. Package subpaths work too: `package/subpath#exportName`. Each selected export must be a plugin object or a no-argument factory; factories may be asynchronous and run once per builder/session. A package can expose several plugins without a default export: + +```json +{ + "plugins": [ + "@purestack/ts-page-scripts#consentPlugin", + "@purestack/ts-page-scripts#analyticsPlugin" + ] +} +``` + +The suffix is the exported symbol's name, not the returned plugin's `name`. A missing export is an error. Repeating a selection (including `package` together with `package#default`) or selecting different exports with the same plugin name is an error. Install packages in the site project; the loader never downloads them. Both JSON declarations and TypeScript config imports use PureStack's own copy of a package when available, keeping shared registries together; other packages resolve from the site. Each plugin still owns its configuration namespace, for example `extensions.images`. + +Plugin declarations execute installed JavaScript with the build process's permissions; treat changes to `plugins` as code changes, including when other site settings are edited through a CMS. + +JSON declarations compose with the standard plugins and `purestack.config.ts`. An explicit TypeScript/programmatic instance replaces a JSON-selected plugin with the same `name`. The JSON `defaultPlugins` flag defaults to `true`; set it to `false` to disable the automatic set and use only declared or explicitly supplied plugins. This also works without a `plugins` array. An explicit TypeScript/programmatic `defaultPlugins` value overrides the JSON flag. Build, publish, the programmatic builder, and the dev server all use this selection path. Changes to `siteConfig.json` recreate the dev builder and its middleware; restart the server after updating an installed package. + +The synchronous `resolveSiteConfig` helper normalizes settings using its explicitly supplied plugin instances and `options.defaultPlugins`; it does not apply JSON plugin selection (`plugins` or `defaultPlugins`) or load `purestack.config.ts`. + +`resolveSiteConfig(input)` returns `DefaultSiteConfig` when using the untouched standard plugin set. Supplying `options.plugins` returns `SiteConfig`, even with defaults enabled: entries can replace standard plugins by name and remove their configuration guarantees. + +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 available, while plugin sections and nested extension values may still be partial. Every selected owner's normalizer runs before `finalize`, whose `SiteConfigFinalizer` receives normalized `SiteConfig`. Factories also receive normalized settings. Type augmentation describes the owning plugin's settings; it does not install that plugin. A plugin consuming another owner's settings must select that owner or validate those inputs itself. + ```json { "extensions": { "brand": { "label": "Acme" } } } ``` @@ -448,7 +528,7 @@ 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. 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. diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 74e61b29..aabaa171 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", @@ -36,7 +36,7 @@ "LICENSE" ], "scripts": { - "build": "tsc", + "build": "tsc && tsc -p tsconfig.contracts.json", "lint": "biome check ./src --formatter-enabled=false --write", "test": "vitest" }, @@ -70,6 +70,8 @@ "unified": "^11.0.5" }, "devDependencies": { - "@purestack/ts-svg-icons": "workspace:^" + "@purestack/ts-images": "workspace:^", + "@purestack/ts-svg-icons": "workspace:^", + "sharp": "^0.35.5" } } diff --git a/packages/ts-ssg/schema/siteConfig.schema.json b/packages/ts-ssg/schema/siteConfig.schema.json index ebe727c7..d6589515 100644 --- a/packages/ts-ssg/schema/siteConfig.schema.json +++ b/packages/ts-ssg/schema/siteConfig.schema.json @@ -4,6 +4,21 @@ "type": "object", "additionalProperties": false, "properties": { + "defaultPlugins": { + "type": "boolean", + "default": true, + "description": "Include the standard plugin set. Set to false and list the plugins you want in plugins. An explicit defaultPlugins option in TypeScript/programmatic configuration takes precedence." + }, + "plugins": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*(?:/[a-zA-Z0-9._-]+)*(?:#[a-zA-Z_$][a-zA-Z0-9_$]*)?$", + "not": { "pattern": "(?:^|/)\\.{1,2}(?:/|#|$)" } + }, + "uniqueItems": true, + "description": "Installed plugin package names (optionally with a package subpath and #exportName). Omit #exportName to select the default export. Each selected export must be a plugin object or a no-argument factory. Multiple plugins can be selected from one package. Package and package#default select the same export and cannot both be declared; the build checks these aliases and duplicate plugin names. Settings remain in the owning plugin's configuration namespace." + }, "extensions": { "type": "object", "additionalProperties": true, @@ -919,9 +934,8 @@ "properties": { "highlighter": { "type": "string", - "enum": ["shiki", "highlightjs"], - "description": "Syntax highlighter backend. Use highlightjs for faster builds.", - "default": "highlightjs" + "minLength": 1, + "description": "Backend identifier selected and validated by the installed highlighting plugin." }, "disableHighlighter": { "type": "boolean", diff --git a/packages/ts-ssg/src/build/asset-receipt.ts b/packages/ts-ssg/src/build/asset-receipt.ts new file mode 100644 index 00000000..42e66164 --- /dev/null +++ b/packages/ts-ssg/src/build/asset-receipt.ts @@ -0,0 +1,10 @@ +/** Internal, serializable ownership records stored in the build manifest. */ +export interface GeneratedAssetReference { + namespace: string + path: string +} + +export interface PageAssetReceipt { + dependencies: string[] + outputs: GeneratedAssetReference[] +} diff --git a/packages/ts-ssg/src/build/build-config.test.ts b/packages/ts-ssg/src/build/build-config.test.ts index 2da60791..425e5bee 100644 --- a/packages/ts-ssg/src/build/build-config.test.ts +++ b/packages/ts-ssg/src/build/build-config.test.ts @@ -1,5 +1,6 @@ import { resolveThemeFileName, resolveThemeHref } from '@purestack/ts-style' import { describe, expect, it } from 'vitest' +import { createDefaultPlugins } from '../plugins/default-plugins' import { resolveBuildSiteConfig } from './build-config' describe('resolveBuildSiteConfig', () => { @@ -16,8 +17,10 @@ describe('resolveBuildSiteConfig', () => { }, publish: { enabled: publish }, } - const first = resolveBuildSiteConfig(input) - const second = resolveBuildSiteConfig(input) + const first = resolveBuildSiteConfig(input, createDefaultPlugins()) + const second = resolveBuildSiteConfig(input, createDefaultPlugins()) + if (!first.style || !second.style) + throw new Error('Expected theme settings') expect(first.style.fileName).toBe('site.css') expect(second.style.fileName).toBe(first.style.fileName) expect(first.style.href).toBe(input.siteConfig.style.href) @@ -31,42 +34,48 @@ describe('resolveBuildSiteConfig', () => { it('applies publish overrides above site config values', () => { const publishDir = `${process.cwd()}-publish` - const config = resolveBuildSiteConfig({ - siteConfig: { - rootDir: process.cwd(), - outDir: `${process.cwd()}-dev`, - publishDir, - html: { - minify: false, + const config = resolveBuildSiteConfig( + { + siteConfig: { + rootDir: process.cwd(), + outDir: `${process.cwd()}-dev`, + publishDir, + html: { + minify: false, + }, + style: { + pretty: true, + }, }, - style: { - pretty: true, + publish: { + enabled: true, }, }, - publish: { - enabled: true, - }, - }) + createDefaultPlugins(), + ) expect(config.outDir).toBe(publishDir) expect(config.html.minify).toBe(true) - expect(config.style.pretty).toBe(false) + expect(config.style?.pretty).toBe(false) }) it('keeps regular build config values when publish is not enabled', () => { - const config = resolveBuildSiteConfig({ - siteConfig: { - rootDir: process.cwd(), - html: { - minify: false, - }, - style: { - pretty: true, + const config = resolveBuildSiteConfig( + { + siteConfig: { + rootDir: process.cwd(), + html: { + minify: false, + }, + style: { + pretty: true, + }, }, }, - }) + createDefaultPlugins(), + ) expect(config.html.minify).toBe(false) - expect(config.style.pretty).toBe(true) + expect(config.style?.pretty).toBe(true) }) }) diff --git a/packages/ts-ssg/src/build/build-config.ts b/packages/ts-ssg/src/build/build-config.ts index 1ba62020..0444b09f 100644 --- a/packages/ts-ssg/src/build/build-config.ts +++ b/packages/ts-ssg/src/build/build-config.ts @@ -1,9 +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 { SiteConfig, SiteConfigInput } from '@purestack/ts-common' +import { resolveSelectedSiteConfig } from '../config/config' import type { PureStackPlugin } from '../plugins/plugin' -import { assertValidPlugins, withPluginSkins } from '../plugins/plugin' import type { BuildInput } from './site' export interface PublishOptions { @@ -11,12 +8,14 @@ export interface PublishOptions { } export function resolveBuildSiteConfig( - input: BuildInput = {}, - plugins: readonly PureStackPlugin[] = resolveBuildPlugins(input.options), + input: BuildInput, + plugins: readonly PureStackPlugin[], + file?: SiteConfigInput, ): SiteConfig { - assertValidPlugins(plugins) - const config = withPluginSkins(plugins, () => - resolveSiteConfig(input.siteConfig, resolvePluginConfigResolvers(plugins)), + const config = resolveSelectedSiteConfig( + input.siteConfig ?? {}, + plugins, + file, ) if (input.publish?.enabled !== true) return config return { @@ -26,9 +25,13 @@ export function resolveBuildSiteConfig( ...config.html, minify: true, }, - style: { - ...config.style, - pretty: false, - }, + ...(config.style + ? { + style: { + ...config.style, + pretty: false, + }, + } + : {}), } } diff --git a/packages/ts-ssg/src/build/content-urls.ts b/packages/ts-ssg/src/build/content-urls.ts index fd4cea06..c2af9b68 100644 --- a/packages/ts-ssg/src/build/content-urls.ts +++ b/packages/ts-ssg/src/build/content-urls.ts @@ -1,6 +1,10 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' -import { isTypeScriptAssetPath, urlNormalizer } from '@purestack/ts-util' +import { + isTypeScriptAssetPath, + stripBasePath, + urlNormalizer, +} from '@purestack/ts-util' import { CONTENT_EXTS, isContentExt } from '../discover/contentExtensions' import type { ResolvedContentFile } from '../i18n/content' import { resolveRouteInfo } from '../routing/route' @@ -34,6 +38,9 @@ export class ContentRouteIndex { get pages() { return [...this.pagesByRelPath.values()] } + get assets() { + return [...this.assetRelPaths] + } withPages(pages: readonly ResolvedContentFile[]) { return new ContentRouteIndex(pages, this.assetRelPaths) @@ -110,9 +117,7 @@ export function resolveContentUrl( if (isAbsolute && !isContentLink) return url const source = toPosixPath(sourceRelPath) - const targetRelPath = isAbsolute - ? resolveAbsoluteTargetRelPath(source, urlPath, config) - : path.posix.join(path.posix.dirname(source), decodePath(urlPath)) + const targetRelPath = contentTargetRelPath(source, urlPath, config) if (escapesContentRoot(targetRelPath)) { throw new Error( `Content link "${url}" in "${source}" escapes the content root.`, @@ -133,6 +138,53 @@ export function resolveContentUrl( ) } +/** Resolves a local asset with the same source-relative and locale fallback rules as page URLs. */ +export function resolveContentAsset( + url: string, + sourceRelPath: string, + routes: ContentRouteIndex, + config: SiteConfig, + dependOn: (relPath: string) => void, +): { relPath: string; publicPath: string } | undefined { + if (!url || url.trim() !== url || urlNormalizer.isSpecialHref(url)) + return undefined + const { base, suffix } = urlNormalizer.splitSuffix(url) + const urlPath = toPosixPath(stripBasePath(config.basePath, base)) + const source = toPosixPath(sourceRelPath) + // Root-absolute asset URLs are site-wide, matching resolveContentUrl's pass-through. + const target = urlPath.startsWith('/') + ? path.posix.normalize(decodePath(urlPath).replace(/^\/+/, '')) + : contentTargetRelPath(source, urlPath, config) + if (escapesContentRoot(target)) + throw new Error(`Asset "${url}" in "${source}" escapes the content root.`) + dependOn(target) + const found = urlPath.startsWith('/') + ? routes.hasAsset(target) + ? target + : undefined + : findWithLocaleFallback(target, source, config, (relPath) => + routes.hasAsset(relPath) ? relPath : undefined, + ) + if (!found) { + if (urlPath.startsWith('/')) return undefined + throw new Error( + `Asset "${url}" in "${source}" points to a missing file "${target}".`, + ) + } + dependOn(found) + return { relPath: found, publicPath: `/${found}${suffix}` } +} + +function contentTargetRelPath( + source: string, + urlPath: string, + config?: SiteConfig, +) { + return urlPath.startsWith('/') + ? resolveAbsoluteTargetRelPath(source, urlPath, config) + : path.posix.join(path.posix.dirname(source), decodePath(urlPath)) +} + function findPublicPath( routes: ContentRouteIndex, targetRelPath: string, diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 9ca8ec35..db734161 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -19,12 +19,14 @@ import { import { resolveOutPath } from '../out-path' import { type BuildContext, + type PageRenderResult, renderPageFromFile, resolveHeaderFooterHtml, resolvePagePartials, writePage, writePageError, } from '../page' +import type { PageAssetManager } from '../page-assets' import type { BuildHooks } from '../site' import { discoverSiteContent, @@ -36,6 +38,7 @@ import { import type { IncrementalBuildResult } from './types' interface IncrementalContentStateInput { + assets: PageAssetManager config: SiteConfig context: BuildContext hooks: BuildHooks @@ -148,9 +151,11 @@ export class IncrementalContentState { */ private async writePageWithHooks(file: ResolvedContentFile) { const { context, hooks, config } = this.input + let renderedPage: PageRenderResult | undefined try { await hooks.onPageStart?.(context, file) const page = await renderPageFromFile(context, file, hooks) + renderedPage = page this.importedFilesByPage.set(file.relPath, new Set(page.importedFiles)) if (page.frontmatter.index === false) { this.unindexedPages.set(file.relPath, page.outPath) @@ -158,11 +163,19 @@ export class IncrementalContentState { this.unindexedPages.delete(file.relPath) } await hooks.onPageRendered?.(context, page) + this.input.assets.reserveSourceOutput( + path.relative(config.outDir, page.outPath), + ) await writePage(page, config.html.minify) await hooks.onPageWritten?.(context, page) + await this.input.assets.commitPage(page) this.input.onPageBuilt(file.relPath, page.scriptEntrypoints) return true } catch (error) { + if (renderedPage) + await this.input.assets.abortPage(renderedPage, { + errorPage: context.writeErrorPages === true, + }) if (context.writeErrorPages !== true) throw error this.input.log.error('page build failed', { relPath: file.relPath, @@ -372,6 +385,7 @@ export class IncrementalContentState { this.contentIndex.remove(relPath) this.dirtyPages.delete(relPath) this.importedFilesByPage.delete(relPath) + await this.input.assets.removePage(relPath) this.unindexedPages.delete(relPath) this.input.onPageRemoved(relPath) result.deletedPages += 1 @@ -427,6 +441,7 @@ export class IncrementalContentState { } async handleMissingRelPathSource(relPath: string) { + await this.input.assets.removePage(relPath) const entry = this.input.getManifest().content[relPath] if (entry) { await removeFile(entry.outPath) diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index da543bfb..7290388b 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -10,6 +10,7 @@ import { } from '@purestack/ts-style' import { toOutputAssetRelPath, toPosixPath } from '@purestack/ts-util' import { getLogger, type Logger } from 'logpot' +import { resolveConfiguredPlugins } from '../../config/plugin-modules' import { DEFAULT_NAV_FILENAME, discoverStaticAssets, @@ -26,10 +27,7 @@ import { resolveContentFile, } from '../../i18n/content' import { buildNavigation } from '../../navigation/navigation' -import { - initializePluginContext, - resolveBuildPlugins, -} from '../../plugins/default-plugins' +import { initializePluginContext } from '../../plugins/default-plugins' import { composePluginHooks, type PureStackPlugin, @@ -50,6 +48,7 @@ import { writeManifest, } from '../manifest' import { type BuildContext, resolveHeaderFooterHtml } from '../page' +import { getPageAssets, PageAssetManager, setPageAssets } from '../page-assets' import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key' import type { BuildHooks, BuildInput, BuildResult } from '../site' import { writeSitemap } from '../sitemap' @@ -74,14 +73,27 @@ useStyleScope(new AsyncLocalStorage()) export async function createIncrementalBuilder( input: BuildInput = {}, ): Promise { + return (await createIncrementalBuildSession(input)).builder +} + +/** Internal server setup shares the builder's resolved config and selected plugins. */ +export async function createIncrementalBuildSession(input: BuildInput = {}) { const styleContext = createStyleContext() return runWithStyleContext(styleContext, async () => { - const runtime = await createIncrementalRuntime(input, styleContext) - return runtime.toBuilder() + const { file, plugins } = await resolveConfiguredPlugins(input) + const config = resolveBuildSiteConfig(input, plugins, file) + const runtime = await createIncrementalRuntime( + input, + styleContext, + config, + plugins, + ) + return { builder: runtime.toBuilder(), config, plugins } }) } interface IncrementalRuntimeOptions { + assets: PageAssetManager styleContext: StyleContext config: SiteConfig hooks: BuildHooks @@ -98,11 +110,11 @@ interface IncrementalRuntimeOptions { async function createIncrementalRuntime( input: BuildInput, styleContext: StyleContext, + config: SiteConfig, + plugins: readonly PureStackPlugin[], ): Promise { const buildOptions = input.options ?? {} const publishOptions = input.publish ?? {} - const plugins = resolveBuildPlugins(buildOptions) - const config = resolveBuildSiteConfig(input, plugins) const hooks = composePluginHooks(plugins) const cleanOutDir = publishOptions.enabled === true || buildOptions.cleanOutDir === true @@ -128,15 +140,27 @@ async function createIncrementalRuntime( contentRoutes: new ContentRouteIndex([]), writeErrorPages: buildOptions.writeErrorPages === true, mdx, - resolveScriptPublicPath: config.scripts.cacheBusting - ? (sourceRelPath) => - `/${toOutputAssetRelPath(sourceRelPath, { - cacheKey: scriptCacheKeys.ensure(sourceRelPath), - })}` - : undefined, + resolveScriptPublicPath: (sourceRelPath) => { + const output = toOutputAssetRelPath(sourceRelPath, { + cacheKey: config.scripts.cacheBusting + ? scriptCacheKeys.ensure(sourceRelPath) + : undefined, + }) + getPageAssets(context)?.reserveSourceOutput(output) + return `/${output}` + }, } + const assets = new PageAssetManager( + context, + existing?.config.contentDir === config.contentDir && + existing.config.outDir === config.outDir + ? existing.pageAssets + : undefined, + ) + setPageAssets(context, assets) return new IncrementalRuntime({ + assets, styleContext, config, hooks, @@ -196,8 +220,13 @@ class IncrementalRuntime { getAssets: () => this.manifest.assets, scriptCacheKeys: this.scriptCacheKeys, persistManifest: () => this.persistManifest(), + reserveOutput: (outPath) => + this.assets.reserveSourceOutput( + path.relative(this.config.outDir, outPath), + ), }) this.contentState = new IncrementalContentState({ + assets: options.assets, config: options.config, context: options.context, hooks: options.hooks, @@ -254,6 +283,10 @@ class IncrementalRuntime { return this.options.context } + private get assets() { + return this.options.assets + } + private get log() { return this.options.log } @@ -278,6 +311,7 @@ class IncrementalRuntime { private async prepareRequestState() { await initializePluginContext(this.context, this.options.plugins) await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir }) + this.assets.resetWrites() // Keep the records object shared with the script manager, but start this // session with no rendered pages or copied assets, even without --clean. this.manifest.content = {} @@ -296,6 +330,8 @@ class IncrementalRuntime { private async discoverAssets() { const files = await discoverStaticAssets(this.config.contentDir) + for (const file of files) + this.assets.reserveSourceOutput(toOutputAssetRelPath(file.relPath)) this.staticAssets = new Map( files.map((file) => [ `/${toPosixPath(toOutputAssetRelPath(file.relPath))}`, @@ -410,6 +446,7 @@ class IncrementalRuntime { ): Promise { await initializePluginContext(this.context, this.options.plugins) await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir }) + this.assets.resetWrites() await this.discoverAssets() const assetFiles = [...this.staticAssets.values()] const contentFiles = await this.prepareContent( @@ -438,6 +475,7 @@ class IncrementalRuntime { contentFiles, assetRelPaths, ) + await this.assets.retainPages(contentFiles.map((file) => file.relPath)) await resolveHeaderFooterHtml(this.context) await hooks?.onContentDiscovered?.(this.context, contentFiles) this.context.navigation = await buildNavigation( @@ -500,11 +538,19 @@ class IncrementalRuntime { private createBuildSummary(input: BuildSummaryInput): BuildResult { const { contentFiles, assetFiles, pages } = input + const generated = new Set( + Object.values(this.assets.snapshot() ?? {}).flatMap((value) => + value.outputs.map((output) => output.path), + ), + ) return { outDir: this.config.outDir, pages, content: countByExt(contentFiles), - assets: countByExt(assetFiles), + assets: countByExt([ + ...assetFiles, + ...[...generated].map((file) => ({ ext: path.extname(file) })), + ]), } } @@ -578,6 +624,7 @@ class IncrementalRuntime { return result } } + const assetDependents = this.assets.dependents(relPath) this.contentState.takeMarkedPageCount() const ext = path.extname(relPath).toLowerCase() const imported = this.importedFiles.has(toPosixPath(relPath)) @@ -632,6 +679,11 @@ class IncrementalRuntime { } await this.contentState.refreshImporters(relPath) await this.syncImportedAssets() + if (assetDependents.length > 0) { + this.contentState.markPagesDirty(assetDependents) + if (!this.renderOnRequest) + await this.contentState.rebuildContentRelPaths(assetDependents, result) + } result.markedPages = this.contentState.takeMarkedPageCount() return result } @@ -776,6 +828,9 @@ class IncrementalRuntime { } private async writeManifestAndSitemap() { + const pageAssets = this.assets.snapshot() + if (pageAssets) this.manifest.pageAssets = pageAssets + else delete this.manifest.pageAssets await writeManifest(this.config.outDir, this.manifest) const sitemap = await this.writeSitemapFromManifest() if (!sitemap) return diff --git a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts index aab808e6..92028717 100644 --- a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts +++ b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts @@ -10,6 +10,7 @@ import { outputPathKey, removeFile, toAssetFile } from './support' import type { IncrementalBuildResult } from './types' interface ScriptEntrypointManagerInput { + reserveOutput: (outPath: string) => void config: Pick minifyScripts: boolean failOnAssetError: boolean @@ -220,6 +221,11 @@ export class ScriptEntrypointManager { const priorCacheKey = this.input.scriptCacheKeys.get(entryRelPath) const cacheKey = this.resolveCacheKey(entryRelPath, options) + this.input.reserveOutput( + resolveStaticOutPath(this.input.config.outDir, assetFile, { + scriptCacheKey: cacheKey, + }), + ) const assetCopy = await copyStaticAsset( this.input.config.contentDir, this.input.config.outDir, diff --git a/packages/ts-ssg/src/build/manifest.test.ts b/packages/ts-ssg/src/build/manifest.test.ts index 2c5e5456..59e4b7b8 100644 --- a/packages/ts-ssg/src/build/manifest.test.ts +++ b/packages/ts-ssg/src/build/manifest.test.ts @@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' import { createEmptyManifest, + isCompatibleManifest, manifestPath, readManifest, signatureEqual, @@ -30,6 +31,13 @@ async function writeRawManifest(outDir: string, contents: string) { } describe('manifest', () => { + it('reuses manifests without a theme and rejects changed style ownership', () => { + const bare = resolveSiteConfig({}, { defaultPlugins: false }) + expect(isCompatibleManifest(createEmptyManifest(bare), bare)).toBe(true) + expect( + isCompatibleManifest(createEmptyManifest(bare), resolveSiteConfig()), + ).toBe(false) + }) let logger: Logger | undefined beforeAll(async () => { diff --git a/packages/ts-ssg/src/build/manifest.ts b/packages/ts-ssg/src/build/manifest.ts index b680c0a8..cd59f110 100644 --- a/packages/ts-ssg/src/build/manifest.ts +++ b/packages/ts-ssg/src/build/manifest.ts @@ -6,6 +6,7 @@ import type { SiteConfig } from '@purestack/ts-common' import { ensureDir } from '@purestack/ts-util-node' import { getLogger } from 'logpot' import type { ContentFile } from '../discover/content' +import type { PageAssetReceipt } from './asset-receipt' export const MANIFEST_VERSION = 1 export const MANIFEST_DIRNAME = '.ts-ssg' @@ -21,7 +22,7 @@ export interface ManifestConfig { outDir: string basePath: string siteTitle: string - style: { + style?: { fileName: string href: string themes: string[] @@ -57,6 +58,7 @@ export interface BuildManifest { content: Record assets: Record styles: StylesManifestEntry + pageAssets?: Record } export function manifestPath(outDir: string) { @@ -71,12 +73,16 @@ export function manifestConfigFromSiteConfig( outDir: config.outDir, basePath: config.basePath, siteTitle: config.siteTitle, - style: { - fileName: config.style.fileName, - href: config.style.href, - themes: config.style.themes, - pretty: config.style.pretty, - }, + ...(config.style + ? { + style: { + fileName: config.style.fileName, + href: config.style.href, + themes: config.style.themes, + pretty: config.style.pretty, + }, + } + : {}), signature: createManifestConfigSignature(config), } } @@ -164,13 +170,14 @@ function manifestConfigEqual(left: ManifestConfig, right: ManifestConfig) { left.outDir === right.outDir && left.basePath === right.basePath && left.siteTitle === right.siteTitle && - isPlainObject(left.style) && - isPlainObject(right.style) && - left.style.fileName === right.style.fileName && - left.style.href === right.style.href && - Array.isArray(left.style.themes) && - left.style.themes.join('|') === right.style.themes.join('|') && - left.style.pretty === right.style.pretty && + ((left.style === undefined && right.style === undefined) || + (isPlainObject(left.style) && + isPlainObject(right.style) && + left.style.fileName === right.style.fileName && + left.style.href === right.style.href && + Array.isArray(left.style.themes) && + left.style.themes.join('|') === right.style.themes.join('|') && + left.style.pretty === right.style.pretty)) && left.signature === right.signature ) } diff --git a/packages/ts-ssg/src/build/page-assets.test.ts b/packages/ts-ssg/src/build/page-assets.test.ts new file mode 100644 index 00000000..d48b13fc --- /dev/null +++ b/packages/ts-ssg/src/build/page-assets.test.ts @@ -0,0 +1,371 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import type { SiteAssets } from '@purestack/ts-common' +import { parseHtml } from '@purestack/ts-minidom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveSiteConfig } from '../config/config' +import { makeRepoTempDir } from '../test/repoTempDir' +import { ContentRouteIndex } from './content-urls' +import type { BuildContext } from './page' +import { PageAssetManager } from './page-assets' + +const document = () => + parseHtml('') + .document as unknown as Document + +describe('page asset source tracking', () => { + let root: string + let context: BuildContext + beforeEach(async () => { + root = await makeRepoTempDir('.tmp-asset-sources-') + context = { + config: resolveSiteConfig( + { rootDir: root, contentDir: root }, + { defaultPlugins: false }, + ), + contentRoutes: new ContentRouteIndex([], ['photo.png', 'other.png']), + } + await fs.writeFile(path.join(root, 'photo.png'), 'Photo') + await fs.writeFile(path.join(root, 'other.png'), 'Other') + }) + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(root, { recursive: true, force: true }) + }) + + it('matches pending and restored dependencies with platform path semantics', async () => { + const manager = new PageAssetManager(context, { + 'restored.md': { dependencies: ['Images/Missing.png'], outputs: [] }, + }) + manager.setProcessors([ + { + namespace: 'test', + processPage(input) { + input.dependOn('Images/Missing.png') + }, + }, + ]) + const session = manager.session('pending.md') + await session?.process(document()) + expect(manager.dependents('Images\\Missing.png')).toEqual([ + 'restored.md', + 'pending.md', + ]) + expect(manager.dependents('images/missing.png')).toEqual( + process.platform === 'win32' ? ['restored.md', 'pending.md'] : [], + ) + await session?.abort() + expect(manager.dependents('Images/Missing.png')).toEqual(['restored.md']) + }) + + it('shares concurrent content-root resolution and refreshes it at the next build', async () => { + const manager = new PageAssetManager(context) + const realpath = vi.spyOn(fs, 'realpath') + manager.setProcessors([ + { + namespace: 'test', + async processPage(input) { + const image = input.document.querySelector('img') + if (!image) throw new Error('Expected image') + const sources = await Promise.all( + ['photo.png', 'other.png'].map((url) => + input.resolveSource(image, url), + ), + ) + expect( + sources.every((source) => source?.absPath.startsWith(root)), + ).toBe(true) + }, + }, + ]) + const processPage = async (name: string) => { + const session = manager.session(name) + await session?.process(document()) + await session?.abort() + } + await Promise.all([processPage('first.md'), processPage('second.md')]) + expect( + realpath.mock.calls.filter(([value]) => value === root), + ).toHaveLength(1) + // Each source still gets its own canonical-path containment check. + expect( + realpath.mock.calls.filter(([value]) => value !== root), + ).toHaveLength(4) + manager.resetWrites() + await processPage('third.md') + expect( + realpath.mock.calls.filter(([value]) => value === root), + ).toHaveLength(2) + }) + + it('retries a failed root lookup instead of retaining a rejected promise', async () => { + const manager = new PageAssetManager(context) + const original = fs.realpath.bind(fs) + vi.spyOn(fs, 'realpath') + .mockRejectedValueOnce(new Error('Temporary root failure')) + .mockImplementation(original) + manager.setProcessors([ + { + namespace: 'test', + async processPage(input) { + const image = input.document.querySelector('img') + if (!image) throw new Error('Expected image') + await input.resolveSource(image, 'photo.png') + }, + }, + ]) + const first = manager.session('first.md') + await expect(first?.process(document())).rejects.toThrow( + 'Temporary root failure', + ) + await first?.abort() + const second = manager.session('second.md') + await expect(second?.process(document())).resolves.toBeUndefined() + await second?.abort() + }) + + async function processAssets( + manager: PageAssetManager, + processPage: SiteAssets['processPage'], + ) { + manager.setProcessors([{ namespace: 'test', processPage }]) + const session = manager.session('page.md') + if (!session) throw new Error('Expected asset session') + await session.process(document()) + return session + } + + const bytes = Buffer.from('First') + const output = (context: BuildContext) => + path.join(context.config.outDir, '_assets', 'test', 'shared.txt') + async function seed(value = bytes) { + const file = output(context) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, value) + return file + } + + it('verifies unchanged warm output once per build across repeated pages', async () => { + const file = await seed() + const read = vi.spyOn(fs, 'readFile') + const rename = vi.spyOn(fs, 'rename') + const manager = new PageAssetManager(context) + for (let page = 0; page < 3; page++) + await processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + await input.emit('shared.txt', bytes) + }) + expect(read.mock.calls.filter(([value]) => value === file)).toHaveLength(1) + expect(rename).not.toHaveBeenCalled() + manager.resetWrites() + await processAssets(manager, (input) => + input.emit('shared.txt', bytes).then(() => {}), + ) + expect(read.mock.calls.filter(([value]) => value === file)).toHaveLength(2) + }) + + it('replaces different-sized output without reading its obsolete contents', async () => { + const file = await seed(Buffer.from('Outdated larger contents')) + const readFile = fs.readFile.bind(fs) + const read = vi.spyOn(fs, 'readFile') + await processAssets(new PageAssetManager(context), async (input) => { + await input.emit('shared.txt', bytes) + }) + expect(read).not.toHaveBeenCalled() + expect(await readFile(file)).toEqual(bytes) + }) + + it.each(['ino', 'ctimeNs'] as const)( + 'keeps hashing when the filesystem does not expose %s', + async (field) => { + const file = await seed() + const lstat = fs.lstat.bind(fs) + vi.spyOn(fs, 'lstat').mockImplementation( + async (...args: Parameters) => { + const stat = await lstat(...args) + if (args[0] === file) + Object.defineProperty(stat, field, { value: 0n }) + return stat + }, + ) + const read = vi.spyOn(fs, 'readFile') + await processAssets(new PageAssetManager(context), async (input) => { + await input.emit('shared.txt', bytes) + await input.emit('shared.txt', bytes) + }) + expect(read.mock.calls.filter(([value]) => value === file)).toHaveLength( + 2, + ) + }, + ) + + it('reuses newly written output after successful atomic publication', async () => { + const readFile = fs.readFile.bind(fs) + const read = vi.spyOn(fs, 'readFile') + const rename = vi.spyOn(fs, 'rename') + await processAssets(new PageAssetManager(context), async (input) => { + await input.emit('shared.txt', bytes) + await input.emit('shared.txt', bytes) + }) + expect(read).not.toHaveBeenCalled() + expect(rename).toHaveBeenCalledTimes(1) + expect(await readFile(output(context))).toEqual(bytes) + }) + + it('repairs output changed while its original bytes are being verified', async () => { + const file = await seed() + const readFile = fs.readFile.bind(fs) + vi.spyOn(fs, 'readFile').mockImplementationOnce( + async (...args: Parameters) => { + const original = await readFile(...args) + await fs.writeFile(file, 'Other') + await fs.utimes(file, 1_700_000_000, 1_700_000_000) + return original + }, + ) + await processAssets(new PageAssetManager(context), async (input) => { + await input.emit('shared.txt', bytes) + }) + expect(await readFile(file)).toEqual(bytes) + }) + + it.each(['rewrite', 'replace', 'delete'] as const)( + 'invalidates confirmed output after external %s', + async (change) => { + const file = await seed() + const timestamp = 1_700_000_000 + await fs.utimes(file, timestamp, timestamp) + const manager = new PageAssetManager(context) + const emit = () => + processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + }) + await emit() + if (change === 'delete') await fs.rm(file) + else if (change === 'rewrite') { + const before = await fs.stat(file, { bigint: true }) + await fs.writeFile(file, 'Other') + // Restoring mtime must not hide same-sized corruption: ctime also matters. + // Windows may report one ctime for several operations in the same clock + // tick. Establish an observable change without a fixed timing delay. + let changed = false + for (let attempt = 0; attempt < 100; attempt++) { + await fs.utimes(file, timestamp, timestamp) + if ( + (await fs.stat(file, { bigint: true })).ctimeNs !== before.ctimeNs + ) { + changed = true + break + } + } + expect(changed).toBe(true) + } else { + const replacement = path.join(root, 'replacement.txt') + await fs.writeFile(replacement, 'Other') + await fs.utimes(replacement, timestamp, timestamp) + await fs.rename(replacement, file) + } + await emit() + expect(await fs.readFile(file)).toEqual(bytes) + }, + ) + + it('does not confirm a failed write and can retry it without weakening hash verification', async () => { + const file = await seed(Buffer.from('Other')) + const readFile = fs.readFile.bind(fs) + const read = vi.spyOn(fs, 'readFile') + const rename = fs.rename.bind(fs) + vi.spyOn(fs, 'rename') + .mockRejectedValueOnce(new Error('Write interrupted')) + .mockImplementation(rename) + await processAssets(new PageAssetManager(context), async (input) => { + await expect(input.emit('shared.txt', bytes)).rejects.toThrow( + 'Write interrupted', + ) + await input.emit('shared.txt', bytes) + }) + expect(read.mock.calls.filter(([value]) => value === file)).toHaveLength(2) + expect(await readFile(file)).toEqual(bytes) + expect(await fs.readdir(path.dirname(file))).toEqual(['shared.txt']) + }) + + it('coalesces concurrent emissions and forgets verification and collision reservations after pruning', async () => { + const manager = new PageAssetManager(context) + const rename = vi.spyOn(fs, 'rename') + const first = await processAssets(manager, async (input) => { + await Promise.all( + Array.from({ length: 4 }, () => input.emit('shared.txt', bytes)), + ) + }) + expect(rename).toHaveBeenCalledTimes(1) + await first.abort() + await expect(fs.stat(output(context))).rejects.toMatchObject({ + code: 'ENOENT', + }) + await processAssets(manager, async (input) => { + await input.emit('shared.txt', Buffer.from('Other')) + }) + expect(await fs.readFile(output(context), 'utf8')).toBe('Other') + }) + + it('queues confirmed emissions behind an in-progress prune and restores the new page output', async () => { + const manager = new PageAssetManager(context) + const first = await processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + }) + let release!: () => void + let began!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const started = new Promise((resolve) => { + began = resolve + }) + const rm = fs.rm.bind(fs) + vi.spyOn(fs, 'rm').mockImplementation(async (file, options) => { + if (file === output(context)) { + began() + await barrier + } + return rm(file, options) + }) + const pruning = first.abort() + await started + let emitted = false + const second = processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + emitted = true + }) + try { + await Promise.resolve() + expect(emitted).toBe(false) + } finally { + release() + } + await Promise.all([pruning, second]) + expect(await fs.readFile(output(context))).toEqual(bytes) + }) + + it('rechecks ancestor symlinks even for already confirmed output', async () => { + const manager = new PageAssetManager(context) + await processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + }) + const directory = path.dirname(output(context)) + const protectedDirectory = path.join(root, 'protected') + await fs.rename(directory, protectedDirectory) + await fs.symlink( + protectedDirectory, + directory, + process.platform === 'win32' ? 'junction' : 'dir', + ) + await expect( + processAssets(manager, async (input) => { + await input.emit('shared.txt', bytes) + }), + ).rejects.toThrow('contains a symlink') + expect( + await fs.readFile(path.join(protectedDirectory, 'shared.txt')), + ).toEqual(bytes) + }) +}) diff --git a/packages/ts-ssg/src/build/page-assets.ts b/packages/ts-ssg/src/build/page-assets.ts new file mode 100644 index 00000000..2d3f6b1c --- /dev/null +++ b/packages/ts-ssg/src/build/page-assets.ts @@ -0,0 +1,521 @@ +import { createHash, randomUUID } from 'node:crypto' +import type { BigIntStats } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import type { PageAssetInput, SiteAssets } from '@purestack/ts-common' +import { isPlainObject } from '@purestack/ts-util' +import type { GeneratedAssetReference, PageAssetReceipt } from './asset-receipt' +import { resolveContentAsset } from './content-urls' +import { resolveOutPath } from './out-path' +import type { BuildContext, PageRenderResult } from './page' +import { elementSourceRelPath, visitSourceElements } from './page-urls' + +const fileKey = (value: string) => + process.platform === 'win32' ? value.toLowerCase() : value + +const validNamespace = (value: string) => /^[a-z0-9][a-z0-9_-]*$/.test(value) + +// Core state is associated with a build without exposing it to public hooks. +const managers = new WeakMap() +export const getPageAssets = (context: BuildContext) => managers.get(context) +export function setPageAssets( + context: BuildContext, + manager: PageAssetManager, +) { + managers.set(context, manager) +} + +function relativeFile(value: string) { + const normalized = value.replaceAll('\\', '/') + if ( + !normalized || + path.posix.isAbsolute(normalized) || + /^[a-zA-Z]:/.test(normalized) || + normalized + .split('/') + .some((part) => part === '..' || part === '' || part === '.') + ) { + throw new Error(`Expected a content-relative file path, got "${value}".`) + } + return normalized +} + +function reference(value: GeneratedAssetReference): GeneratedAssetReference { + if (!validNamespace(value.namespace)) + throw new Error('Invalid generated asset namespace.') + const relPath = relativeFile(value.path) + if (!relPath.startsWith(`_assets/${value.namespace}/`)) + throw new Error('Generated output escapes its namespace.') + return { namespace: value.namespace, path: relPath } +} + +function receipt(value: unknown): PageAssetReceipt { + if ( + !isPlainObject(value) || + !Array.isArray(value.dependencies) || + !Array.isArray(value.outputs) + ) + throw new Error('Invalid page asset receipt.') + return { + dependencies: value.dependencies.map((value) => relativeFile(value)), + outputs: value.outputs.map((value) => reference(value)), + } +} + +export interface PageAssetSession { + receipt: PageAssetReceipt + process: (document: Document) => Promise + abort: () => Promise +} + +/** Owns generated files independently of any particular asset integration. */ +export class PageAssetManager { + private processors: readonly SiteAssets[] = [] + private readonly receipts = new WeakMap< + PageRenderResult, + { source: string; value: PageAssetReceipt } + >() + private readonly sourceOutputs = new Set() + private readonly pages = new Map() + private readonly pending = new Map() + private readonly writing = new Map< + string, + { hash: string; task: Promise } + >() + private readonly reservedHashes = new Map() + // Separate successful verification from hashes reserved for collision checks. + private readonly verifiedOutputs = new Map< + string, + { hash: string; stat: BigIntStats } + >() + private contentRoot: Promise | undefined + private work: Promise = Promise.resolve() + private reservedFor: BuildContext['contentRoutes'] | undefined + private reserved = new Set() + + private sourceOwns(relPath: string) { + const routes = this.context.contentRoutes + if (this.reservedFor !== routes) { + this.reservedFor = routes + this.reserved = new Set(routes.assets.map(fileKey)) + for (const page of routes.pages) { + const outPath = path + .relative( + this.context.config.outDir, + resolveOutPath(this.context.config.outDir, page), + ) + .replaceAll('\\', '/') + this.reserved.add(fileKey(outPath)) + } + } + return ( + this.reserved.has(fileKey(relPath)) || + this.sourceOutputs.has(fileKey(relPath)) + ) + } + + /** Receives actual output paths from core producers, never inferred filenames. */ + reserveSourceOutput(relPath: string) { + const key = fileKey(relativeFile(relPath)) + this.sourceOutputs.add(key) + const owned = [...this.pages.values(), ...this.pending.keys()].some( + (value) => value.outputs.some((output) => fileKey(output.path) === key), + ) + if (owned) + throw new Error( + `Source output "${relPath}" conflicts with a generated asset.`, + ) + } + + setProcessors(processors: readonly SiteAssets[]) { + this.processors = [...processors] + } + + trackPage(page: PageRenderResult, value: PageAssetReceipt) { + this.receipts.set(page, { source: page.file.relPath, value }) + } + + async commitPage(page: PageRenderResult) { + const value = this.receipts.get(page) + if (value) await this.commit(value.source, value.value) + this.receipts.delete(page) + } + + async abortPage( + page: PageRenderResult, + options: { errorPage?: boolean } = {}, + ) { + const value = this.receipts.get(page) + this.receipts.delete(page) + if (!value) return + await this.abort(value.value) + if (options.errorPage) + await this.commit(value.source, { + dependencies: value.value.dependencies, + outputs: [], + }) + } + + constructor( + private readonly context: BuildContext, + previous?: Record, + ) { + if (!isPlainObject(previous)) return + for (const [page, value] of Object.entries(previous)) { + try { + relativeFile(page) + this.pages.set(page, receipt(value)) + } catch { + /* Never trust paths loaded from a build cache. */ + } + } + } + + resetWrites() { + this.reservedHashes.clear() + this.verifiedOutputs.clear() + this.sourceOutputs.clear() + this.contentRoot = undefined + } + + session(sourceRelPath: string): PageAssetSession | undefined { + const processors = this.processors + if (processors.length === 0) return undefined + const collected: PageAssetReceipt = { dependencies: [], outputs: [] } + this.pending.set(collected, sourceRelPath) + const dependencies = new Set() + const dependOn = (value: string) => { + const relPath = relativeFile(value) + if (dependencies.has(relPath)) return + dependencies.add(relPath) + collected.dependencies.push(relPath) + } + return { + receipt: collected, + process: async (document) => { + for (const processor of processors) { + const sources = new WeakMap() + visitSourceElements(document, sourceRelPath, (element, source) => + sources.set(element, source), + ) + const scope = assetInputScope(processor.namespace, { + document, + sourceRelPath, + dependOn, + resolveSource: async (element, url) => { + const resolved = resolveContentAsset( + url, + sources.get(element) ?? + elementSourceRelPath(element, sourceRelPath), + this.context.contentRoutes, + this.context.config, + dependOn, + ) + if (!resolved) return undefined + this.contentRoot ??= fs + .realpath(this.context.config.contentDir) + .catch((error) => { + this.contentRoot = undefined + throw error + }) + const root = await this.contentRoot + const absPath = await fs.realpath( + path.resolve(root, resolved.relPath), + ) + if (!inside(root, absPath)) + throw new Error( + `Asset "${resolved.relPath}" resolves outside the content root.`, + ) + return { ...resolved, absPath } + }, + emit: async (name, data) => { + const file = reference({ + namespace: processor.namespace, + path: `_assets/${processor.namespace}/${relativeFile(name)}`, + }) + if ( + !collected.outputs.some((output) => output.path === file.path) + ) + collected.outputs.push(file) + await this.emit(file, data) + return `/${file.path.split('/').map(encodeURIComponent).join('/')}` + }, + }) + try { + await processor.processPage(scope.input) + scope.assertDrained() + } finally { + await scope.close() + } + } + }, + abort: () => this.abort(collected), + } + } + + dependents(relPath: string) { + const key = fileKey(relPath.replaceAll('\\', '/')) + const dependsOn = (value: PageAssetReceipt) => + value.dependencies.some((dependency) => fileKey(dependency) === key) + return [ + ...new Set([ + ...[...this.pages] + .filter(([, value]) => dependsOn(value)) + .map(([page]) => page), + ...[...this.pending] + .filter(([value]) => dependsOn(value)) + .map(([, page]) => page), + ]), + ] + } + + private commit(page: string, value: PageAssetReceipt) { + return this.queue(async () => { + const next = receipt(value) + const previous = this.pages.get(page) + if (next.dependencies.length > 0 || next.outputs.length > 0) + this.pages.set(page, next) + else this.pages.delete(page) + this.pending.delete(value) + await this.prune(previous?.outputs ?? []) + }) + } + + removePage(page: string) { + return this.queue(async () => { + const previous = this.pages.get(page) + this.pages.delete(page) + await this.prune(previous?.outputs ?? []) + }) + } + + retainPages(pages: readonly string[]) { + return this.queue(async () => { + const retained = new Set(pages) + const namespaces = new Set( + this.processors.map((processor) => processor.namespace), + ) + const unused: GeneratedAssetReference[] = [] + for (const [page, value] of this.pages) { + if (!retained.has(page)) { + this.pages.delete(page) + unused.push(...value.outputs) + } else { + unused.push( + ...value.outputs.filter( + (output) => !namespaces.has(output.namespace), + ), + ) + value.outputs = value.outputs.filter((output) => + namespaces.has(output.namespace), + ) + if (namespaces.size === 0) this.pages.delete(page) + } + } + await this.prune(unused) + }) + } + + snapshot(): Record | undefined { + return this.pages.size > 0 + ? Object.fromEntries( + [...this.pages].map(([page, value]) => [page, receipt(value)]), + ) + : undefined + } + + private abort(value: PageAssetReceipt) { + return this.queue(async () => { + this.pending.delete(value) + await this.prune(value.outputs) + }) + } + + private async emit(file: GeneratedAssetReference, data: Uint8Array) { + if (!(data instanceof Uint8Array)) + throw new Error('Generated asset contents must be a Uint8Array.') + if (this.sourceOwns(file.path)) + throw new Error( + `Generated asset "${file.path}" conflicts with a source output.`, + ) + const key = fileKey(file.path) + const hash = createHash('sha256').update(data).digest('hex') + const pending = this.writing.get(key) + if (pending) { + if (pending.hash !== hash) + throw new Error( + `Generated asset "${file.path}" was emitted with different contents. Use content-addressed names.`, + ) + return pending.task + } + const task = this.queue(async () => { + const previous = this.reservedHashes.get(key) + if (previous && previous !== hash) + throw new Error( + `Generated asset "${file.path}" was emitted with different contents. Use content-addressed names.`, + ) + this.reservedHashes.set(key, hash) + await this.write(file.path, data, hash) + }) + this.writing.set(key, { hash, task }) + try { + await task + } finally { + this.writing.delete(key) + } + } + + private async write(relPath: string, data: Uint8Array, hash: string) { + const key = fileKey(relPath) + const { output, stat } = await this.outputPath(relPath) + const verified = this.verifiedOutputs.get(key) + if ( + verified?.hash === hash && + verified.stat.ino !== 0n && + verified.stat.ctimeNs !== 0n && + sameFileState(verified.stat, stat) + ) + return + this.verifiedOutputs.delete(key) + // Size only rules out equality. Equal-sized files still require a hash check + // unless this build already confirmed their unchanged filesystem identity. + if (stat?.isFile() && stat.size === BigInt(data.byteLength)) { + const existing = await fs.readFile(output).catch((error) => { + if (error.code === 'ENOENT') return undefined + throw error + }) + if ( + existing && + createHash('sha256').update(existing).digest('hex') === hash + ) { + const after = await outputStat(output) + if (after && sameFileState(stat, after)) { + this.verifiedOutputs.set(key, { hash, stat: after }) + return + } + } + } + await fs.mkdir(path.dirname(output), { recursive: true }) + const temporary = `${output}.${randomUUID()}.tmp` + try { + await fs.writeFile(temporary, data) + await fs.rename(temporary, output) + const after = await outputStat(output) + if (after?.isFile() && after.size === BigInt(data.byteLength)) + this.verifiedOutputs.set(key, { hash, stat: after }) + } finally { + await fs.rm(temporary, { force: true }) + } + } + + private async outputPath(relPath: string) { + const root = path.resolve(this.context.config.outDir) + const output = path.resolve(root, relativeFile(relPath)) + if (!inside(root, output)) + throw new Error('Generated asset escapes the output directory.') + // A source/manifest must not redirect generated writes or cleanup via symlinks. + let current = output + let outputState: BigIntStats | undefined + while (current !== root) { + const stat = await outputStat(current) + if (current === output) outputState = stat + if (stat?.isSymbolicLink()) + throw new Error(`Generated asset path contains a symlink: ${current}`) + current = path.dirname(current) + } + return { output, stat: outputState } + } + + private async prune(candidates: GeneratedAssetReference[]) { + const used = new Set( + [...this.pages.values(), ...this.pending.keys()].flatMap((value) => + value.outputs.map((output) => fileKey(output.path)), + ), + ) + for (const candidate of candidates) { + const file = reference(candidate) + if (used.has(fileKey(file.path)) || this.sourceOwns(file.path)) continue + const { output } = await this.outputPath(file.path) + await fs.rm(output, { force: true }) + this.reservedHashes.delete(fileKey(file.path)) + this.verifiedOutputs.delete(fileKey(file.path)) + } + } + + private queue(run: () => Promise): Promise { + const task = this.work.then(run) + this.work = task.catch(() => undefined) + return task + } +} + +function outputStat(file: string) { + return fs.lstat(file, { bigint: true }).catch((error) => { + if (error.code !== 'ENOENT') throw error + return undefined + }) +} + +function sameFileState(before: BigIntStats, after: BigIntStats | undefined) { + return ( + after?.isFile() === true && + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size && + before.mtimeNs === after.mtimeNs && + before.ctimeNs === after.ctimeNs && + before.birthtimeNs === after.birthtimeNs + ) +} + +/** Every asynchronous input operation must settle before the page can commit or abort. */ +function assetInputScope(namespace: string, input: PageAssetInput) { + let open = true + const pending = new Set>() + const assertOpen = () => { + if (!open) + throw new Error(`Asset processor "${namespace}" input is closed.`) + } + const track = (work: () => Promise): Promise => { + assertOpen() + const task = work() + pending.add(task) + void task.then( + () => pending.delete(task), + () => pending.delete(task), + ) + return task + } + return { + input: { + ...input, + dependOn: (relPath) => { + assertOpen() + input.dependOn(relPath) + }, + resolveSource: (element, url) => + track(() => input.resolveSource(element, url)), + emit: (name, bytes) => track(() => input.emit(name, bytes)), + } satisfies PageAssetInput, + assertDrained() { + if (pending.size > 0) + throw new Error( + `Asset processor "${namespace}" must await all source resolution and emission work before returning.`, + ) + }, + async close() { + open = false + await Promise.allSettled([...pending]) + }, + } +} + +function inside(root: string, candidate: string) { + const relative = path.relative(root, candidate) + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ) +} diff --git a/packages/ts-ssg/src/build/page-urls.ts b/packages/ts-ssg/src/build/page-urls.ts index 8475a7ca..7c3222ce 100644 --- a/packages/ts-ssg/src/build/page-urls.ts +++ b/packages/ts-ssg/src/build/page-urls.ts @@ -21,32 +21,57 @@ export function markContentSource(html: string, sourceRelPath: string) { return `<${CONTENT_SOURCE_TAG} file="${file}">${html}` } +/** The innermost source marker wins for imported content and shared partials. */ +export function elementSourceRelPath(element: Element, fallback: string) { + for ( + let parent: Element | null = element; + parent; + parent = parent.parentElement + ) { + if (parent.tagName === CONTENT_SOURCE_TAG_NAME) + return parent.getAttribute('file') ?? fallback + } + return fallback +} + /** * Finishes every URL in a rendered page. Links, sources and posters resolve * from the content file that wrote them, and root-absolute URLs gain the * base path. Source markers are removed, so the page keeps its markup. */ export function resolvePageUrls(document: Document, options: PageUrlOptions) { - visitChildren(document, options.sourceRelPath, options) + visitSourceElements( + document, + options.sourceRelPath, + (element, source) => resolveElementUrls(element, source, options), + true, + ) } -function visitChildren( +export function visitSourceElements( parent: ParentNode, sourceRelPath: string, - options: PageUrlOptions, + visit: (element: Element, sourceRelPath: string) => void, + unwrap = false, ) { for (const child of [...parent.childNodes]) { if (!isElement(child)) continue if (child.tagName === CONTENT_SOURCE_TAG_NAME) { - visitChildren(child, child.getAttribute('file') ?? sourceRelPath, options) - child.replaceWith(...child.childNodes) + visitSourceElements( + child, + child.getAttribute('file') ?? sourceRelPath, + visit, + unwrap, + ) + if (unwrap) child.replaceWith(...child.childNodes) continue } - resolveElementUrls(child, sourceRelPath, options) - visitChildren( + visit(child, sourceRelPath) + visitSourceElements( isTemplate(child) ? child.content : child, sourceRelPath, - options, + visit, + unwrap, ) } } diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index fac3a702..afe3eec8 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -46,6 +46,7 @@ import type { ContentRouteIndex } from './content-urls' import { resolveHeadConfig } from './head-config' import { readSource, writeHtml } from './io' import { resolveOutPath } from './out-path' +import { getPageAssets } from './page-assets' import { markContentSource, resolvePageUrls } from './page-urls' import { renderPage } from './renderer' import type { BuildHooks } from './site' @@ -142,10 +143,12 @@ export async function renderPageFromFile( const outPath = resolveOutPath(context.config.outDir, file) // Kept on an error page too, so creating a missing file renders it again. const importedFiles = new Set() + const assets = getPageAssets(context) + const assetSession = assets?.session(file.relPath) try { const source = await readContentSource(file) const parsedContent = parseFrontmatterSource(source, file.relPath, { - defaultShowToc: context.config.pageToc.enabled, + defaultShowToc: context.config.pageToc?.enabled, }) const compiled = await compilePageContent( context, @@ -188,19 +191,21 @@ export async function renderPageFromFile( file, htmlShell, { pageInfo, navigation, outline: compiled.outline, scriptEntrypoints }, - (document, runtimeEmbeds) => - hooks.onPageDocument?.(context, { + async (document, runtimeEmbeds) => { + await hooks.onPageDocument?.(context, { document, file, frontmatter, urlPath, outline: compiled.outline, runtimeEmbeds, - }), + }) + await assetSession?.process(document) + }, ) const renderTimeMs = Number(process.hrtime.bigint() - renderStart) / 1_000_000 - return { + const result: PageRenderResult = { file, body: parsedContent.body, frontmatter, @@ -218,6 +223,8 @@ export async function renderPageFromFile( ), importedFiles: [...importedFiles], } + if (assetSession) assets?.trackPage(result, assetSession.receipt) + return result } catch (error) { if (isError(error)) getLogger().warn(error as Error) const errorWithContext = attachPageContext(error, { @@ -225,13 +232,23 @@ export async function renderPageFromFile( urlPath, outPath, }) + await assetSession?.abort() if (context.writeErrorPages) { const errorPage = await writePageError(context, file, errorWithContext, { outPath, urlPath, renderStart, }) - return { ...errorPage, importedFiles: [...importedFiles] } + const result: PageRenderResult = { + ...errorPage, + importedFiles: [...importedFiles], + } + if (assetSession) + assets?.trackPage(result, { + dependencies: assetSession.receipt.dependencies, + outputs: [], + }) + return result } throw errorWithContext } @@ -507,7 +524,7 @@ function renderPageApp( context: { site: context.config, ...baseContext, - theme: context.config.style.theme, + theme: context.config.style?.theme, basePath: context.config.basePath, locale: baseContext.pageInfo.locale, locales: context.config.i18n.locales, diff --git a/packages/ts-ssg/src/config/config-values.test.ts b/packages/ts-ssg/src/config/config-values.test.ts new file mode 100644 index 00000000..fb6d45ec --- /dev/null +++ b/packages/ts-ssg/src/config/config-values.test.ts @@ -0,0 +1,26 @@ +import path from 'node:path' +import { resolveConfigUrl, urlNormalizer } from '@purestack/ts-util' +import { describe, expect, it } from 'vitest' + +describe('content-root configuration URLs', () => { + it('preserves path and suffix semantics without Node imports in the implementation', () => { + const segments = ['', '.', '..', 'assets', 'logo.svg', '\\icons', 'a\\b'] + const suffixes = ['', '?v=2', '#icon', '?v=2#icon'] + for (const first of segments) + for (const second of segments) + for (const suffix of suffixes) { + const input = `${first}/${second}${suffix}` + const { base, suffix: tail } = urlNormalizer.splitSuffix(input) + const expected = + input.startsWith('/') || urlNormalizer.isSpecialHref(input) + ? input + : path.posix.normalize(`/${base.replaceAll('\\', '/')}`) + tail + expect(resolveConfigUrl(input), input).toBe(expected) + } + expect(resolveConfigUrl(undefined)).toBeUndefined() + expect(resolveConfigUrl('')).toBe('') + expect(resolveConfigUrl('https://example.com/a/../b')).toBe( + 'https://example.com/a/../b', + ) + }) +}) diff --git a/packages/ts-ssg/src/config/config.test.ts b/packages/ts-ssg/src/config/config.test.ts index 672752f1..abd16835 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('leaves automatic ga4 consent registration to the analytics plugin', () => { + it('resolves automatic ga4 consent registration through the default analytics plugin', () => { const config = resolveSiteConfig({ rootDir: process.cwd(), consent: { @@ -666,7 +666,7 @@ describe('resolveSiteConfig sitemap', () => { const ga4Service = config.consent.services.find( (entry) => entry.id === 'ga4', ) - expect(ga4Service).toBeUndefined() + expect(ga4Service?.ga4MeasurementId).toBe('G-TEST1234') }) 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 1ecaeb16..70a6ad63 100644 --- a/packages/ts-ssg/src/config/config.ts +++ b/packages/ts-ssg/src/config/config.ts @@ -1,909 +1,59 @@ -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' import type { - AnalyticsConfig, - AuthConfig, - ConsentCategory, - ConsentConfig, - ConsentScript, - ConsentService, - Ga4Config, - I18nConfig, - I18nUrlStrategy, - PagefindConfig, - PageTocConfig, - PreviewConfig, - RobotsConfig, SiteConfig, SiteConfigInput, - SiteConfigResolver, - SiteHtmlConfig, - SiteLogoConfig, SiteMdxConfig, - SitemapConfig, - SiteScriptsConfig, - SiteStyleConfig, } from '@purestack/ts-common' -import { - docLayoutDefaults, - pickSemanticTone, - resolveThemes, - themes, -} from '@purestack/ts-style' -import type { DeepPartial } from '@purestack/ts-util' -import { - clonePlainData, - isPlainObject, - merge, - normalizeBasePath, - urlNormalizer, -} from '@purestack/ts-util' -import { resolveNavigationConfig } from '../navigation/navigation' - -const DEFAULT_ROOT = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - '..', -) -export const SITE_CONFIG_FILENAME = 'siteConfig.json' -const DEFAULT_CONSENT_CATEGORIES: ConsentCategory[] = [ - { - id: 'necessary', - label: 'Necessary', - description: 'Required for core site functionality.', - required: true, - }, - { - id: 'preferences', - label: 'Preferences', - description: 'Stores optional settings like personalized UX behavior.', - }, - { - id: 'analytics', - label: 'Analytics', - description: 'Helps improve the site by measuring usage.', - }, - { - id: 'marketing', - label: 'Marketing', - description: 'Used for advertising and campaign attribution.', - }, -] - +import type { BuildOptions } from '../build/site' +import { resolvePluginConfigResolvers } from '../plugins/contributions' +import { resolveBuildPlugins } from '../plugins/default-plugins' +import { assertValidPlugins, withPluginSkins } from '../plugins/plugin' +import type { PureStackPlugin } from '../plugins/types' +import { resolveCoreSiteConfig } from './core-config' + +export { SITE_CONFIG_FILENAME } from './core-config' + +/** Settings guaranteed by the standard plugin set. Custom plugin lists may omit them. */ +export type DefaultSiteConfig = SiteConfig & + Required< + Pick< + SiteConfig, + | 'style' + | 'docLayout' + | 'pageToc' + | 'logo' + | 'auth' + | 'consent' + | 'analytics' + | 'pagefind' + | 'preview' + > + > & { mdx: SiteMdxConfig & { highlighter: string } } + +/** Resolves only the selected plugins' settings; standard plugins are included by default. */ +export function resolveSiteConfig( + input?: SiteConfigInput, + options?: { plugins?: undefined; defaultPlugins?: true }, +): DefaultSiteConfig +export function resolveSiteConfig( + input: SiteConfigInput | undefined, + options: Pick, +): 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) - const outDir = - input.outDir ?? - resolvePathFromFileConfig(fileConfig.outDir, contentDir) ?? - path.join(rootDir, 'dist', 'site') - const publishDir = - input.publishDir ?? - resolvePathFromFileConfig(fileConfig.publishDir, contentDir) ?? - path.join(rootDir, 'dist', 'publish') - const basePath = normalizeBasePath( - resolveString(input.basePath, fileConfig.basePath), - ) - const siteTitle = resolveString( - input.siteTitle, - fileConfig.siteTitle, - 'ts-ssg', - ) - const logo = resolveSiteLogoConfig(input.logo, fileConfig.logo) - const style = resolveStyleConfig(input, fileConfig) - const html = resolveHtmlConfig(input.html, fileConfig.html) - const navigation = resolveNavigationConfig( - input.navigation, - fileConfig.navigation, - ) - const pageToc = resolvePageTocConfig(input.pageToc, fileConfig.pageToc) - const auth = resolveAuthConfig(input.auth, fileConfig.auth) - const scripts = resolveScriptsConfig(input.scripts, fileConfig.scripts) - const sitemap = resolveSitemapConfig(input.sitemap, fileConfig.sitemap) - const analytics = resolveAnalyticsConfig( - input.analytics, - fileConfig.analytics, - ) - 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) - 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, - publishDir, - basePath, - siteTitle, - favicon: resolveFavicon(input.favicon, fileConfig.favicon), - logo, - docLayout: { - sidebarTop: - resolveOptionalString(input.docLayout?.sidebarTop) ?? - resolveOptionalString(fileConfig.docLayout?.sidebarTop) ?? - docLayoutDefaults.sidebarTop, - sidebarTopMobile: - resolveOptionalString(input.docLayout?.sidebarTopMobile) ?? - resolveOptionalString(fileConfig.docLayout?.sidebarTopMobile) ?? - docLayoutDefaults.sidebarTopMobile, - }, - style, - html, - navigation, - pageToc, - auth, - scripts, - sitemap, - consent, - analytics, - pagefind, - preview, - i18n, - mdx, - } - return applyConfigResolvers(config, resolvers, input, fileConfig) -} - -export function applyConfigResolvers( - config: SiteConfig, - resolvers: readonly SiteConfigResolver[], - input: SiteConfigInput = {}, - file: SiteConfigInput = {}, + options: Pick = {}, ): 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 + const plugins = resolveBuildPlugins(options) + return resolveSelectedSiteConfig(input, plugins) } -function resolveScriptsConfig( - input?: DeepPartial, - file?: DeepPartial, -): SiteScriptsConfig { - return { - cacheBusting: pickBoolean(input?.cacheBusting, file?.cacheBusting, true), - } -} - -function resolveAuthConfig( - input?: DeepPartial, - file?: DeepPartial, -): AuthConfig { - return { - enabled: pickBoolean(input?.enabled, file?.enabled, false), - signUp: pickBoolean(input?.signUp, file?.signUp, true), - signedInStorageKey: resolveString( - input?.signedInStorageKey, - file?.signedInStorageKey, - 'signed-in-hint', - ), - } -} - -function resolvePageTocConfig( - input?: DeepPartial, - file?: DeepPartial, -): PageTocConfig { - const variant = - resolveOptionalString(input?.variant) ?? - resolveOptionalString(file?.variant) - const className = - resolveOptionalString(input?.class) ?? resolveOptionalString(file?.class) - return { - enabled: pickBoolean(input?.enabled, file?.enabled, true), - tone: pickSemanticTone(input?.tone, file?.tone) ?? 'neutral', - ...(variant ? { variant } : {}), - ...(className ? { class: className } : {}), - } -} - -function resolveHtmlConfig( - input?: DeepPartial, - file?: DeepPartial, -): SiteHtmlConfig { - return { - minify: pickBoolean(input?.minify, file?.minify, false), - } -} - -function resolveStyleConfig( +/** Internal build entrypoint: package selection and settings share one file snapshot. */ +export function resolveSelectedSiteConfig( input: SiteConfigInput, - fileConfig: SiteConfigInput, -): SiteStyleConfig { - const styleInput = input.style - const styleFile = fileConfig.style - const fileName = resolveString( - styleInput?.fileName, - styleFile?.fileName, - 'site.css', - ) - const href = resolveConfigUrl( - resolveString(styleInput?.href, styleFile?.href, `/assets/${fileName}`), - ) - const themeNames = resolveThemes(styleInput?.themes, styleFile?.themes) - const pretty = pickBoolean(styleInput?.pretty, styleFile?.pretty, false) - const theme = themes.resolve(styleInput?.theme, styleFile?.theme) - return { - fileName, - href, - themes: themeNames, - pretty, - theme, - } -} - -function resolveSiteLogoConfig( - input?: DeepPartial, - file?: DeepPartial, -): SiteLogoConfig { - const href = input?.href !== undefined ? input.href : file?.href - return { - brand: resolveString(input?.brand, file?.brand, 'Pure Stack'), - letterColors: resolveOptionalString( - input?.letterColors ?? file?.letterColors, - ), - subtitleLetterColors: resolveOptionalString( - input?.subtitleLetterColors ?? file?.subtitleLetterColors, - ), - brandSizeSm: resolveOptionalString(input?.brandSizeSm ?? file?.brandSizeSm), - brandSizeMd: resolveOptionalString(input?.brandSizeMd ?? file?.brandSizeMd), - brandSizeLg: resolveOptionalString(input?.brandSizeLg ?? file?.brandSizeLg), - brandSizeXl: resolveOptionalString(input?.brandSizeXl ?? file?.brandSizeXl), - subtitleSizeSm: resolveOptionalString( - input?.subtitleSizeSm ?? file?.subtitleSizeSm, - ), - subtitleSizeMd: resolveOptionalString( - input?.subtitleSizeMd ?? file?.subtitleSizeMd, - ), - subtitleSizeLg: resolveOptionalString( - input?.subtitleSizeLg ?? file?.subtitleSizeLg, - ), - subtitleSizeXl: resolveOptionalString( - input?.subtitleSizeXl ?? file?.subtitleSizeXl, - ), - iconSize: resolveOptionalString(input?.iconSize ?? file?.iconSize), - iconSizeSm: resolveOptionalString(input?.iconSizeSm ?? file?.iconSizeSm), - iconSizeMd: resolveOptionalString(input?.iconSizeMd ?? file?.iconSizeMd), - iconSizeLg: resolveOptionalString(input?.iconSizeLg ?? file?.iconSizeLg), - iconSizeXl: resolveOptionalString(input?.iconSizeXl ?? file?.iconSizeXl), - subtitleInset: resolveOptionalString( - input?.subtitleInset ?? file?.subtitleInset, - ), - subtitleInsetSm: resolveOptionalString( - input?.subtitleInsetSm ?? file?.subtitleInsetSm, - ), - subtitleInsetMd: resolveOptionalString( - input?.subtitleInsetMd ?? file?.subtitleInsetMd, - ), - subtitleInsetLg: resolveOptionalString( - input?.subtitleInsetLg ?? file?.subtitleInsetLg, - ), - subtitleInsetXl: resolveOptionalString( - input?.subtitleInsetXl ?? file?.subtitleInsetXl, - ), - component: resolveOptionalString(input?.component ?? file?.component), - colors: (input?.colors ?? file?.colors)?.filter( - (color): color is string => typeof color === 'string', - ), - logoBackground: input?.logoBackground ?? file?.logoBackground, - logoForeground: input?.logoForeground ?? file?.logoForeground, - href: - href === null || href === '' - ? href - : resolveConfigUrl(resolveString(href, '/')), - subtitle: resolveOptionalString(input?.subtitle ?? file?.subtitle), - suffix: resolveOptionalString(input?.suffix ?? file?.suffix), - ariaLabel: resolveOptionalString(input?.ariaLabel ?? file?.ariaLabel), - icon: resolveOptionalString(input?.icon ?? file?.icon), - imageSrc: resolveConfigUrl( - resolveOptionalString(input?.imageSrc ?? file?.imageSrc), - ), - imageSrcDark: resolveConfigUrl( - resolveOptionalString(input?.imageSrcDark ?? file?.imageSrcDark), - ), - monogram: resolveOptionalString(input?.monogram ?? file?.monogram), - brandColor: resolveOptionalString(input?.brandColor ?? file?.brandColor), - accentColor: resolveOptionalString(input?.accentColor ?? file?.accentColor), - markBackground: resolveOptionalString( - input?.markBackground ?? file?.markBackground, - ), - markColor: resolveOptionalString(input?.markColor ?? file?.markColor), - brandSize: resolveOptionalString(input?.brandSize ?? file?.brandSize), - subtitleSize: resolveOptionalString( - input?.subtitleSize ?? file?.subtitleSize, - ), - markSize: resolveOptionalString(input?.markSize ?? file?.markSize), - gap: resolveOptionalString(input?.gap ?? file?.gap), - layout: resolveLogoChoice(input?.layout ?? file?.layout, [ - 'horizontal', - 'stacked', - 'wordmark', - 'mark', - ] as const), - size: resolveLogoChoice(input?.size ?? file?.size, [ - 'sm', - 'md', - 'lg', - 'xl', - ] as const), - appearance: resolveLogoChoice(input?.appearance ?? file?.appearance, [ - 'plain', - 'badge', - 'outline', - ] as const), - markStyle: resolveLogoChoice(input?.markStyle ?? file?.markStyle, [ - 'plain', - 'soft', - 'solid', - 'outline', - ] as const), - wordmarkStyle: resolveLogoChoice( - input?.wordmarkStyle ?? file?.wordmarkStyle, - ['plain', 'accent', 'gradient'] as const, - ), - shape: resolveLogoChoice(input?.shape ?? file?.shape, [ - 'rounded', - 'square', - 'circle', - ] as const), - tone: pickSemanticTone(input?.tone, file?.tone), - } -} - -function resolveLogoChoice( - value: unknown, - choices: readonly T[], -): T | undefined { - return typeof value === 'string' && choices.includes(value as T) - ? (value as T) - : undefined -} - -function resolveFavicon(...values: Array): string | undefined { - for (const value of values) { - const favicon = resolveOptionalString(value) - if (favicon) return favicon - } - return undefined -} - -function resolveString(...values: Array) { - for (const value of values) { - if (typeof value === 'string' && value.length > 0) return value - } - return '' -} - -function pickBoolean(input: unknown, file: unknown, fallback = false) { - const searchValues = [input, file] - for (const value of searchValues) { - if (typeof value === 'boolean') return value - } - return fallback -} - -function resolvePathFromFileConfig(value: unknown, contentDir: string) { - if (typeof value !== 'string' || value.length === 0) return undefined - if (path.isAbsolute(value)) return value - return path.resolve(contentDir, value) -} - -function resolveSitemapConfig( - input?: DeepPartial, - file?: DeepPartial, -): SitemapConfig { - const baseUrl = normalizeBaseUrl(input?.baseUrl ?? file?.baseUrl ?? '') - const enabled = input?.enabled ?? file?.enabled ?? false - const fileName = resolveString(input?.fileName, file?.fileName, 'sitemap.xml') - const robots = resolveRobotsConfig(input?.robots, file?.robots) - if (enabled && baseUrl.length === 0) { - throw new Error( - 'Sitemap is enabled but sitemap.baseUrl is missing. Provide an absolute base URL.', - ) - } - if (robots.enabled && robots.fileName.length === 0) { - throw new Error( - 'Sitemap robots config is enabled but sitemap.robots.fileName is empty.', - ) - } - return { - enabled, - baseUrl, - fileName, - robots, - } -} - -function resolveConsentConfig( - input?: DeepPartial, - file?: DeepPartial, -): ConsentConfig { - const enabled = input?.enabled ?? file?.enabled ?? false - const categories = resolveConsentCategories( - input?.categories, - file?.categories, - ) - const services = resolveConsentServices( - input?.services, - file?.services, - categories, - ) - return { - enabled, - storageKey: resolveString( - input?.storageKey, - file?.storageKey, - 'ts-ssg-consent', - ), - policyVersion: resolveString( - input?.policyVersion, - file?.policyVersion, - '1', - ), - bannerTitle: resolveString( - input?.bannerTitle, - file?.bannerTitle, - 'Your privacy choices', - ), - bannerDescription: resolveString( - input?.bannerDescription, - file?.bannerDescription, - 'We use cookies and similar technologies to improve your experience. You can accept all, reject non-essential, or manage preferences.', - ), - privacyPolicyUrl: resolveConfigUrl( - resolveOptionalString(input?.privacyPolicyUrl ?? file?.privacyPolicyUrl), - ), - privacyPolicyLabel: resolveString( - input?.privacyPolicyLabel, - file?.privacyPolicyLabel, - 'Privacy Policy', - ), - acceptAllLabel: resolveString( - input?.acceptAllLabel, - file?.acceptAllLabel, - 'Accept all', - ), - rejectAllLabel: resolveString( - input?.rejectAllLabel, - file?.rejectAllLabel, - 'Reject non-essential', - ), - manageLabel: resolveString( - input?.manageLabel, - file?.manageLabel, - 'Manage preferences', - ), - saveLabel: resolveString(input?.saveLabel, file?.saveLabel, 'Save choices'), - settingsLabel: resolveString( - input?.settingsLabel, - file?.settingsLabel, - 'Privacy settings', - ), - categories, - services, - } -} - -function resolveAnalyticsConfig( - input?: DeepPartial, - file?: DeepPartial, -): AnalyticsConfig { - const ga4 = resolveGa4Config(input?.ga4, file?.ga4) - return { ga4 } -} - -function resolveGa4Config( - input?: DeepPartial, - file?: DeepPartial, -): Ga4Config { - const measurementId = resolveGa4MeasurementId( - input?.measurementId, - file?.measurementId, + plugins: readonly PureStackPlugin[], + file?: SiteConfigInput, +): SiteConfig { + assertValidPlugins(plugins) + return withPluginSkins(plugins, () => + resolveCoreSiteConfig(input, resolvePluginConfigResolvers(plugins), file), ) - const enabled = input?.enabled ?? file?.enabled ?? Boolean(measurementId) - if (enabled && !measurementId) { - throw new Error( - 'analytics.ga4.enabled is true but analytics.ga4.measurementId is missing.', - ) - } - return { - enabled, - measurementId, - serviceId: resolveString(input?.serviceId, file?.serviceId, 'ga4'), - consentCategory: resolveString( - input?.consentCategory, - file?.consentCategory, - 'analytics', - ), - } -} - -function resolveGa4MeasurementId(...values: Array) { - for (const value of values) { - const raw = resolveOptionalString(value) - if (!raw) continue - const normalized = raw.toUpperCase() - if (!/^G-[A-Z0-9]+$/.test(normalized)) { - throw new Error( - `analytics.ga4.measurementId must look like "G-XXXXXXXX" but got "${raw}".`, - ) - } - return normalized - } - return undefined -} - -function resolvePagefindConfig( - input?: Partial, - file?: Partial, -): PagefindConfig { - return { - enabled: input?.enabled ?? file?.enabled ?? true, - excludePaths: normalizePagefindExcludePaths( - input?.excludePaths ?? file?.excludePaths, - ), - } -} - -function resolvePreviewConfig( - input?: DeepPartial, - file?: DeepPartial, -): PreviewConfig { - return { - title: resolveOptionalString(input?.title ?? file?.title), - description: resolveOptionalString(input?.description ?? file?.description), - image: resolveOptionalString(input?.image ?? file?.image), - imageAlt: resolveOptionalString(input?.imageAlt ?? file?.imageAlt), - imageWidth: normalizePreviewImageDimension( - input?.imageWidth ?? file?.imageWidth, - ), - imageHeight: normalizePreviewImageDimension( - input?.imageHeight ?? file?.imageHeight, - ), - siteName: resolveOptionalString(input?.siteName ?? file?.siteName), - type: resolveOptionalString(input?.type ?? file?.type), - locale: resolveOptionalString(input?.locale ?? file?.locale), - twitterCard: resolveOptionalString(input?.twitterCard ?? file?.twitterCard), - twitterSite: resolveOptionalString(input?.twitterSite ?? file?.twitterSite), - twitterCreator: resolveOptionalString( - input?.twitterCreator ?? file?.twitterCreator, - ), - } -} - -function resolveI18nConfig( - input?: DeepPartial, - file?: DeepPartial, -): I18nConfig { - const rawLocales = input?.locales ?? file?.locales - const locales = normalizeLocales(rawLocales) - const defaultLocale = - normalizeLocale(input?.defaultLocale ?? file?.defaultLocale) ?? - locales[0] ?? - '' - const enabled = - pickBoolean(input?.enabled, file?.enabled, false) || locales.length > 0 - const resolvedLocales = enabled - ? normalizeLocales([defaultLocale, ...locales]) - : [] - if (enabled && !defaultLocale) { - throw new Error( - 'i18n.defaultLocale is required when i18n is enabled or locales are configured.', - ) - } - if (enabled && !resolvedLocales.includes(defaultLocale)) { - throw new Error('i18n.locales must include i18n.defaultLocale.') - } - return { - enabled, - defaultLocale: enabled ? defaultLocale : '', - locales: resolvedLocales, - urlStrategy: resolveI18nUrlStrategy( - input?.urlStrategy ?? file?.urlStrategy, - ), - queryParam: resolveString(input?.queryParam, file?.queryParam, 'lang'), - cookieName: resolveString( - input?.cookieName, - file?.cookieName, - 'ts-ssg.lang', - ), - } -} - -function normalizeLocales(value: unknown): string[] { - if (!Array.isArray(value)) return [] - const result: string[] = [] - const seen = new Set() - for (const entry of value) { - const locale = normalizeLocale(entry) - if (!locale || seen.has(locale)) continue - seen.add(locale) - result.push(locale) - } - return result -} - -function normalizeLocale(value: unknown): string | undefined { - const locale = resolveOptionalString(value) - if (!locale) return undefined - if (!/^[A-Za-z0-9-]+$/.test(locale)) { - throw new Error(`Invalid locale "${locale}".`) - } - return locale -} - -function resolveI18nUrlStrategy(value: unknown): I18nUrlStrategy { - if (value === 'hidden') return 'hidden' - return 'prefix-all' -} - -function normalizePreviewImageDimension(value: unknown) { - if (typeof value !== 'number' || !Number.isInteger(value)) return undefined - return value > 0 ? value : undefined -} - -function resolveMdxConfig( - input?: Partial, - file?: Partial, -): SiteMdxConfig { - return { - highlighter: resolveMdxHighlighter(input?.highlighter, file?.highlighter), - disableHighlighter: - input?.disableHighlighter ?? file?.disableHighlighter ?? false, - compileMdAsMdx: input?.compileMdAsMdx ?? file?.compileMdAsMdx ?? true, - } -} - -function resolveMdxHighlighter( - ...values: unknown[] -): SiteMdxConfig['highlighter'] { - for (const value of values) { - if (value === 'highlightjs') return 'highlightjs' - if (value === 'shiki') return 'shiki' - } - return 'highlightjs' -} - -function resolveConsentCategories( - input: Array> | undefined, - file: Array> | undefined, -) { - const source = Array.isArray(input) - ? input - : Array.isArray(file) - ? file - : DEFAULT_CONSENT_CATEGORIES - const categories: ConsentCategory[] = [] - const seen = new Set() - for (const entry of source) { - if (!isPlainObject(entry)) continue - const id = resolveOptionalString(entry.id) - if (!id) continue - if (seen.has(id)) { - throw new Error(`Duplicate consent category id "${id}".`) - } - const required = entry.required === true || id === 'necessary' - const label = resolveString(entry.label, toTitleCase(id)) - categories.push({ - id, - label, - description: resolveOptionalString(entry.description), - required, - }) - seen.add(id) - } - if (!seen.has('necessary')) { - categories.unshift({ - id: 'necessary', - label: 'Necessary', - description: 'Required for core site functionality.', - required: true, - }) - } - return categories -} - -function resolveConsentServices( - input: Array> | undefined, - file: Array> | undefined, - categories: ConsentCategory[], -) { - const source = Array.isArray(input) ? input : Array.isArray(file) ? file : [] - const categoryIds = new Set(categories.map((entry) => entry.id)) - const services: ConsentService[] = [] - const seenServiceIds = new Set() - for (const entry of source) { - if (!isPlainObject(entry)) continue - const id = resolveOptionalString(entry.id) - if (!id) continue - if (seenServiceIds.has(id)) { - throw new Error(`Duplicate consent service id "${id}".`) - } - const category = resolveOptionalString(entry.category) - if (!category || !categoryIds.has(category)) { - throw new Error( - `Consent service "${id}" references unknown category "${entry.category ?? ''}".`, - ) - } - const scripts = resolveConsentServiceScripts(entry.scripts, id) - services.push({ - id, - category, - label: resolveOptionalString(entry.label), - description: resolveOptionalString(entry.description), - scripts, - }) - seenServiceIds.add(id) - } - return services -} - -function resolveConsentServiceScripts( - scripts: Array> | undefined, - serviceId: string, -) { - if (!Array.isArray(scripts) || scripts.length === 0) { - throw new Error( - `Consent service "${serviceId}" must define at least one script.`, - ) - } - const resolved: ConsentScript[] = [] - for (const entry of scripts) { - if (!isPlainObject(entry)) continue - const src = resolveConfigUrl(resolveOptionalString(entry.src)) - const content = resolveOptionalString(entry.content) - if (!src && !content) { - throw new Error( - `Consent service "${serviceId}" has a script without "src" or "content".`, - ) - } - resolved.push({ - src, - content, - type: resolveOptionalString(entry.type), - async: entry.async === true, - defer: entry.defer === true, - integrity: resolveOptionalString(entry.integrity), - nonce: resolveOptionalString(entry.nonce), - crossOrigin: resolveCrossOrigin(entry.crossOrigin), - referrerPolicy: resolveOptionalString(entry.referrerPolicy), - }) - } - if (resolved.length === 0) { - throw new Error(`Consent service "${serviceId}" has no valid scripts.`) - } - return resolved -} - -function resolveCrossOrigin( - value: unknown, -): 'anonymous' | 'use-credentials' | undefined { - if (value === 'anonymous' || value === 'use-credentials') return value - return undefined -} - -function normalizeBaseUrl(value: string): string { - const normalized = value.trim() - let end = normalized.length - while (end > 0 && normalized[end - 1] === '/') --end - return normalized.slice(0, end) -} - -function resolveRobotsConfig( - input?: Partial, - file?: Partial, -): RobotsConfig { - return { - enabled: input?.enabled ?? file?.enabled ?? true, - fileName: resolveString(input?.fileName, file?.fileName, 'robots.txt'), - userAgent: resolveString(input?.userAgent, file?.userAgent, '*'), - allow: normalizeDirectiveList(input?.allow ?? file?.allow, ['/']), - disallow: normalizeDirectiveList(input?.disallow ?? file?.disallow, []), - crawlDelay: normalizeOptionalNumber(input?.crawlDelay ?? file?.crawlDelay), - host: resolveOptionalString(input?.host ?? file?.host), - additionalSitemaps: normalizeDirectiveList( - input?.additionalSitemaps ?? file?.additionalSitemaps, - [], - ), - customDirectives: normalizeDirectiveList( - input?.customDirectives ?? file?.customDirectives, - [], - ), - } -} - -function resolveOptionalString(value: unknown) { - if (typeof value !== 'string') return undefined - const normalized = value.trim() - return normalized.length > 0 ? normalized : undefined -} - -/** - * Site configuration lives at the content root, so its relative URLs start - * there: `assets/logo.svg` means `/assets/logo.svg` on every page. - */ -function resolveConfigUrl(value: string): string -function resolveConfigUrl(value: string | undefined): string | undefined -function resolveConfigUrl(value: string | undefined) { - if (!value || value.startsWith('/') || urlNormalizer.isSpecialHref(value)) { - return value - } - const { base, suffix } = urlNormalizer.splitSuffix(value) - return `${path.posix.normalize(`/${base.replaceAll('\\', '/')}`)}${suffix}` -} - -function normalizeOptionalNumber(value: unknown) { - if (typeof value !== 'number' || Number.isNaN(value)) return undefined - if (value < 0) return undefined - return value -} - -function normalizeDirectiveList(value: unknown, fallback: string[]) { - if (!Array.isArray(value)) return fallback - const normalized = value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - return normalized.length > 0 ? normalized : fallback -} - -function toTitleCase(value: string) { - const normalized = value.replaceAll(/[-_]+/g, ' ').trim() - if (normalized.length === 0) return 'Consent' - return normalized.replaceAll(/\b\w/g, (char) => char.toUpperCase()) -} - -function normalizePagefindExcludePaths(value: unknown): string[] { - if (!Array.isArray(value)) return [] - const unique = new Set() - for (const entry of value) { - if (typeof entry !== 'string') continue - const trimmed = entry.trim() - if (!trimmed) continue - const normalized = normalizeExcludePath(trimmed) - unique.add(normalized) - } - return [...unique] -} - -function normalizeExcludePath(pathname: string): string { - const normalized = pathname.replaceAll('\\', '/').trim() - if (normalized === '/') return '/' - const withLeading = normalized.startsWith('/') ? normalized : `/${normalized}` - return withLeading.endsWith('/') ? withLeading : `${withLeading}/` -} - -function loadSiteConfigFile(contentDir: string): SiteConfigInput { - const filePath = path.join(contentDir, SITE_CONFIG_FILENAME) - try { - const raw = fs.readFileSync(filePath, 'utf8') - const parsed = JSON.parse(raw) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('siteConfig.json must contain a JSON object.') - } - return parsed as SiteConfigInput - } catch (error) { - const err = error as NodeJS.ErrnoException - if (err.code === 'ENOENT') return {} - const message = err.message ?? String(err) - throw new Error(`Failed to read ${SITE_CONFIG_FILENAME}: ${message}`, { - cause: error, - }) - } } diff --git a/packages/ts-ssg/src/config/core-config.ts b/packages/ts-ssg/src/config/core-config.ts new file mode 100644 index 00000000..13f5c809 --- /dev/null +++ b/packages/ts-ssg/src/config/core-config.ts @@ -0,0 +1,314 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { + I18nConfig, + I18nUrlStrategy, + RobotsConfig, + SiteConfig, + SiteConfigDraft, + SiteConfigInput, + SiteConfigResolver, + SiteHtmlConfig, + SiteMdxConfig, + SitemapConfig, + SiteScriptsConfig, +} from '@purestack/ts-common' +import type { DeepPartial } from '@purestack/ts-util' +import { + clonePlainData, + isPlainObject, + merge, + normalizeBasePath, + pickBoolean, + resolveOptionalString, + resolveString, +} from '@purestack/ts-util' +import { resolveNavigationConfig } from '../navigation/navigation' + +const DEFAULT_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +) +export const SITE_CONFIG_FILENAME = 'siteConfig.json' + +export function resolveSiteContentDir(input: SiteConfigInput = {}) { + return ( + input.contentDir ?? + path.join(input.rootDir ?? DEFAULT_ROOT, 'sample-content') + ) +} + +export function resolveCoreSiteConfig( + input: SiteConfigInput = {}, + resolvers: readonly SiteConfigResolver[] = [], + fileConfig: SiteConfigInput = loadSiteConfigFile( + resolveSiteContentDir(input), + ), +): SiteConfig { + const rootDir = input.rootDir ?? DEFAULT_ROOT + const contentDir = resolveSiteContentDir(input) + const outDir = + input.outDir ?? + resolvePathFromFileConfig(fileConfig.outDir, contentDir) ?? + path.join(rootDir, 'dist', 'site') + const publishDir = + input.publishDir ?? + resolvePathFromFileConfig(fileConfig.publishDir, contentDir) ?? + path.join(rootDir, 'dist', 'publish') + const basePath = normalizeBasePath( + resolveString(input.basePath, fileConfig.basePath), + ) + const siteTitle = resolveString( + input.siteTitle, + fileConfig.siteTitle, + 'ts-ssg', + ) + const html = resolveHtmlConfig(input.html, fileConfig.html) + const navigation = resolveNavigationConfig( + input.navigation, + fileConfig.navigation, + ) + const scripts = resolveScriptsConfig(input.scripts, fileConfig.scripts) + const sitemap = resolveSitemapConfig(input.sitemap, fileConfig.sitemap) + const i18n = resolveI18nConfig(input.i18n, fileConfig.i18n) + const mdx = resolveMdxConfig(input.mdx, fileConfig.mdx) + 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: SiteConfigDraft = { + extensions: clonePlainData( + merge(fileConfig.extensions ?? {}, input.extensions), + ), + rootDir, + contentDir, + outDir, + publishDir, + basePath, + siteTitle, + html, + navigation, + scripts, + sitemap, + i18n, + mdx, + } + return applyConfigResolvers(config, resolvers, input, fileConfig) +} + +export function applyConfigResolvers( + config: SiteConfigDraft, + 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)) + } + // The selected owners have now normalized their sections and finalized them. + return config as SiteConfig +} + +function resolveScriptsConfig( + input?: DeepPartial, + file?: DeepPartial, +): SiteScriptsConfig { + return { + cacheBusting: pickBoolean(input?.cacheBusting, file?.cacheBusting, true), + } +} + +function resolveHtmlConfig( + input?: DeepPartial, + file?: DeepPartial, +): SiteHtmlConfig { + return { + minify: pickBoolean(input?.minify, file?.minify, false), + } +} + +function resolvePathFromFileConfig(value: unknown, contentDir: string) { + if (typeof value !== 'string' || value.length === 0) return undefined + if (path.isAbsolute(value)) return value + return path.resolve(contentDir, value) +} + +function resolveSitemapConfig( + input?: DeepPartial, + file?: DeepPartial, +): SitemapConfig { + const baseUrl = normalizeBaseUrl(input?.baseUrl ?? file?.baseUrl ?? '') + const enabled = input?.enabled ?? file?.enabled ?? false + const fileName = resolveString(input?.fileName, file?.fileName, 'sitemap.xml') + const robots = resolveRobotsConfig(input?.robots, file?.robots) + if (enabled && baseUrl.length === 0) { + throw new Error( + 'Sitemap is enabled but sitemap.baseUrl is missing. Provide an absolute base URL.', + ) + } + if (robots.enabled && robots.fileName.length === 0) { + throw new Error( + 'Sitemap robots config is enabled but sitemap.robots.fileName is empty.', + ) + } + return { + enabled, + baseUrl, + fileName, + robots, + } +} + +function resolveI18nConfig( + input?: DeepPartial, + file?: DeepPartial, +): I18nConfig { + const rawLocales = input?.locales ?? file?.locales + const locales = normalizeLocales(rawLocales) + const defaultLocale = + normalizeLocale(input?.defaultLocale ?? file?.defaultLocale) ?? + locales[0] ?? + '' + const enabled = + pickBoolean(input?.enabled, file?.enabled, false) || locales.length > 0 + const resolvedLocales = enabled + ? normalizeLocales([defaultLocale, ...locales]) + : [] + if (enabled && !defaultLocale) { + throw new Error( + 'i18n.defaultLocale is required when i18n is enabled or locales are configured.', + ) + } + if (enabled && !resolvedLocales.includes(defaultLocale)) { + throw new Error('i18n.locales must include i18n.defaultLocale.') + } + return { + enabled, + defaultLocale: enabled ? defaultLocale : '', + locales: resolvedLocales, + urlStrategy: resolveI18nUrlStrategy( + input?.urlStrategy ?? file?.urlStrategy, + ), + queryParam: resolveString(input?.queryParam, file?.queryParam, 'lang'), + cookieName: resolveString( + input?.cookieName, + file?.cookieName, + 'ts-ssg.lang', + ), + } +} + +function normalizeLocales(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const result: string[] = [] + const seen = new Set() + for (const entry of value) { + const locale = normalizeLocale(entry) + if (!locale || seen.has(locale)) continue + seen.add(locale) + result.push(locale) + } + return result +} + +function normalizeLocale(value: unknown): string | undefined { + const locale = resolveOptionalString(value) + if (!locale) return undefined + if (!/^[A-Za-z0-9-]+$/.test(locale)) { + throw new Error(`Invalid locale "${locale}".`) + } + return locale +} + +function resolveI18nUrlStrategy(value: unknown): I18nUrlStrategy { + if (value === 'hidden') return 'hidden' + return 'prefix-all' +} + +function resolveMdxConfig( + input?: Partial, + file?: Partial, +): SiteMdxConfig { + return { + ...((input?.highlighter ?? file?.highlighter) !== undefined + ? { highlighter: input?.highlighter ?? file?.highlighter } + : {}), + disableHighlighter: + input?.disableHighlighter ?? file?.disableHighlighter ?? false, + compileMdAsMdx: input?.compileMdAsMdx ?? file?.compileMdAsMdx ?? true, + } +} + +function normalizeBaseUrl(value: string): string { + const normalized = value.trim() + let end = normalized.length + while (end > 0 && normalized[end - 1] === '/') --end + return normalized.slice(0, end) +} + +function resolveRobotsConfig( + input?: Partial, + file?: Partial, +): RobotsConfig { + return { + enabled: input?.enabled ?? file?.enabled ?? true, + fileName: resolveString(input?.fileName, file?.fileName, 'robots.txt'), + userAgent: resolveString(input?.userAgent, file?.userAgent, '*'), + allow: normalizeDirectiveList(input?.allow ?? file?.allow, ['/']), + disallow: normalizeDirectiveList(input?.disallow ?? file?.disallow, []), + crawlDelay: normalizeOptionalNumber(input?.crawlDelay ?? file?.crawlDelay), + host: resolveOptionalString(input?.host ?? file?.host), + additionalSitemaps: normalizeDirectiveList( + input?.additionalSitemaps ?? file?.additionalSitemaps, + [], + ), + customDirectives: normalizeDirectiveList( + input?.customDirectives ?? file?.customDirectives, + [], + ), + } +} + +function normalizeOptionalNumber(value: unknown) { + if (typeof value !== 'number' || Number.isNaN(value)) return undefined + if (value < 0) return undefined + return value +} + +function normalizeDirectiveList(value: unknown, fallback: string[]) { + if (!Array.isArray(value)) return fallback + const normalized = value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + return normalized.length > 0 ? normalized : fallback +} + +export function loadSiteConfigFile( + contentDir: string, +): SiteConfigInput & { plugins?: string[]; defaultPlugins?: boolean } { + const filePath = path.join(contentDir, SITE_CONFIG_FILENAME) + try { + const raw = fs.readFileSync(filePath, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('siteConfig.json must contain a JSON object.') + } + return parsed as SiteConfigInput & { + plugins?: string[] + defaultPlugins?: boolean + } + } catch (error) { + const err = error as NodeJS.ErrnoException + if (err.code === 'ENOENT') return {} + const message = err.message ?? String(err) + throw new Error(`Failed to read ${SITE_CONFIG_FILENAME}: ${message}`, { + cause: error, + }) + } +} diff --git a/packages/ts-ssg/src/config/ga4-consent.test.ts b/packages/ts-ssg/src/config/ga4-consent.test.ts index aef85ed2..5a580b0b 100644 --- a/packages/ts-ssg/src/config/ga4-consent.test.ts +++ b/packages/ts-ssg/src/config/ga4-consent.test.ts @@ -1,6 +1,5 @@ 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' @@ -22,27 +21,24 @@ const site = JSON.parse( 'utf8', ), ) -const resolved = resolveSiteConfig( - { - consent: { +const resolved = resolveSiteConfig({ + consent: { + enabled: true, + policyVersion: '2026-10-07', + categories: [ + { id: 'necessary', required: true }, + { id: 'analytics', required: false }, + ], + }, + analytics: { + ga4: { enabled: true, - policyVersion: '2026-10-07', - categories: [ - { id: 'necessary', required: true }, - { id: 'analytics', required: false }, - ], - }, - analytics: { - ga4: { - enabled: true, - measurementId: 'G-TEST1234', - consentCategory: 'analytics', - serviceId: 'ga4', - }, + measurementId: 'G-TEST1234', + consentCategory: 'analytics', + serviceId: 'ga4', }, }, - [analyticsPlugin().config.finalize], -) +}) const config = resolved.consent const disableKey = 'ga-disable-G-TEST1234' diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 5b8f83c4..866b4c3d 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.15', + content: 'PureStack v1.1.16', }), ) } diff --git a/packages/ts-ssg/src/config/plugin-modules.test.ts b/packages/ts-ssg/src/config/plugin-modules.test.ts new file mode 100644 index 00000000..776c9447 --- /dev/null +++ b/packages/ts-ssg/src/config/plugin-modules.test.ts @@ -0,0 +1,449 @@ +import fs from 'node:fs/promises' +import net from 'node:net' +import path from 'node:path' +import { runInDom } from '@purestack/ts-minidom' +import { disableLogger, getLogger } from 'logpot' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { buildSite } from '../build/site' +import { runCli } from '../cli-runner' +import { type DevServerHandle, startDevServer } from '../dev/server' +import { makeRepoTempDir } from '../test/repoTempDir' +import { resolveConfiguredPlugins } from './plugin-modules' + +const marker = ` +export default function markerPlugin() { + let label = 'Not initialized' + return { + name: 'example:marker', + config: { normalize: ({ config }) => ({ extensions: { marker: { label: config.extensions.marker?.label ?? 'Default' } } }) }, + hooks: { + onConfigResolved({ config }) { label = config.extensions.marker.label }, + onPageDocument(_context, page) { page.document.body.setAttribute('data-marker', label) }, + }, + devMiddleware(request, response) { if (request.url === '/marker') response.end(label) }, + } +} +export { markerPlugin } +` + +const layout = ` +export const layoutPlugin = { + name: 'example:layout', + templates: { + doc: ({ head, bodyHtml }) => ({ toHtml: () => '' + head.toHtml() + '' + bodyHtml + '' }), + }, +} +` + +describe('siteConfig.json plugin modules', () => { + let root: string + let server: DevServerHandle | undefined + beforeAll(() => disableLogger()) + afterAll(() => getLogger().close()) + afterEach(async () => { + await server?.close() + server = undefined + if (root) await fs.rm(root, { recursive: true, force: true }) + }) + async function fixture( + plugins: unknown = ['example-plugin'], + code = marker, + packageName = 'example-plugin', + ) { + root = await makeRepoTempDir('.tmp-json-plugins-') + const contentDir = path.join(root, 'content') + const packageDir = path.join(root, 'node_modules', packageName) + await fs.mkdir(contentDir) + await fs.mkdir(packageDir, { recursive: true }) + await fs.writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: packageName, + type: 'module', + exports: { + '.': { import: './index.mjs', require: './unused.cjs' }, + './plugin': './index.mjs', + }, + }), + ) + await fs.writeFile(path.join(packageDir, 'index.mjs'), code) + await fs.writeFile(path.join(contentDir, 'index.md'), '# Home') + const config = { + outDir: '../out', + publishDir: '../publish', + defaultPlugins: undefined as boolean | undefined, + plugins, + extensions: { marker: { label: 'From JSON' } }, + navigation: { mode: 'none' }, + pagefind: { enabled: false }, + mdx: { disableHighlighter: true }, + } + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + return { contentDir, config } + } + + it('builds and publishes from JSON alone using an installed ESM plugin and its settings', async () => { + const { contentDir } = await fixture() + for (const command of ['build', 'publish']) { + await runCli([command, '--content', contentDir]) + const html = await fs.readFile( + path.join(root, command === 'build' ? 'out' : 'publish', 'index.html'), + 'utf8', + ) + expect(html).toMatch(/data-marker=(?:"From JSON"|'From JSON')/) + } + }) + + it('applies the same selection through the programmatic builder', async () => { + const { contentDir } = await fixture() + await buildSite({ siteConfig: { contentDir } }) + expect( + await fs.readFile(path.join(root, 'out', 'index.html'), 'utf8'), + ).toContain('data-marker="From JSON"') + }) + + it.each([ + [undefined, undefined, undefined, true], + [undefined, undefined, [], true], + [true, undefined, ['example-plugin'], true], + [false, undefined, undefined, false], + [false, undefined, [], false], + [false, undefined, ['example-plugin'], false], + [false, true, ['example-plugin'], true], + [true, false, undefined, false], + ] as const)( + 'resolves defaults (JSON=%s, explicit=%s, declarations=%j)', + async (fileDefault, explicitDefault, declarations, defaultsEnabled) => { + const { contentDir, config } = await fixture() + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ + ...config, + plugins: declarations, + defaultPlugins: fileDefault, + }), + ) + const selected = await resolveConfiguredPlugins({ + siteConfig: { contentDir }, + options: { + defaultPlugins: explicitDefault, + plugins: [{ name: 'explicit' }], + }, + }) + const names = selected.plugins.map((plugin) => plugin.name) + const additions = [ + ...(declarations?.length ? ['example:marker'] : []), + 'explicit', + ] + if (defaultsEnabled) { + expect(names).toEqual( + expect.arrayContaining([ + 'purestack:theme', + 'purestack:components', + 'purestack:pagefind', + ...additions, + ]), + ) + } else expect(names).toEqual(additions) + }, + ) + + it.each([null, 'false', 0, [], {}])( + 'rejects a non-boolean defaultPlugins value (%j) without module declarations', + async (defaultPlugins) => { + const { contentDir, config } = await fixture() + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ ...config, plugins: undefined, defaultPlugins }), + ) + await expect(buildSite({ siteConfig: { contentDir } })).rejects.toThrow( + 'siteConfig.json defaultPlugins must be a boolean.', + ) + }, + ) + + it('builds and publishes with only the plugins selected in JSON', async () => { + const { contentDir, config } = await fixture( + ['example-plugin#layoutPlugin', 'example-plugin#markerPlugin'], + `${marker}\n${layout}`, + ) + config.defaultPlugins = false + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + for (const command of ['build', 'publish']) { + await runCli([command, '--content', contentDir]) + const html = await fs.readFile( + path.join(root, command === 'build' ? 'out' : 'publish', 'index.html'), + 'utf8', + ) + await runInDom(html, (document) => { + expect(document.body.getAttribute('data-marker')).toBe('From JSON') + expect(document.querySelector('h1')?.textContent).toBe('Home') + expect(document.querySelector('link[rel="stylesheet"]')).toBeNull() + }) + } + }) + + it('lets the TypeScript config override the JSON defaultPlugins flag', async () => { + const { contentDir, config } = await fixture() + config.defaultPlugins = false + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + await fs.writeFile( + path.join(contentDir, 'purestack.config.ts'), + 'export default { defaultPlugins: true }', + ) + await runCli(['build', '--content', contentDir]) + const html = await fs.readFile(path.join(root, 'out', 'index.html'), 'utf8') + expect(html).toContain('data-marker="From JSON"') + expect(html).toContain('data-theme="light"') + }) + + it('builds and publishes with multiple named plugins from a package without a default export', async () => { + const { contentDir } = await fixture( + ['example-plugin#markerPlugin', 'example-plugin#auditPlugin'], + `${marker.replace('export default function', 'function')} +export const auditPlugin = { + name: 'example:audit', + hooks: { onPageDocument(_context, page) { page.document.body.setAttribute('data-audit', 'Enabled') } }, +}`, + ) + for (const command of ['build', 'publish']) { + await runCli([command, '--content', contentDir]) + const html = await fs.readFile( + path.join(root, command === 'build' ? 'out' : 'publish', 'index.html'), + 'utf8', + ) + await runInDom(html, (document) => { + expect(document.body.getAttribute('data-marker')).toBe('From JSON') + expect(document.body.getAttribute('data-audit')).toBe('Enabled') + }) + } + }) + + it.each([ + [ + 'object', + 'example-plugin/plugin', + 'export default { name: "subpath-plugin" }', + ], + [ + 'async factory', + 'example-plugin/plugin', + 'export default async () => ({ name: "subpath-plugin" })', + ], + [ + 'explicit default', + 'example-plugin/plugin#default', + 'export default { name: "subpath-plugin" }', + ], + ])( + 'supports package subpaths with a default-exported %s', + async (_, reference, code) => { + const { contentDir } = await fixture([reference], code) + const selected = await resolveConfiguredPlugins({ + siteConfig: { contentDir }, + options: { defaultPlugins: false }, + }) + expect(selected.plugins.map((plugin) => plugin.name)).toEqual([ + 'subpath-plugin', + ]) + }, + ) + + it.each([ + ['object', 'export const selectedPlugin = { name: "named-plugin" }'], + [ + 'async factory', + 'export async function selectedPlugin() { return { name: "named-plugin" } }', + ], + ])('supports a named %s from a scoped package subpath', async (_, code) => { + const { contentDir } = await fixture( + ['@example/plugins/plugin#selectedPlugin'], + code, + '@example/plugins', + ) + const selected = await resolveConfiguredPlugins({ + siteConfig: { contentDir }, + options: { defaultPlugins: false }, + }) + expect(selected.plugins.map((plugin) => plugin.name)).toEqual([ + 'named-plugin', + ]) + }) + + it('lets an explicit plugin instance replace a JSON-selected owner', async () => { + const { contentDir } = await fixture(['example-plugin#markerPlugin']) + const explicit = { name: 'example:marker' } + const selected = await resolveConfiguredPlugins({ + siteConfig: { contentDir }, + options: { defaultPlugins: false, plugins: [explicit] }, + }) + expect(selected.plugins).toEqual([explicit]) + }) + + it.each([ + null, + {}, + true, + [null], + ['../local.mjs'], + ['https://example.com/plugin.mjs'], + ['example-plugin/../other'], + ['example-plugin/.#markerPlugin'], + ['example-plugin#'], + ['example-plugin##markerPlugin'], + ['example-plugin#markerPlugin#other'], + ['example-plugin#invalid-name'], + ['example-plugin#123'], + ['example-plugin#markerPlugin()'], + ['example-plugin', 'example-plugin'], + ['example-plugin', 'example-plugin#default'], + ['example-plugin#markerPlugin', 'example-plugin#markerPlugin'], + ])('rejects invalid module declarations (%j)', async (plugins) => { + const { contentDir } = await fixture(plugins) + await expect(buildSite({ siteConfig: { contentDir } })).rejects.toThrow( + /siteConfig.json plugins/, + ) + }) + + it('rejects different exports that declare the same plugin name', async () => { + const { contentDir } = await fixture([ + 'example-plugin', + 'example-plugin#markerPlugin', + ]) + await expect(buildSite({ siteConfig: { contentDir } })).rejects.toThrow( + /plugin "example:marker" is listed more than once/, + ) + }) + + it.each([ + [ + 'missing-optional-integration', + marker, + /Could not load plugin "missing-optional-integration".*Could not resolve/s, + ], + [ + 'example-plugin', + 'export const plugin = {}', + /Could not load plugin "example-plugin"/, + ], + [ + 'example-plugin#missingPlugin', + marker, + /Could not load plugin "example-plugin#missingPlugin".*missingPlugin/, + ], + [ + 'example-plugin#invalidPlugin', + `${marker}\nexport const invalidPlugin = 42`, + /Could not load plugin "example-plugin#invalidPlugin"/, + ], + [ + 'example-plugin', + 'export default () => ({ name: "bad", invalid: true })', + /unknown field "invalid"/, + ], + [ + 'example-plugin', + 'export default () => { throw new Error("Factory failed") }', + /Factory failed/, + ], + ])( + 'reports the module and original error for %s', + async (specifier, code, error) => { + const { contentDir } = await fixture([specifier], code) + const result = buildSite({ siteConfig: { contentDir } }) + await expect(result).rejects.toThrow(error) + await expect(result).rejects.not.toThrow('Install the package') + }, + ) + + it('reloads JSON plugin selection, defaults, settings, and dev middleware', async () => { + const { contentDir, config } = await fixture( + undefined, + `${marker}\n${layout}`, + ) + const port = await freePort() + server = await startDevServer({ + host: '127.0.0.1', + port, + liveReload: false, + build: { siteConfig: { contentDir } }, + }) + const origin = `http://127.0.0.1:${port}` + expect(await (await fetch(`${origin}/marker`)).text()).toBe('From JSON') + expect(await (await fetch(origin)).text()).toContain( + 'data-marker="From JSON"', + ) + config.extensions.marker.label = 'Reloaded' + config.plugins = ['example-plugin#markerPlugin'] + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + await waitFor( + async () => + (await (await fetch(`${origin}/marker`)).text()) === 'Reloaded', + ) + expect(await (await fetch(origin)).text()).toContain( + 'data-marker="Reloaded"', + ) + config.defaultPlugins = false + config.plugins = [ + 'example-plugin#layoutPlugin', + 'example-plugin#markerPlugin', + ] + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + await waitFor(async () => { + const html = await (await fetch(origin)).text() + return ( + html.includes('data-marker="Reloaded"') && + !html.includes('data-theme="light"') + ) + }) + expect(await (await fetch(`${origin}/marker`)).text()).toBe('Reloaded') + config.defaultPlugins = true + config.plugins = [] + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + await waitFor(async () => { + const html = await (await fetch(origin)).text() + return ( + !html.includes('data-marker=') && html.includes('data-theme="light"') + ) + }) + }) +}) + +async function freePort() { + const probe = net.createServer() + await new Promise((resolve, reject) => { + probe.once('error', reject) + probe.listen(0, '127.0.0.1', resolve) + }) + const address = probe.address() + await new Promise((resolve) => probe.close(() => resolve())) + if (!address || typeof address === 'string') throw new Error('Expected port') + return address.port +} + +async function waitFor(check: () => Promise) { + const deadline = Date.now() + 3000 + while (Date.now() < deadline) { + if (await check()) return + await new Promise((resolve) => setTimeout(resolve, 25)) + } + throw new Error('Expected configuration reload') +} diff --git a/packages/ts-ssg/src/config/plugin-modules.ts b/packages/ts-ssg/src/config/plugin-modules.ts new file mode 100644 index 00000000..43ddab52 --- /dev/null +++ b/packages/ts-ssg/src/config/plugin-modules.ts @@ -0,0 +1,87 @@ +import path from 'node:path' +import type { BuildInput } from '../build/site' +import { resolveBuildPlugins } from '../plugins/default-plugins' +import { assertValidPlugins } from '../plugins/plugin' +import type { PureStackPlugin } from '../plugins/types' +import { + loadSiteConfigFile, + resolveSiteContentDir, + SITE_CONFIG_FILENAME, +} from './core-config' +import { loadPluginExport } from './project-config' + +/** Package declarations are build setup; each integration still owns its settings. */ +export async function resolveConfiguredPlugins(input: BuildInput) { + const contentDir = resolveSiteContentDir(input.siteConfig) + const file = loadSiteConfigFile(contentDir) + if ( + file.defaultPlugins !== undefined && + typeof file.defaultPlugins !== 'boolean' + ) + throw new Error('siteConfig.json defaultPlugins must be a boolean.') + const options = { + ...input.options, + defaultPlugins: input.options?.defaultPlugins ?? file.defaultPlugins, + } + const modules: unknown = file.plugins + if (modules === undefined) + return { file, plugins: resolveBuildPlugins(options) } + if (!Array.isArray(modules)) + throw new Error( + 'siteConfig.json plugins must be an array of installed package names.', + ) + const seen = new Set() + const declared: PureStackPlugin[] = [] + for (const [index, reference] of modules.entries()) { + const match = + typeof reference === 'string' + ? /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:\/[a-zA-Z0-9._-]+)*)(?:#([a-zA-Z_$][a-zA-Z0-9_$]*))?$/.exec( + reference, + ) + : null + if ( + !match || + match[1].split('/').some((part) => part === '.' || part === '..') + ) + throw new Error( + `siteConfig.json plugins[${index}] must be an installed npm package name, optionally with a subpath and #exportName.`, + ) + const [, specifier, exportName = 'default'] = match + const key = `${specifier}#${exportName}` + if (seen.has(key)) + throw new Error( + `siteConfig.json plugins declares "${key}" more than once.`, + ) + seen.add(key) + try { + const exported = await loadPluginExport( + specifier, + exportName, + path.resolve(contentDir, SITE_CONFIG_FILENAME), + ) + const plugin: unknown = + typeof exported === 'function' ? await exported() : exported + assertValidPlugins([plugin as PureStackPlugin]) + declared.push(plugin as PureStackPlugin) + } catch (error) { + throw new Error( + `Could not load plugin "${reference}" from siteConfig.json: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ) + } + } + assertValidPlugins(declared) + // Explicit TypeScript/programmatic instances can customize a declared owner. + const supplied = options.plugins ?? [] + const replacements = new Set(supplied.map((plugin) => plugin.name)) + return { + file, + plugins: resolveBuildPlugins({ + ...options, + plugins: [ + ...declared.filter((plugin) => !replacements.has(plugin.name)), + ...supplied, + ], + }), + } +} diff --git a/packages/ts-ssg/src/config/project-config.test.ts b/packages/ts-ssg/src/config/project-config.test.ts index 29e58d2e..2db9735d 100644 --- a/packages/ts-ssg/src/config/project-config.test.ts +++ b/packages/ts-ssg/src/config/project-config.test.ts @@ -7,6 +7,7 @@ import { makeRepoTempDir } from '../test/repoTempDir' import { defineConfig, findProjectConfig, + loadPluginExport, loadProjectConfig, PROJECT_CONFIG_FILENAME, withProjectConfig, @@ -86,6 +87,60 @@ describe('purestack.config.ts', () => { ) }) + it("uses PureStack's package copy for both JSON declarations and TypeScript imports", async () => { + const projectRoot = await createProject({ + 'node_modules/logpot/package.json': JSON.stringify({ + name: 'logpot', + type: 'module', + exports: './index.mjs', + }), + 'node_modules/logpot/index.mjs': + 'throw new Error("The site copy must not load"); export function getLogger() {}', + 'content/purestack.config.ts': [ + "import { getLogger } from 'logpot'", + 'export default { plugins: [{ name: "shared", hooks: { onBuildComplete: getLogger } }] }', + ].join('\n'), + }) + const contentDir = path.join(projectRoot, 'content') + const fromJson = await loadPluginExport( + 'logpot', + 'getLogger', + path.join(contentDir, 'siteConfig.json'), + ) + const fromTs = await loadProjectConfig( + path.join(contentDir, PROJECT_CONFIG_FILENAME), + ) + expect(fromJson).toBeTypeOf('function') + expect(fromJson).toBe(fromTs.config.plugins?.[0].hooks?.onBuildComplete) + }) + + it('uses the same site-only package copy for JSON declarations and TypeScript imports', async () => { + const projectRoot = await createProject({ + 'node_modules/site-only-plugin/package.json': JSON.stringify({ + name: 'site-only-plugin', + type: 'module', + exports: './index.mjs', + }), + 'node_modules/site-only-plugin/index.mjs': + 'export const plugin = { name: "site-only" }', + 'content/purestack.config.ts': [ + "import { plugin } from 'site-only-plugin'", + 'export default { plugins: [plugin] }', + ].join('\n'), + }) + const contentDir = path.join(projectRoot, 'content') + const fromJson = await loadPluginExport( + 'site-only-plugin', + 'plugin', + path.join(contentDir, 'siteConfig.json'), + ) + const fromTs = await loadProjectConfig( + path.join(contentDir, PROJECT_CONFIG_FILENAME), + ) + expect(fromJson).toEqual({ name: 'site-only' }) + expect(fromJson).toBe(fromTs.config.plugins?.[0]) + }) + it.each([ [ 'export const plugins = []', diff --git a/packages/ts-ssg/src/config/project-config.ts b/packages/ts-ssg/src/config/project-config.ts index 6438782e..7657361f 100644 --- a/packages/ts-ssg/src/config/project-config.ts +++ b/packages/ts-ssg/src/config/project-config.ts @@ -95,9 +95,42 @@ export async function loadProjectConfig( } } -function bundleConfig(filePath: string, configDir: string) { +/** Resolve installed plugin packages using ESM exports, without downloading anything. */ +export async function loadPluginExport( + specifier: string, + exportName: string, + siteConfigPath: string, +): Promise { + const bundled = await bundleConfig( + siteConfigPath, + path.dirname(siteConfigPath), + { specifier, exportName }, + ) + const code = bundled.outputFiles[0]?.text ?? '' + const module = await import( + `data:text/javascript;base64,${Buffer.from(code).toString('base64')}` + ) + return module.default +} + +function bundleConfig( + filePath: string, + configDir: string, + selectedExport?: { specifier: string; exportName: string }, +) { return build({ - entryPoints: [filePath], + ...(selectedExport === undefined + ? { entryPoints: [filePath] } + : { + // JSON declarations name installed packages, not TypeScript path aliases. + tsconfigRaw: { compilerOptions: {} }, + stdin: { + contents: `export { ${JSON.stringify(selectedExport.exportName)} as default } from ${JSON.stringify(selectedExport.specifier)}`, + resolveDir: configDir, + sourcefile: filePath, + loader: 'js' as const, + }, + }), absWorkingDir: configDir, bundle: true, write: false, diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index 2d10c178..ad55a335 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -4,9 +4,8 @@ import path from 'node:path' import type { I18nConfig } from '@purestack/ts-common' import { logError, stripBasePath, withBasePath } from '@purestack/ts-util' import { getLogger, type Logger } from 'logpot' -import { resolveBuildSiteConfig } from '../build/build-config' import { - createIncrementalBuilder, + createIncrementalBuildSession, type IncrementalBuilder, } from '../build/incremental' import { ensureLogger } from '../build/logger' @@ -98,8 +97,9 @@ export async function startDevServer( } const projectConfig = trackProjectConfig(input.configFile) let buildInput = await projectConfig.load(baseBuildInput) - const config = resolveBuildSiteConfig(buildInput) - let devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? []) + const session = await createIncrementalBuildSession(buildInput) + const config = session.config + let devMiddleware = composeDevMiddleware(session.plugins) const log = getLogger() const { host, port, watch, liveReload, fullRender } = @@ -186,7 +186,7 @@ export async function startDevServer( } const displayHost = host === '0.0.0.0' ? LOOPBACK_HOST : host - let incremental = await createIncrementalBuilder(buildInput) + let incremental = session.builder const rebuild = async ( reason: string, @@ -206,10 +206,9 @@ export async function startDevServer( watchConfigDependencies() } if (options?.recreateBuilder || options?.reloadConfig) { - incremental = await createIncrementalBuilder(buildInput) - } - if (options?.reloadConfig) { - devMiddleware = composeDevMiddleware(buildInput.options?.plugins ?? []) + const next = await createIncrementalBuildSession(buildInput) + incremental = next.builder + devMiddleware = composeDevMiddleware(next.plugins) } if (fullRender && !initialSetupDone) { await incremental.buildAll(reason) diff --git a/packages/ts-ssg/src/dev/static-files.ts b/packages/ts-ssg/src/dev/static-files.ts index 4acac8ad..2210e929 100644 --- a/packages/ts-ssg/src/dev/static-files.ts +++ b/packages/ts-ssg/src/dev/static-files.ts @@ -135,6 +135,8 @@ function contentTypeForExt(ext: string) { return 'application/xml; charset=utf-8' case '.webp': return 'image/webp' + case '.avif': + return 'image/avif' default: return undefined } diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts index 8a624a1f..53f10c53 100644 --- a/packages/ts-ssg/src/index.ts +++ b/packages/ts-ssg/src/index.ts @@ -1,5 +1,8 @@ export type { + AssetSource, + CoreSiteConfig, MdxCodeHighlighter, + PageAssetInput, PageContext, PageFrontmatter, PageHeadConfigInput, @@ -9,8 +12,11 @@ export type { PageTemplate, PageTemplateInput, PageTemplateMap, + SiteAssets, SiteConfig, SiteConfigContributions, + SiteConfigDraft, + SiteConfigFinalizer, SiteConfigInput, SiteConfigResolution, SiteConfigResolver, @@ -35,7 +41,7 @@ export { type PublishOptions, } from './build/site' export { runCli } from './cli-runner' -export { resolveSiteConfig } from './config/config' +export { type DefaultSiteConfig, resolveSiteConfig } from './config/config' export { defineConfig, type PureStackConfig } from './config/project-config' export { type DevServerHandle, diff --git a/packages/ts-ssg/src/plugins/assets.test.ts b/packages/ts-ssg/src/plugins/assets.test.ts new file mode 100644 index 00000000..ee0b6616 --- /dev/null +++ b/packages/ts-ssg/src/plugins/assets.test.ts @@ -0,0 +1,575 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import type { PageAssetInput, SiteAssets } from '@purestack/ts-common' +import { componentsPlugin } from '@purestack/ts-components' +import { h } from '@purestack/ts-html' +import { disableLogger, getLogger } from 'logpot' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { createIncrementalBuilder } from '../build/incremental' +import { readManifest } from '../build/manifest' +import { type BuildInput, buildSite } from '../build/site' +import { makeRepoTempDir } from '../test/repoTempDir' +import type { PureStackPlugin } from './types' + +describe('generic page assets', () => { + let root: string | undefined + beforeAll(() => disableLogger()) + afterAll(() => getLogger().close()) + afterEach(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }) + }) + async function fixture(process?: SiteAssets['processPage']) { + root = await makeRepoTempDir('.tmp-page-assets-') + const contentDir = path.join(root, 'content'), + outDir = path.join(root, 'out') + await fs.mkdir(contentDir) + const processor: PureStackPlugin = { + name: 'files', + assets: () => ({ + namespace: 'files', + processPage: + process ?? + (async (input) => { + for (const element of input.document.querySelectorAll( + 'a[data-asset]', + )) { + const source = await input.resolveSource( + element, + element.getAttribute('href') ?? '', + ) + if (!source) continue + const data = await fs.readFile(source.absPath) + const url = await input.emit( + `${createHash('sha256').update(data).digest('hex')}.txt`, + data, + ) + element.setAttribute('href', url) + } + }), + }), + } + const input: BuildInput = { + siteConfig: { + rootDir: root, + contentDir, + outDir, + navigation: { mode: 'none' }, + mdx: { disableHighlighter: true }, + }, + options: { + defaultPlugins: false, + plugins: [ + { + name: 'layout', + templates: { + doc: ({ head, bodyHtml }) => + h('html').push(head, h('body').raw(bodyHtml)), + }, + }, + processor, + ], + }, + } + await fs.writeFile( + path.join(contentDir, 'a.md'), + 'A', + ) + await fs.writeFile( + path.join(contentDir, 'b.md'), + 'B', + ) + await fs.writeFile(path.join(contentDir, 'source.txt'), 'First') + return { input, contentDir, outDir } + } + + it('records dependencies, reuses shared output, rebuilds on asset edits, and prunes only after the final reference disappears', async () => { + const { input, contentDir, outDir } = await fixture() + const builder = await createIncrementalBuilder(input) + await builder.buildAll('test') + const first = await readManifest(outDir) + expect(first?.pageAssets?.['a.md'].dependencies).toEqual(['source.txt']) + const file = first?.pageAssets?.['a.md'].outputs[0].path + if (!file) throw new Error('Expected owned output') + expect(first?.pageAssets?.['b.md'].outputs[0].path).toBe(file) + expect(await fs.readFile(path.join(outDir, file), 'utf8')).toBe('First') + await fs.writeFile(path.join(contentDir, 'source.txt'), 'Second') + expect( + (await builder.applyChange(path.join(contentDir, 'source.txt'))) + .changedPages, + ).toBe(2) + const second = await readManifest(outDir) + const next = second?.pageAssets?.['a.md'].outputs[0].path + expect(next).not.toBe(file) + await expect(fs.stat(path.join(outDir, file))).rejects.toMatchObject({ + code: 'ENOENT', + }) + await fs.writeFile(path.join(contentDir, 'a.md'), '# No asset') + await builder.applyChange(path.join(contentDir, 'a.md')) + expect(await fs.readFile(path.join(outDir, next ?? ''), 'utf8')).toBe( + 'Second', + ) + await fs.writeFile(path.join(contentDir, 'b.md'), '# No asset') + await builder.applyChange(path.join(contentDir, 'b.md')) + await expect(fs.stat(path.join(outDir, next ?? ''))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(await fs.readFile(path.join(outDir, 'source.txt'), 'utf8')).toBe( + 'Second', + ) + }) + + it('repairs same-size corrupted output instead of trusting its content-addressed filename', async () => { + const { input, outDir } = await fixture() + input.options = { ...input.options, cleanOutDir: false } + await buildSite(input) + const manifest = await readManifest(outDir) + const file = manifest?.pageAssets?.['a.md'].outputs[0].path + if (!file) throw new Error('Expected generated file') + await fs.writeFile(path.join(outDir, file), 'Other') + await buildSite(input) + expect(await fs.readFile(path.join(outDir, file), 'utf8')).toBe('First') + }) + + it.skipIf(process.platform !== 'win32')( + 'recovers missing dependencies after a differently cased Windows watcher event', + async () => { + const { input, contentDir, outDir } = await fixture() + input.options = { ...input.options, writeErrorPages: true } + await fs.rm(path.join(contentDir, 'source.txt')) + const builder = await createIncrementalBuilder(input) + await builder.prepareForRequests() + await builder.renderByUrlPath('/a/') + expect( + await fs.readFile(path.join(outDir, 'a', 'index.html'), 'utf8'), + ).toContain('missing file') + await fs.writeFile(path.join(contentDir, 'source.txt'), 'Recovered') + await builder.applyChange(path.join(contentDir, 'SOURCE.TXT')) + expect( + await builder.renderIfDirtyByOutPath( + path.join(outDir, 'a', 'index.html'), + ), + ).toBe(true) + const manifest = await readManifest(outDir) + const file = manifest?.pageAssets?.['a.md'].outputs[0].path + if (!file) throw new Error('Expected generated output after recovery') + expect(await fs.readFile(path.join(outDir, file), 'utf8')).toBe( + 'Recovered', + ) + }, + ) + + it('restores dependency tracking from a manifest and cleans up when its processor is removed', async () => { + const { input, contentDir, outDir } = await fixture() + await fs.mkdir(path.join(contentDir, 'nested')) + await fs.rm(path.join(contentDir, 'a.md')) + await fs.writeFile( + path.join(contentDir, 'nested', 'a.md'), + 'A', + ) + await buildSite(input) + const manifest = await readManifest(outDir) + const file = + manifest?.pageAssets?.[path.join('nested', 'a.md')]?.outputs[0].path + if (!file) throw new Error('Expected output') + const resumed = await createIncrementalBuilder(input) + await fs.writeFile(path.join(contentDir, 'source.txt'), 'New bytes') + expect( + (await resumed.applyChange(path.join(contentDir, 'source.txt'))) + .changedPages, + ).toBe(2) + const refreshed = await readManifest(outDir) + const next = + refreshed?.pageAssets?.[path.join('nested', 'a.md')]?.outputs[0].path + const plugins = input.options?.plugins?.filter( + (plugin) => plugin.name !== 'files', + ) + await buildSite({ ...input, options: { defaultPlugins: false, plugins } }) + await expect(fs.stat(path.join(outDir, next ?? ''))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect((await readManifest(outDir))?.pageAssets).toBeUndefined() + }) + + it('does not prune a shared file already emitted by an uncommitted concurrent page', async () => { + let release: (() => void) | undefined, began: (() => void) | undefined + const barrier = new Promise((resolve) => { + release = resolve + }) + const started = new Promise((resolve) => { + began = resolve + }) + const { input, contentDir, outDir } = await fixture(async (input) => { + const element = input.document.querySelector('a') + if (!element) return + await input.resolveSource(element, element.getAttribute('href') ?? '') + element.setAttribute( + 'href', + await input.emit('shared.txt', Buffer.from('Shared')), + ) + if (input.sourceRelPath === 'c.md') { + began?.() + await barrier + } + }) + await fs.writeFile(path.join(contentDir, 'b.md'), '# No asset') + await fs.writeFile(path.join(contentDir, 'c.md'), '# Not rendered yet') + const builder = await createIncrementalBuilder(input) + await builder.buildAll('shared asset') + await builder.prepareForRequests() + await fs.writeFile( + path.join(contentDir, 'c.md'), + 'C', + ) + await builder.applyChange(path.join(contentDir, 'c.md')) + const pending = builder.renderByUrlPath('/c/') + await started + await fs.writeFile(path.join(contentDir, 'a.md'), '# Removed reference') + await builder.applyChange(path.join(contentDir, 'a.md')) + await builder.renderByUrlPath('/a/') + expect( + await fs.readFile( + path.join(outDir, '_assets', 'files', 'shared.txt'), + 'utf8', + ), + ).toBe('Shared') + release?.() + expect(await pending).toBe(true) + expect( + (await readManifest(outDir))?.pageAssets?.['c.md'].outputs[0].path, + ).toBe('_assets/files/shared.txt') + }) + + it('keeps generated files leased to concurrent renders and marks an in-flight dependent dirty', async () => { + let release: (() => void) | undefined + const pending = new Promise((resolve) => { + release = resolve + }) + let began: (() => void) | undefined + const started = new Promise((resolve) => { + began = resolve + }) + const { input, contentDir } = await fixture(async (input) => { + const element = input.document.querySelector('a') + if (!element) return + await input.resolveSource(element, './source.txt') + began?.() + await pending + element.setAttribute( + 'href', + await input.emit('shared.txt', Buffer.from('Shared')), + ) + }) + const builder = await createIncrementalBuilder(input) + await builder.prepareForRequests() + const rendering = builder.renderByUrlPath('/a/') + await started + await fs.writeFile( + path.join(contentDir, 'source.txt'), + 'Changed during render', + ) + const changed = await builder.applyChange( + path.join(contentDir, 'source.txt'), + ) + expect(changed.markedPages).toBeGreaterThan(0) + release?.() + expect(await rendering).toBe(true) + expect( + await builder.renderIfDirtyByOutPath( + path.join(input.siteConfig?.outDir ?? '', 'a', 'index.html'), + ), + ).toBe(true) + }) + + it.each(['../outside.txt', '/absolute.txt', 'folder/../../outside.txt'])( + 'rejects escaping output names (%s)', + async (name) => { + const { input } = await fixture(async (input) => { + await input.emit(name, Buffer.from('No')) + }) + await expect(buildSite(input)).rejects.toThrow( + /Plugin "files" failed in assets.processPage.*relative file path/, + ) + }, + ) + + it('rejects conflicting bytes and attributes processor errors', async () => { + const { input } = await fixture(async (input) => { + await input.emit('fixed.txt', Buffer.from('One')) + await input.emit('fixed.txt', Buffer.from('Two')) + }) + await expect(buildSite(input)).rejects.toThrow( + /Plugin "files" failed in assets.processPage.*different contents/, + ) + }) + + it('releases outputs from an error page while retaining its recovery dependencies', async () => { + const { input, outDir } = await fixture(async (input) => { + input.dependOn('source.txt') + await input.emit('unused.txt', Buffer.from('Unused')) + throw new Error('Cannot render') + }) + input.options = { ...input.options, writeErrorPages: true } + await buildSite(input) + await expect( + fs.stat(path.join(outDir, '_assets', 'files', 'unused.txt')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await readManifest(outDir))?.pageAssets?.['a.md']).toEqual({ + dependencies: ['source.txt'], + outputs: [], + }) + }) + + it('releases generated files when a written-page hook fails and retains recovery dependencies', async () => { + const { input, outDir } = await fixture() + input.options = { + ...input.options, + writeErrorPages: true, + plugins: [ + ...(input.options?.plugins ?? []), + { + name: 'fail-after-write', + hooks: { + onPageWritten() { + throw new Error('Written hook failed') + }, + }, + }, + ], + } + await buildSite(input) + const state = (await readManifest(outDir))?.pageAssets?.['a.md'] + expect(state).toEqual({ dependencies: ['source.txt'], outputs: [] }) + expect(await fs.readdir(path.join(outDir, '_assets', 'files'))).toEqual([]) + expect( + await fs.readFile(path.join(outDir, 'a', 'index.html'), 'utf8'), + ).toContain('Written hook failed') + }) + + it('encodes emitted URLs and preserves a conflicting source output', async () => { + const { input, contentDir, outDir } = await fixture(async (input) => { + const element = input.document.querySelector('a') + element?.setAttribute( + 'href', + await input.emit('a #.txt', Buffer.from('Generated')), + ) + }) + await buildSite(input) + expect( + await fs.readFile(path.join(outDir, 'a', 'index.html'), 'utf8'), + ).toContain('/_assets/files/a%20%23.txt') + await fs.mkdir(path.join(contentDir, '_assets', 'files'), { + recursive: true, + }) + await fs.writeFile( + path.join(contentDir, '_assets', 'files', 'a #.txt'), + 'Source', + ) + await expect(buildSite(input)).rejects.toThrow( + /conflicts with (?:a source output|a generated asset)/, + ) + expect( + await fs.readFile( + path.join(contentDir, '_assets', 'files', 'a #.txt'), + 'utf8', + ), + ).toBe('Source') + }) + + it.each([false, true])( + 'reserves actual script outputs including mixed-case extensions (cache busting: %s)', + async (cacheBusting) => { + let scriptUrl: string | undefined + const { input, contentDir } = await fixture(async (input) => { + if (!scriptUrl) throw new Error('Expected script URL') + await input.emit( + scriptUrl.replace('/_assets/files/', ''), + Buffer.from('Generated'), + ) + }) + input.siteConfig = { ...input.siteConfig, scripts: { cacheBusting } } + input.options?.plugins?.push(componentsPlugin(), { + name: 'observe-script', + hooks: { + onPageDocument(_context, { document }) { + scriptUrl = + document.querySelector('script[src]')?.getAttribute('src') ?? + undefined + }, + }, + }) + await fs.writeFile( + path.join(contentDir, 'a.md'), + '', + ) + await fs.mkdir(path.join(contentDir, '_assets', 'files'), { + recursive: true, + }) + await fs.writeFile( + path.join(contentDir, '_assets', 'files', 'script.tS'), + 'export const value = 1', + ) + await expect(buildSite(input)).rejects.toThrow( + 'conflicts with a source output', + ) + }, + ) + + it('rejects a script reservation that conflicts with an already emitted output', async () => { + const { input, contentDir, outDir } = await fixture(async (input) => { + if (input.sourceRelPath === 'a.md') + await input.emit('script.js', Buffer.from('Generated')) + }) + input.siteConfig = { ...input.siteConfig, scripts: { cacheBusting: false } } + input.options?.plugins?.push(componentsPlugin()) + await fs.mkdir(path.join(contentDir, '_assets', 'files'), { + recursive: true, + }) + await fs.writeFile( + path.join(contentDir, '_assets', 'files', 'script.ts'), + 'export const value = 1', + ) + await fs.writeFile( + path.join(contentDir, 'b.md'), + '', + ) + await expect(buildSite(input)).rejects.toThrow( + 'conflicts with a generated asset', + ) + expect( + await fs.readFile( + path.join(outDir, '_assets', 'files', 'script.js'), + 'utf8', + ), + ).toBe('Generated') + }) + + it('keeps asset bookkeeping out of hook contexts and rendered results', async () => { + const { input } = await fixture() + let calls = 0 + input.options?.plugins?.push({ + name: 'public-context', + hooks: { + onPageRendered(context, page) { + calls++ + expect(context).not.toHaveProperty('assets') + expect(context).not.toHaveProperty('assetProcessors') + expect(page).not.toHaveProperty('assetReceipt') + }, + }, + }) + await buildSite(input) + expect(calls).toBe(2) + }) + + it('expires input handles after each page call', async () => { + let captured: PageAssetInput | undefined + const { input, outDir } = await fixture(async (value) => { + captured = value + await value.emit('valid.txt', Buffer.from('Valid')) + }) + await buildSite(input) + if (!captured) throw new Error('Expected asset input') + expect(() => captured?.dependOn('late.txt')).toThrow('input is closed') + expect(() => captured?.emit('late.txt', Buffer.from('Late'))).toThrow( + 'input is closed', + ) + await expect( + fs.stat(path.join(outDir, '_assets', 'files', 'late.txt')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('waits for detached emission before aborting and removes its output', async () => { + const { input, outDir } = await fixture((value) => { + void value.emit('detached.txt', Buffer.from('Detached')) + }) + await expect(buildSite(input)).rejects.toThrow( + 'must await all source resolution and emission work', + ) + await expect( + fs.stat(path.join(outDir, '_assets', 'files', 'detached.txt')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('runs processors in order without implicitly treating generated outputs as content inputs', async () => { + const order: string[] = [] + const { input } = await fixture(async (value) => { + order.push(`first:${value.sourceRelPath}`) + const element = value.document.querySelector('a') + element?.setAttribute( + 'href', + await value.emit('shared.txt', Buffer.from('Shared')), + ) + }) + input.options?.plugins?.push({ + name: 'second', + assets: () => ({ + namespace: 'second', + async processPage(value) { + order.push(`second:${value.sourceRelPath}`) + const element = value.document.querySelector('a') + if (!element) throw new Error('Expected link') + expect( + await value.resolveSource( + element, + element.getAttribute('href') ?? '', + ), + ).toBeUndefined() + }, + }), + }) + await buildSite(input) + expect(order).toEqual([ + 'first:a.md', + 'second:a.md', + 'first:b.md', + 'second:b.md', + ]) + }) + + it.each([ + { namespace: '../escape', processPage: () => {} }, + { namespace: 'valid' }, + ])('rejects invalid processor contracts (%j)', async (processor) => { + const { input } = await fixture() + input.options?.plugins?.push({ + name: 'invalid', + assets: () => processor as SiteAssets, + }) + await expect(buildSite(input)).rejects.toThrow( + /Plugin "invalid" failed in assets.*namespace and processPage/, + ) + }) + + it('rejects duplicate namespaces before rendering', async () => { + const { input } = await fixture() + input.options?.plugins?.push({ + name: 'other', + assets: () => ({ namespace: 'files', processPage: () => {} }), + }) + await expect(buildSite(input)).rejects.toThrow( + /namespace "files".*plugin "files"/, + ) + }) + + it('ignores unsafe ownership paths from a manifest without deleting external files', async () => { + const { input, contentDir, outDir } = await fixture() + await buildSite(input) + const outside = path.join(contentDir, 'important.txt') + await fs.writeFile(outside, 'Keep') + const manifest = await readManifest(outDir) + if (!manifest) throw new Error('Expected manifest') + manifest.pageAssets = { + 'gone.md': { + dependencies: [], + outputs: [{ namespace: 'files', path: '../content/important.txt' }], + }, + } + await fs.writeFile( + path.join(outDir, '.ts-ssg', 'manifest.json'), + JSON.stringify(manifest), + ) + await buildSite(input) + expect(await fs.readFile(outside, 'utf8')).toBe('Keep') + }) +}) diff --git a/packages/ts-ssg/src/plugins/assets.ts b/packages/ts-ssg/src/plugins/assets.ts new file mode 100644 index 00000000..69f08d64 --- /dev/null +++ b/packages/ts-ssg/src/plugins/assets.ts @@ -0,0 +1,44 @@ +import type { SiteAssets, SiteConfig } from '@purestack/ts-common' +import { isPlainObject } from '@purestack/ts-util' +import { awaitPlugin, callPlugin } from './errors' +import type { PureStackPlugin } from './types' + +export function resolvePluginAssets( + plugins: readonly PureStackPlugin[], + config: SiteConfig, +): SiteAssets[] { + const processors: SiteAssets[] = [] + const owners = new Map() + for (const plugin of plugins) { + const processor = callPlugin(plugin.name, 'assets', () => + plugin.assets?.(config), + ) + if (processor === undefined) continue + callPlugin(plugin.name, 'assets', () => { + if ( + !isPlainObject(processor) || + typeof processor.namespace !== 'string' || + !/^[a-z0-9][a-z0-9_-]*$/.test(processor.namespace) || + typeof processor.processPage !== 'function' + ) { + throw new Error( + 'assets must return a namespace and processPage function.', + ) + } + const previous = owners.get(processor.namespace) + if (previous) + throw new Error( + `Asset namespace "${processor.namespace}" is already owned by plugin "${previous}".`, + ) + owners.set(processor.namespace, plugin.name) + processors.push({ + namespace: processor.namespace, + processPage: (input) => + awaitPlugin(plugin.name, 'assets.processPage', () => + processor.processPage(input), + ), + }) + }) + } + return processors +} diff --git a/packages/ts-ssg/src/plugins/config.test.ts b/packages/ts-ssg/src/plugins/config.test.ts index 6fc41236..b8e7cd1e 100644 --- a/packages/ts-ssg/src/plugins/config.test.ts +++ b/packages/ts-ssg/src/plugins/config.test.ts @@ -1,12 +1,15 @@ import fs from 'node:fs/promises' import path from 'node:path' import { h } from '@purestack/ts-html' +import { imagesPlugin } from '@purestack/ts-images' 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 { type BuildInput, buildSite } from '../build/site' +import { resolveSiteConfig } from '../config/config' import { makeRepoTempDir } from '../test/repoTempDir' +import { resolveBuildPlugins } from './default-plugins' import type { PureStackPlugin } from './plugin' declare module '@purestack/ts-common' { @@ -15,7 +18,42 @@ declare module '@purestack/ts-common' { } } +function resolveTestConfig(input: BuildInput) { + return resolveBuildSiteConfig(input, resolveBuildPlugins(input.options)) +} + describe('plugin configuration', () => { + it('exposes partial plugin input during resolution and normalized settings during finalization', () => { + const observations: unknown[] = [] + resolveSiteConfig( + { extensions: { images: { widths: [64] } } }, + { + defaultPlugins: false, + plugins: [ + imagesPlugin(), + { + name: 'phase-observer', + config: { + resolve({ config }) { + observations.push(config.extensions.images?.quality) + return {} + }, + normalize({ config }) { + // This normalizer follows the image owner's normalizer. + observations.push(config.extensions.images?.quality?.webp) + return {} + }, + finalize({ config }) { + observations.push(config.extensions.images?.presets.hero.widths) + return {} + }, + }, + }, + ], + }, + ) + expect(observations).toEqual([undefined, 80, [960, 1440, 1920]]) + }) let root: string | undefined beforeAll(() => disableLogger()) afterAll(async () => getLogger().close()) @@ -23,16 +61,58 @@ describe('plugin configuration', () => { if (root) await fs.rm(root, { recursive: true, force: true }) }) + it('lets an ordinary provider own a custom backend identifier with defaults installed', async () => { + root = await makeRepoTempDir('.tmp-plugin-backend-') + const contentDir = path.join(root, 'content'), + outDir = path.join(root, 'out') + await fs.mkdir(contentDir) + await fs.writeFile( + path.join(contentDir, 'index.md'), + '```ts\nconst answer = 42\n```', + ) + let calls = 0 + await buildSite({ + siteConfig: { + rootDir: root, + contentDir, + outDir, + pagefind: { enabled: false }, + mdx: { highlighter: 'custom-engine' }, + }, + options: { + plugins: [ + { + name: 'custom-engine', + markdown: { + createHighlighter(config) { + calls++ + expect(config.highlighter).toBe('custom-engine') + return { + codeToHtml: () => + '
Custom output
', + } + }, + }, + }, + ], + }, + }) + expect(calls).toBe(1) + expect( + await fs.readFile(path.join(outDir, 'index.html'), 'utf8'), + ).toContain('Custom output') + }) + it('owns mutable extension input and contributed settings across independent resolutions', () => { const input = { rootDir: process.cwd(), extensions: { brand: { text: 'Input' } }, } - const first = resolveBuildSiteConfig({ + const first = resolveTestConfig({ siteConfig: input, options: { defaultPlugins: false }, }) - const second = resolveBuildSiteConfig({ + const second = resolveTestConfig({ siteConfig: input, options: { defaultPlugins: false }, }) @@ -41,7 +121,7 @@ describe('plugin configuration', () => { expect(second.extensions.brand?.text).toBe('Input') expect(input.extensions.brand.text).toBe('Input') const contribution = { extensions: { brand: { text: 'Contributed' } } } - const configured = resolveBuildSiteConfig({ + const configured = resolveTestConfig({ options: { defaultPlugins: false, plugins: [{ name: 'brand', config: { resolve: () => contribution } }], @@ -73,7 +153,7 @@ describe('plugin configuration', () => { config: { resolve({ config, input, file }) { calls.push('config') - expect(config.consent.services).toHaveLength(0) + expect(config.consent?.services).toHaveLength(0) return { extensions: { brand: { @@ -87,14 +167,14 @@ describe('plugin configuration', () => { }, finalize({ config }) { calls.push('finalize') - expect(config.consent.services).toHaveLength(1) + 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.consent?.services).toHaveLength(1) expect(config.extensions.brand?.text).toBe('From input') expect(config.extensions.otherPlugin).toEqual({ preserved: true }) return { @@ -110,7 +190,7 @@ describe('plugin configuration', () => { }, components(config) { calls.push('components') - expect(config.consent.services).toHaveLength(1) + expect(config.consent?.services).toHaveLength(1) expect(config.extensions.brand?.text).toBe('From input') return {} }, @@ -122,7 +202,7 @@ describe('plugin configuration', () => { onConfigResolved({ config, components, templates }) { calls.push('hook') expect(config.extensions.brand?.text).toBe('From input') - expect(config.consent.services).toHaveLength(1) + expect(config.consent?.services).toHaveLength(1) expect(components).toEqual({}) expect(templates?.doc).toBeTypeOf('function') }, @@ -150,7 +230,7 @@ describe('plugin configuration', () => { it('composes configuration in plugin order and retains publish output overrides', () => { const calls: string[] = [] - const config = resolveBuildSiteConfig({ + const config = resolveTestConfig({ siteConfig: { rootDir: process.cwd(), siteTitle: 'Input', @@ -196,7 +276,7 @@ describe('plugin configuration', () => { 'attributes invalid config results to their plugin (%j)', (value) => { expect(() => - resolveBuildSiteConfig({ + resolveTestConfig({ options: { defaultPlugins: false, plugins: [ @@ -214,7 +294,7 @@ describe('plugin configuration', () => { 'attributes invalid config finalizers (%j)', (value) => { expect(() => - resolveBuildSiteConfig({ + resolveTestConfig({ options: { defaultPlugins: false, plugins: [ @@ -295,7 +375,7 @@ describe('plugin configuration', () => { }, components(config) { observed = true - const services = config.consent.services + const services = config.consent?.services ?? [] expect(services).toHaveLength(enabled && finalConsent ? 1 : 0) if (enabled && finalConsent) expect(services[0].ga4MeasurementId).toBe('G-OVERRIDE') @@ -318,7 +398,7 @@ describe('plugin configuration', () => { ) it('honors consent categories and explicit services supplied by later resolvers', () => { - const config = resolveBuildSiteConfig({ + const config = resolveTestConfig({ siteConfig: { rootDir: process.cwd(), consent: { enabled: true }, @@ -334,7 +414,7 @@ describe('plugin configuration', () => { resolve: ({ config }) => ({ consent: { categories: [ - ...config.consent.categories, + ...(config.consent?.categories ?? []), { id: 'custom', label: 'Custom', @@ -349,8 +429,8 @@ describe('plugin configuration', () => { ], }, }) - expect(config.consent.services[0].category).toBe('custom') - const manual = resolveBuildSiteConfig({ + expect(config.consent?.services[0].category).toBe('custom') + const manual = resolveTestConfig({ siteConfig: { rootDir: process.cwd(), consent: { enabled: true }, @@ -378,16 +458,16 @@ describe('plugin configuration', () => { ], }, }) - expect(manual.consent.services).toHaveLength(1) - expect(manual.consent.services[0].label).toBe('Manual') - expect(manual.consent.services[0].ga4MeasurementId).toBeUndefined() + expect(manual.consent?.services).toHaveLength(1) + expect(manual.consent?.services[0].label).toBe('Manual') + expect(manual.consent?.services[0].ga4MeasurementId).toBeUndefined() }) it.each([null, [], 42])( 'rejects malformed extension containers (%j)', (extensions) => { expect(() => - resolveBuildSiteConfig({ + resolveTestConfig({ siteConfig: { extensions } as never, options: { defaultPlugins: false }, }), diff --git a/packages/ts-ssg/src/plugins/contributions.ts b/packages/ts-ssg/src/plugins/contributions.ts index d266336c..07a10147 100644 --- a/packages/ts-ssg/src/plugins/contributions.ts +++ b/packages/ts-ssg/src/plugins/contributions.ts @@ -1,6 +1,7 @@ import type { PageHeadConfigTemplate, PageHeadTemplate, + SiteConfig, SiteConfigResolver, } from '@purestack/ts-common' import { isPlainObject } from '@purestack/ts-util' @@ -11,14 +12,20 @@ export function resolvePluginConfigResolvers( plugins: readonly PureStackPlugin[], ): SiteConfigResolver[] { const resolvers: SiteConfigResolver[] = [] - for (const phase of ['resolve', 'finalize'] as const) { + for (const phase of ['resolve', 'normalize', 'finalize'] as const) { for (const plugin of plugins) { - const resolve = plugin.config?.[phase] - if (!resolve) continue + if (!plugin.config?.[phase]) continue const contribution = `config.${phase}` resolvers.push((source) => callPlugin(plugin.name, contribution, () => { - const result = resolve.call(plugin.config, source) + // Finalizers see normalized settings only after all normalizers ran. + const result = + phase === 'finalize' + ? plugin.config?.finalize?.({ + ...source, + config: source.config as SiteConfig, + }) + : plugin.config?.[phase]?.(source) if (!isPlainObject(result)) throw new Error( `${contribution} must return a configuration object synchronously.`, diff --git a/packages/ts-ssg/src/plugins/default-plugins.test.ts b/packages/ts-ssg/src/plugins/default-plugins.test.ts index d0b45e9f..197e6e57 100644 --- a/packages/ts-ssg/src/plugins/default-plugins.test.ts +++ b/packages/ts-ssg/src/plugins/default-plugins.test.ts @@ -157,6 +157,8 @@ describe('default plugins', () => { hooks: { onConfigResolved({ config }) { if (source === 'config') { + if (!config.style) + throw new Error('Expected theme settings') config.style.theme.palette.light.accent = '#123456' config.style.theme.palette.light.semanticTone.neutral.canvas = '#abcdef' diff --git a/packages/ts-ssg/src/plugins/default-plugins.ts b/packages/ts-ssg/src/plugins/default-plugins.ts index 45981edd..3a4ef17e 100644 --- a/packages/ts-ssg/src/plugins/default-plugins.ts +++ b/packages/ts-ssg/src/plugins/default-plugins.ts @@ -10,7 +10,9 @@ import { pagefindPlugin } from '@purestack/ts-pagefind' import { seoPlugin } from '@purestack/ts-seo' import { themePlugin } from '@purestack/ts-theme' import type { BuildContext } from '../build/page' +import { getPageAssets } from '../build/page-assets' import type { BuildOptions } from '../build/site' +import { resolvePluginAssets } from './assets' import { resolvePluginHeadConfigTemplates, resolvePluginHeadTemplates, @@ -77,6 +79,8 @@ export async function initializePluginContext( ) { context.styles = resolvePluginStyles(plugins, context.config) context.search = resolvePluginSearch(plugins, context.config) + const assetProcessors = resolvePluginAssets(plugins, context.config) + getPageAssets(context)?.setProcessors(assetProcessors) context.components = resolvePluginComponents(plugins, context.config) context.templates = resolvePluginTemplates(plugins) context.headTemplates = resolvePluginHeadTemplates(plugins) diff --git a/packages/ts-ssg/src/plugins/images.test.ts b/packages/ts-ssg/src/plugins/images.test.ts new file mode 100644 index 00000000..6e811779 --- /dev/null +++ b/packages/ts-ssg/src/plugins/images.test.ts @@ -0,0 +1,642 @@ +import fs from 'node:fs/promises' +import net from 'node:net' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { h } from '@purestack/ts-html' +import { + type ImageDiagnostic, + type ImagesPluginOptions, + imagesPlugin, +} from '@purestack/ts-images' +import { runInDom } from '@purestack/ts-minidom' +import { build } from 'esbuild' +import { disableLogger, getLogger } from 'logpot' +import { defineComponent, html as markup } from 'regor' +import sharp from 'sharp' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { createIncrementalBuilder } from '../build/incremental' +import { readManifest } from '../build/manifest' +import { type BuildInput, buildSite } from '../build/site' +import { resolveSiteConfig } from '../config/config' +import { type DevServerHandle, startDevServer } from '../dev/server' +import { makeRepoTempDir } from '../test/repoTempDir' + +describe('optional images plugin', () => { + let root: string | undefined + let server: DevServerHandle | undefined + beforeAll(() => disableLogger()) + afterAll(() => getLogger().close()) + afterEach(async () => { + await server?.close() + server = undefined + if (root) await fs.rm(root, { recursive: true, force: true }) + }) + async function fixture( + source = '![A dashboard](./photo.png)', + options: ImagesPluginOptions = {}, + ) { + root = await makeRepoTempDir('.tmp-images-') + const contentDir = path.join(root, 'content'), + outDir = path.join(root, 'out') + await fs.mkdir(contentDir) + await fs.writeFile(path.join(contentDir, 'index.md'), source) + await writeImage(path.join(contentDir, 'photo.png'), 'red') + const diagnostics: ImageDiagnostic[] = [] + const plugin = imagesPlugin({ + cacheDir: path.join(root, 'cache'), + widths: [16, 32, 128], + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + ...options, + }) + const input: BuildInput = { + siteConfig: { + rootDir: root, + contentDir, + outDir, + basePath: '/docs', + navigation: { mode: 'none' }, + mdx: { disableHighlighter: true }, + }, + options: { + defaultPlugins: false, + plugins: [ + { + name: 'layout', + templates: { + doc: ({ head, bodyHtml, headerHtml, footerHtml }) => + h('html').push( + head, + h('body').raw( + (headerHtml ?? '') + bodyHtml + (footerHtml ?? ''), + ), + ), + }, + }, + plugin, + ], + }, + } + return { input, contentDir, outDir, diagnostics, plugin } + } + const html = (outDir: string, file = 'index.html') => + fs.readFile(path.join(outDir, file), 'utf8') + async function generated(outDir: string) { + return ( + await fs.readdir(path.join(outDir, '_assets', 'images')).catch(() => []) + ).sort() + } + + it('enables the named image export and disables defaults through JSON for build, publish, and lazy development without a TypeScript config', async () => { + const { input, contentDir, outDir } = await fixture(undefined, { + formats: ['webp'], + }) + await buildSite(input) + const expectedHtml = await html(outDir) + const expectedFiles = await generated(outDir) + const expectedBytes = await Promise.all( + expectedFiles.map((file) => + fs.readFile(path.join(outDir, '_assets', 'images', file)), + ), + ) + // A site-only package exercises fallback resolution without competing with + // the workspace's own image package. Its code is the real image integration. + const packageDir = path.join(root ?? '', 'node_modules', 'example-images') + await fs.mkdir(packageDir, { recursive: true }) + const bundled = await build({ + entryPoints: [ + fileURLToPath( + new URL('../../../ts-images/src/index.ts', import.meta.url), + ), + ], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + packages: 'external', + logLevel: 'silent', + }) + await fs.writeFile( + path.join(packageDir, 'index.mjs'), + bundled.outputFiles[0].contents, + ) + await fs.writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: 'example-images', + type: 'module', + exports: { '.': { import: './index.mjs' } }, + }), + ) + const config = { + defaultPlugins: false, + plugins: ['example-images#imagesPlugin'], + publishDir: '../publish', + extensions: { + images: { + widths: [16, 32, 128], + formats: ['webp'], + cacheDir: path.join(root ?? '', 'cache'), + enabled: true, + }, + }, + } + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + input.options = { + ...input.options, + defaultPlugins: undefined, + plugins: input.options?.plugins?.filter( + (plugin) => plugin.name !== 'purestack:images', + ), + } + await buildSite(input) + expect(await html(outDir)).toBe(expectedHtml) + expect(await generated(outDir)).toEqual(expectedFiles) + expect( + await Promise.all( + expectedFiles.map((file) => + fs.readFile(path.join(outDir, '_assets', 'images', file)), + ), + ), + ).toEqual(expectedBytes) + await buildSite({ ...input, publish: { enabled: true } }) + expect(await generated(path.join(root ?? '', 'publish'))).toEqual( + expectedFiles, + ) + const port = await freePort() + server = await startDevServer({ + host: '127.0.0.1', + port, + watch: false, + liveReload: false, + build: input, + }) + const response = await fetch(`http://127.0.0.1:${port}/docs/`) + expect(response.status).toBe(200) + expect(await response.text()).toContain('') + expect(await generated(outDir)).toEqual(expectedFiles) + await server.close() + server = undefined + config.extensions.images.enabled = false + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify(config), + ) + await buildSite(input) + expect(await html(outDir)).not.toContain('srcset=') + expect(await generated(outDir)).toEqual([]) + expect(await fs.stat(path.join(outDir, 'photo.png'))).toBeDefined() + }) + + it('optimizes ordinary Markdown, emits real format bytes, reserves aspect ratio, and keeps the original', async () => { + const { input, contentDir, outDir, diagnostics } = await fixture() + const result = await buildSite(input) + expect(await generated(outDir)).toHaveLength(9) + expect(result.assets?.total).toBe(10) + expect(await fs.readFile(path.join(outDir, 'photo.png'))).toEqual( + await fs.readFile(path.join(contentDir, 'photo.png')), + ) + await runInDom(await html(outDir), (document) => { + const image = document.querySelector('picture > img') + expect(image?.getAttribute('alt')).toBe('A dashboard') + expect(image?.getAttribute('src')).toMatch( + /^\/docs\/_assets\/images\/[a-f0-9]{64}\.64\.png$/, + ) + expect(image?.getAttribute('width')).toBe('64') + expect(image?.getAttribute('height')).toBe('32') + expect(image?.getAttribute('loading')).toBe('lazy') + expect(image?.getAttribute('decoding')).toBe('async') + expect( + document + .querySelector('source[type="image/avif"]') + ?.getAttribute('srcset'), + ).toContain('32w') + expect( + document + .querySelector('source[type="image/webp"]') + ?.getAttribute('sizes'), + ).toBe('100vw') + }) + for (const file of await generated(outDir)) { + const metadata = await sharp( + await fs.readFile(path.join(outDir, '_assets', 'images', file)), + ).metadata() + expect(metadata.width).toBeLessThanOrEqual(64) + expect( + file.endsWith('.avif') + ? ['heif', 'avif'] + : [path.extname(file).slice(1)], + ).toContain(metadata.format) + } + expect(diagnostics[0]).toMatchObject({ + cache: 'miss', + variants: 9, + source: 'photo.png', + }) + expect( + (await readManifest(outDir))?.pageAssets?.['index.md'].dependencies, + ).toEqual(['photo.png']) + }) + + it('optimizes unquoted HTML source URLs', async () => { + const { input, outDir } = await fixture('Photo') + await buildSite(input) + runInDom( + await fs.readFile(path.join(outDir, 'index.html'), 'utf8'), + (document) => { + expect( + document.querySelector('picture > img')?.getAttribute('alt'), + ).toBe('Photo') + expect( + document.querySelector('picture > img')?.getAttribute('src'), + ).toMatch(/^\/docs\/_assets\/images\//) + }, + ) + }) + + it('honors author attributes and explicit presets and priority', async () => { + const { input, outDir } = await fixture( + 'A', + { formats: ['webp'] }, + ) + await buildSite(input) + await runInDom(await html(outDir), (document) => { + const image = document.querySelector('#hero') + expect(image?.getAttribute('class')).toBe('cover') + expect(image?.getAttribute('width')).toBe('40') + expect(image?.getAttribute('height')).toBe('20') + expect(image?.getAttribute('sizes')).toBe('50vw') + expect(image?.getAttribute('loading')).toBe('eager') + expect(image?.getAttribute('decoding')).toBe('sync') + expect(image?.getAttribute('fetchpriority')).toBe('high') + expect(image?.getAttribute('src')).toContain('?version=1#preview') + expect(document.querySelector('a')?.getAttribute('href')).toBe( + '/docs/photo.png', + ) + expect(image?.hasAttribute('data-image-preset')).toBe(false) + }) + }) + + it('preserves existing picture/srcset markup, external URLs, SVG, and opt-outs', async () => { + const { input, contentDir, outDir } = await fixture( + [ + 'Authored', + 'Set', + 'Remote', + 'SVG', + 'Original', + ].join('\n\n'), + ) + await fs.writeFile( + path.join(contentDir, 'logo.svg'), + '', + ) + await buildSite(input) + expect(await generated(outDir)).toHaveLength(0) + await runInDom(await html(outDir), (document) => { + expect(document.querySelectorAll('picture')).toHaveLength(1) + expect(document.querySelectorAll('img[width]')).toHaveLength(0) + expect( + document.querySelector('img[alt="Remote"]')?.getAttribute('src'), + ).toBe('https://example.com/image.png') + }) + }) + + it('resolves shared partial and template images from their own content directories', async () => { + const { input, contentDir, outDir } = await fixture('# Nested page', { + formats: ['webp'], + }) + await fs.mkdir(path.join(contentDir, 'guides')) + await fs.writeFile(path.join(contentDir, 'guides', 'index.md'), '# Guide') + await fs.writeFile( + path.join(contentDir, 'header.mdx'), + 'Header\n\n', + ) + await buildSite(input) + const manifest = await readManifest(outDir) + expect( + manifest?.pageAssets?.[path.join('guides', 'index.md')]?.dependencies, + ).toEqual(['photo.png']) + const output = await html(outDir, 'guides/index.html') + expect(output).toContain('alt="Template"') + expect(output).not.toContain('src="./photo.png"') + await runInDom(output, (document) => { + expect( + document.querySelector('img[alt="Header"]')?.getAttribute('src'), + ).toContain('/docs/_assets/images/') + expect( + ( + document.querySelector('template') as HTMLTemplateElement + )?.content.querySelector('picture'), + ).not.toBeNull() + }) + }) + + it('uses locale fallback and invalidates it when a localized image appears', async () => { + const { input, contentDir, outDir } = await fixture('# Home', { + formats: ['webp'], + }) + await fs.mkdir(path.join(contentDir, 'en')) + await fs.mkdir(path.join(contentDir, 'de')) + await writeImage(path.join(contentDir, 'en', 'photo.png'), 'blue') + await fs.writeFile( + path.join(contentDir, 'de', 'index.md'), + '![Localized](./photo.png)', + ) + input.siteConfig = { + ...input.siteConfig, + i18n: { defaultLocale: 'en', locales: ['en', 'de'] }, + } + const builder = await createIncrementalBuilder(input) + await builder.buildAll('localized') + expect( + (await readManifest(outDir))?.pageAssets?.[path.join('de', 'index.md')] + ?.dependencies, + ).toEqual(['de/photo.png', 'en/photo.png']) + const first = await html(outDir, 'de/index.html') + await writeImage(path.join(contentDir, 'de', 'photo.png'), 'green') + await builder.applyChange(path.join(contentDir, 'de', 'photo.png')) + await builder.renderIfDirtyByOutPath(path.join(outDir, 'de', 'index.html')) + expect(await html(outDir, 'de/index.html')).not.toBe(first) + }) + + it('regenerates clean output from persistent cache and responds to image-only edits without rebuilding unrelated pages', async () => { + const { input, contentDir, outDir, diagnostics } = await fixture( + undefined, + { formats: ['webp'] }, + ) + await fs.writeFile(path.join(contentDir, 'other.md'), '# Unrelated') + input.options = { ...input.options, cleanOutDir: true } + const builder = await createIncrementalBuilder(input) + await builder.buildAll('first') + const files = await generated(outDir) + await builder.buildAll('cached') + expect(await generated(outDir)).toEqual(files) + expect(diagnostics.at(-1)?.cache).toBe('hit') + const unrelated = await fs.stat(path.join(outDir, 'other', 'index.html')) + await writeImage(path.join(contentDir, 'photo.png'), 'blue') + expect( + (await builder.applyChange(path.join(contentDir, 'photo.png'))) + .changedPages, + ).toBe(1) + expect(await generated(outDir)).not.toEqual(files) + expect( + (await fs.stat(path.join(outDir, 'other', 'index.html'))).mtimeMs, + ).toBe(unrelated.mtimeMs) + }) + + it('generates only requested pages in lazy mode, survives deletion, and recovers when a missing image returns', async () => { + const { input, contentDir, outDir } = await fixture(undefined, { + formats: ['webp'], + }) + await fs.writeFile( + path.join(contentDir, 'unused.md'), + '![Unused](./photo.png)', + ) + input.options = { ...input.options, writeErrorPages: true } + const builder = await createIncrementalBuilder(input) + await builder.prepareForRequests() + expect(await generated(outDir)).toHaveLength(0) + await builder.renderByUrlPath('/') + expect(await generated(outDir)).toHaveLength(6) + await expect( + fs.stat(path.join(outDir, 'unused', 'index.html')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + await fs.rm(path.join(contentDir, 'photo.png')) + await builder.applyChange(path.join(contentDir, 'photo.png')) + await builder.renderByUrlPath('/') + expect(await html(outDir)).toContain('missing file') + expect( + (await readManifest(outDir))?.pageAssets?.['index.md'].dependencies, + ).toEqual(['photo.png']) + await writeImage(path.join(contentDir, 'photo.png'), 'green') + await builder.applyChange(path.join(contentDir, 'photo.png')) + await builder.renderByUrlPath('/') + expect(await html(outDir)).toContain('') + expect(await html(outDir)).not.toContain('Render Error') + }) + + it('selects images using include/exclude rules and supports fallback-only output', async () => { + const { input, contentDir, outDir } = await fixture( + '![Keep](./photo.png)\n\n![Selected](./selected.png)', + { exclude: ['photo.png'], formats: [] }, + ) + await writeImage(path.join(contentDir, 'selected.png'), 'blue') + await buildSite(input) + expect(await generated(outDir)).toHaveLength(3) + await runInDom(await html(outDir), (document) => { + expect(document.querySelector('picture')).toBeNull() + expect( + document.querySelector('img[alt="Keep"]')?.getAttribute('src'), + ).toBe('/docs/photo.png') + expect( + document.querySelector('img[alt="Selected"]')?.getAttribute('srcset'), + ).toContain('32w') + }) + }) + + it('has no output effect when disabled, and no configuration effect when omitted', async () => { + const { input, contentDir, outDir } = await fixture(undefined, { + enabled: false, + }) + await buildSite(input) + expect(await generated(outDir)).toHaveLength(0) + expect(await html(outDir)).not.toContain('') + expect( + resolveSiteConfig( + { contentDir, extensions: { images: { widths: [] } } }, + { defaultPlugins: false }, + ).extensions.images, + ).toEqual({ widths: [] }) + }) + + it('processes server-rendered components and images added by later document hooks', async () => { + const { input, outDir } = await fixture('', { formats: [] }) + input.options?.plugins?.push({ + name: 'site-images', + components: () => ({ + PreviewImage: defineComponent( + markup`Component`, + ), + }), + hooks: { + onPageDocument(_context, page) { + const image = page.document.createElement('img') + image.setAttribute('src', './photo.png') + image.setAttribute('alt', 'Hook') + page.document.body.appendChild(image) + }, + }, + }) + await buildSite(input) + expect(await generated(outDir)).toHaveLength(3) + await runInDom(await html(outDir), (document) => { + expect(document.querySelectorAll('img[srcset]')).toHaveLength(2) + expect( + document.querySelector('img[alt="Hook"]')?.getAttribute('src'), + ).toContain('/docs/_assets/images/') + }) + }) + + it('isolates simultaneous sites that reuse the same plugin instance', async () => { + const { input, contentDir, outDir } = await fixture(undefined, { + formats: [], + }) + const secondContent = path.join(path.dirname(contentDir), 'second-content') + const secondOutput = path.join(path.dirname(outDir), 'second-out') + await fs.mkdir(secondContent) + await fs.writeFile( + path.join(secondContent, 'index.md'), + '![Other](./photo.png)', + ) + await writeImage(path.join(secondContent, 'photo.png'), 'blue') + const second = { + ...input, + siteConfig: { + ...input.siteConfig, + contentDir: secondContent, + outDir: secondOutput, + extensions: { images: { widths: [24] } }, + }, + } + await Promise.all([buildSite(input), buildSite(second)]) + expect(await generated(outDir)).toHaveLength(3) + expect(await generated(secondOutput)).toHaveLength(1) + const file = (await generated(secondOutput))[0] + expect( + ( + await sharp( + await fs.readFile(path.join(secondOutput, '_assets', 'images', file)), + ).metadata() + ).width, + ).toBe(24) + expect((await readManifest(outDir))?.config.outDir).toBe(outDir) + expect((await readManifest(secondOutput))?.config.outDir).toBe(secondOutput) + }) + + it('reports corrupt image failures and supports an explicit warning fallback', async () => { + const { input, contentDir, outDir } = await fixture(undefined, { + formats: [], + }) + await fs.writeFile(path.join(contentDir, 'photo.png'), 'Not an image') + await expect(buildSite(input)).rejects.toThrow( + /purestack:images.*assets.processPage.*photo.png/, + ) + input.siteConfig = { + ...input.siteConfig, + extensions: { images: { onError: 'warn' } }, + } + await buildSite(input) + expect(await html(outDir)).not.toContain('srcset=') + expect(await generated(outDir)).toHaveLength(0) + }) + + it('rejects caches inside watched content or cleaned output directories', async () => { + const { input, contentDir, outDir } = await fixture() + for (const cacheDir of [ + path.join(contentDir, 'cache'), + path.join(outDir, 'cache'), + ]) { + input.siteConfig = { + ...input.siteConfig, + extensions: { images: { cacheDir } }, + } + await expect(buildSite(input)).rejects.toThrow(/cacheDir must be outside/) + } + }) + + it.each([ + { widths: [] }, + { quality: { webp: 0 } }, + { concurrency: 0 }, + { cacheMaxAgeDays: -1 }, + { cacheMaxAgeDays: 1.5 }, + { formats: ['jpeg'] }, + { maxInputPixels: 0 }, + { extra: true }, + ])('attributes invalid settings to the plugin (%j)', async (settings) => { + const { input } = await fixture(undefined, settings as ImagesPluginOptions) + await expect(buildSite(input)).rejects.toThrow( + /Plugin "purestack:images" failed in config.normalize/, + ) + }) + + it('merges typed extension input above factory and file settings and computes preset defaults after late overrides', async () => { + const { input, contentDir } = await fixture(undefined, { + widths: [16], + quality: { webp: 20 }, + }) + await fs.writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ + extensions: { images: { widths: [24], quality: { webp: 30 } } }, + }), + ) + const config = resolveSiteConfig( + { ...input.siteConfig, extensions: { images: { widths: [32] } } }, + { + defaultPlugins: false, + plugins: [ + ...(input.options?.plugins ?? []), + { + name: 'late', + config: { + resolve: () => ({ + extensions: { images: { quality: { webp: 70 } } }, + }), + }, + }, + ], + }, + ) + expect(config.extensions.images?.widths).toEqual([32]) + expect(config.extensions.images?.quality.webp).toBe(70) + expect(config.extensions.images?.presets.content.quality.webp).toBe(70) + }) + + it('serves generated AVIF and WebP with correct MIME types through the real lazy dev server', async () => { + const { input } = await fixture() + const port = await freePort() + server = await startDevServer({ + host: '127.0.0.1', + port, + watch: false, + liveReload: false, + build: input, + }) + const origin = `http://127.0.0.1:${port}` + const response = await fetch(`${origin}/docs/`) + expect(response.status).toBe(200) + const output = await response.text() + const urls = + output.match(/\/docs\/_assets\/images\/[a-f0-9]+\.\d+\.(?:avif|webp)/g) ?? + [] + expect(urls.length).toBeGreaterThan(0) + for (const extension of ['avif', 'webp']) { + const url = urls.find((url) => url.endsWith(`.${extension}`)) + if (!url) throw new Error(`Expected ${extension} URL`) + const asset = await fetch(origin + url) + expect(asset.status).toBe(200) + expect(asset.headers.get('content-type')).toBe(`image/${extension}`) + expect((await asset.arrayBuffer()).byteLength).toBeGreaterThan(0) + } + }) +}) + +async function writeImage(file: string, background: string) { + await sharp({ create: { width: 64, height: 32, channels: 4, background } }) + .png() + .toFile(file) +} +async function freePort() { + const server = net.createServer() + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected port') + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + return address.port +} diff --git a/packages/ts-ssg/src/plugins/isolation.test.ts b/packages/ts-ssg/src/plugins/isolation.test.ts index 951264e3..9bdb2986 100644 --- a/packages/ts-ssg/src/plugins/isolation.test.ts +++ b/packages/ts-ssg/src/plugins/isolation.test.ts @@ -50,6 +50,8 @@ describe('independent site builders', () => { }, hooks: { async onConfigResolved(context) { + if (!context.config.style) + throw new Error('Expected theme settings') if (!context.styles) throw new Error('Expected site stylesheet provider') providers[name] = context.styles diff --git a/packages/ts-ssg/src/plugins/plugin.test.ts b/packages/ts-ssg/src/plugins/plugin.test.ts index 4402fbf0..3867c4fe 100644 --- a/packages/ts-ssg/src/plugins/plugin.test.ts +++ b/packages/ts-ssg/src/plugins/plugin.test.ts @@ -480,7 +480,7 @@ describe('plugins', () => { [null, 'Plugin 1 must be an object.'], [ { name: 'typo', hook: {} }, - 'Plugin "typo" has an unknown field "hook". Plugin fields: name, defaults, skins, config, components, templates, head, headConfig, styles, search, markdown, pages, devMiddleware, hooks.', + 'Plugin "typo" has an unknown field "hook". Plugin fields: name, defaults, skins, config, components, templates, head, headConfig, styles, search, assets, markdown, pages, devMiddleware, hooks.', ], [ { name: 'typo', hooks: { onPageRender: () => {} } }, @@ -514,9 +514,13 @@ describe('plugins', () => { { name: 'bad', config: { finalize: 42 } }, 'Plugin "bad" needs `config.finalize` to be a configuration resolver.', ], + [ + { name: 'bad', config: { normalize: 42 } }, + 'Plugin "bad" needs `config.normalize` to be a configuration resolver.', + ], [ { name: 'bad', config: { after: () => ({}) } }, - 'Plugin "bad" has an unknown config field "after". Config fields: resolve, finalize.', + 'Plugin "bad" has an unknown config field "after". Config fields: resolve, normalize, finalize.', ], [ { name: 'bad', head: {} }, diff --git a/packages/ts-ssg/src/plugins/presentation.test.ts b/packages/ts-ssg/src/plugins/presentation.test.ts index 44942eb5..cbc60865 100644 --- a/packages/ts-ssg/src/plugins/presentation.test.ts +++ b/packages/ts-ssg/src/plugins/presentation.test.ts @@ -185,7 +185,7 @@ describe('presentation integrations', () => { omitted !== 'purestack:consent', ) expect( - config?.consent.services.some( + config?.consent?.services.some( (service) => service.ga4MeasurementId === 'G-TEST1234', ), ).toBe(omitted !== 'purestack:analytics') @@ -214,7 +214,7 @@ describe('presentation integrations', () => { hooks: { onConfigResolved({ config }) { expect( - config.consent.services.filter( + config.consent?.services.filter( (service) => service.id === 'ga4', ), ).toHaveLength(1) @@ -249,8 +249,8 @@ describe('presentation integrations', () => { name: 'inspect', hooks: { onConfigResolved({ config }) { - expect(config.consent.services).toHaveLength(1) - expect(config.consent.services[0].scripts[0].content).toBe( + expect(config.consent?.services).toHaveLength(1) + expect(config.consent?.services[0].scripts[0].content).toBe( 'window.manualAnalytics = true', ) }, diff --git a/packages/ts-ssg/src/plugins/settings.test.ts b/packages/ts-ssg/src/plugins/settings.test.ts new file mode 100644 index 00000000..20a131f6 --- /dev/null +++ b/packages/ts-ssg/src/plugins/settings.test.ts @@ -0,0 +1,278 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { componentsPlugin } from '@purestack/ts-components' +import { + analyticsPlugin, + authStateHintPlugin, + consentPlugin, +} from '@purestack/ts-page-scripts' +import { pagefindPlugin } from '@purestack/ts-pagefind' +import { seoPlugin } from '@purestack/ts-seo' +import { themePlugin } from '@purestack/ts-theme' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { resolveSiteConfig as resolveConfig } from '../config/config' +import { createDefaultPlugins } from './default-plugins' + +describe('plugin-owned settings', () => { + let root: string | undefined + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'ssg-plugin-settings-')) + }) + const resolveSiteConfig = ( + input: Parameters[0] = {}, + options: Parameters[1] = {}, + ) => resolveConfig({ contentDir: root, ...input }, options) + afterEach(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }) + }) + + it('omits unused settings and does not validate inputs owned by absent plugins', () => { + if (!root) throw new Error('Expected fixture directory') + fs.writeFileSync( + path.join(root, 'siteConfig.json'), + JSON.stringify({ + style: { theme: { skin: 'does-not-exist' } }, + analytics: { ga4: { measurementId: 'invalid' } }, + consent: { categories: [{ id: 'duplicate' }, { id: 'duplicate' }] }, + }), + ) + const config = resolveSiteConfig( + { contentDir: root }, + { defaultPlugins: false }, + ) + for (const key of [ + 'style', + 'docLayout', + 'pageToc', + 'favicon', + 'logo', + 'auth', + 'consent', + 'analytics', + 'pagefind', + 'preview', + ]) { + expect(config).not.toHaveProperty(key) + } + expect(config.html.minify).toBe(false) + expect(() => + resolveSiteConfig( + { contentDir: root }, + { defaultPlugins: false, plugins: [themePlugin()] }, + ), + ).toThrow(/purestack:theme.*config.resolve.*Unknown theme skin/) + expect(() => + resolveSiteConfig( + { contentDir: root }, + { defaultPlugins: false, plugins: [analyticsPlugin()] }, + ), + ).toThrow(/purestack:analytics.*config.resolve.*measurementId/) + expect(() => + resolveSiteConfig( + { contentDir: root }, + { defaultPlugins: false, plugins: [consentPlugin()] }, + ), + ).toThrow(/purestack:consent.*Duplicate consent category/) + }) + + it('does not retain theme guarantees when a default plugin is replaced', () => { + const config = resolveSiteConfig( + {}, + { plugins: [{ name: 'purestack:theme' }] }, + ) + expect(config).not.toHaveProperty('style') + expect(config).not.toHaveProperty('docLayout') + expect(config.logo).toBeDefined() + }) + + it('preserves custom backend identifiers without selecting an engine in core', () => { + expect( + resolveSiteConfig({}, { defaultPlugins: false }).mdx, + ).not.toHaveProperty('highlighter') + expect( + resolveSiteConfig( + { mdx: { highlighter: 'custom-engine' } }, + { defaultPlugins: false }, + ).mdx.highlighter, + ).toBe('custom-engine') + expect( + resolveSiteConfig({ mdx: { highlighter: 'custom-engine' } }).mdx + .highlighter, + ).toBe('custom-engine') + expect(resolveSiteConfig().mdx.highlighter).toBe('highlightjs') + }) + + it.each([ + [themePlugin, ['style', 'docLayout', 'pageToc']], + [componentsPlugin, ['logo']], + [authStateHintPlugin, ['auth']], + [consentPlugin, ['consent']], + [analyticsPlugin, ['analytics']], + [pagefindPlugin, ['pagefind']], + [seoPlugin, ['preview']], + ] as const)('resolves only the settings supplied by %s', (factory, keys) => { + const config = resolveSiteConfig( + {}, + { defaultPlugins: false, plugins: [factory()] }, + ) + for (const key of [ + 'style', + 'docLayout', + 'pageToc', + 'logo', + 'auth', + 'consent', + 'analytics', + 'pagefind', + 'preview', + ]) { + expect(config[key as keyof typeof config] !== undefined).toBe( + (keys as readonly string[]).includes(key), + ) + } + }) + + it('preserves standard configuration when standard plugin order is reversed', () => { + const input = { + consent: { enabled: true }, + analytics: { ga4: { measurementId: 'g-test1234' } }, + style: { theme: { skin: 'standard' } }, + pagefind: { excludePaths: ['docs', 'docs/', ''] }, + preview: { title: ' Preview ', imageWidth: 1200 }, + } + const reversed = resolveSiteConfig(input, { + defaultPlugins: false, + plugins: createDefaultPlugins().reverse(), + }) + const standard = resolveSiteConfig(input) + // Skin factories create fresh callback closures in each resolution. + expect(JSON.parse(JSON.stringify(reversed))).toEqual( + JSON.parse(JSON.stringify(standard)), + ) + expect(reversed.consent).toEqual(standard.consent) + }) + + it('normalizes late overrides before dependent factories and attributes invalid overrides', () => { + const config = resolveSiteConfig( + { analytics: { ga4: { measurementId: 'G-INITIAL' } } }, + { + plugins: [ + { + name: 'site', + config: { + resolve: () => ({ + analytics: { ga4: { measurementId: ' g-final ' } }, + pagefind: { excludePaths: ['guide', 'guide/', ''] }, + preview: { title: ' Final ', imageWidth: -1 }, + }), + }, + }, + ], + }, + ) + expect(config.analytics?.ga4.measurementId).toBe('G-FINAL') + expect(config.pagefind?.excludePaths).toEqual(['/guide/']) + expect(config.preview?.title).toBe('Final') + expect(config.preview?.imageWidth).toBeUndefined() + expect(() => + resolveSiteConfig( + {}, + { + plugins: [ + { + name: 'site', + config: { + resolve: () => ({ analytics: { ga4: { enabled: true } } }), + }, + }, + ], + }, + ), + ).toThrow(/purestack:analytics.*config.normalize.*measurementId/) + }) + + it.each([null, [], 42, Promise.resolve({})])( + 'attributes invalid normalizer results (%j)', + (value) => { + expect(() => + resolveSiteConfig( + {}, + { + defaultPlugins: false, + plugins: [ + { name: 'invalid', config: { normalize: () => value } } as never, + ], + }, + ), + ).toThrow( + 'Plugin "invalid" failed in config.normalize: config.normalize must return a configuration object synchronously.', + ) + }, + ) + + it('runs every normalizer before any finalizer', () => { + const calls: string[] = [] + resolveSiteConfig( + {}, + { + defaultPlugins: false, + plugins: [ + { + name: 'first', + config: { + resolve: () => { + calls.push('resolve:first') + return {} + }, + normalize: () => { + calls.push('normalize:first') + return {} + }, + finalize: () => { + calls.push('finalize:first') + return {} + }, + }, + }, + { + name: 'second', + config: { + resolve: () => { + calls.push('resolve:second') + return {} + }, + normalize: () => { + calls.push('normalize:second') + return {} + }, + finalize: () => { + calls.push('finalize:second') + return {} + }, + }, + }, + ], + }, + ) + expect(calls).toEqual([ + 'resolve:first', + 'resolve:second', + 'normalize:first', + 'normalize:second', + 'finalize:first', + 'finalize:second', + ]) + }) + + it('does not bypass configured consent when analytics is used without the consent runtime', () => { + const config = resolveSiteConfig( + { + consent: { enabled: true }, + analytics: { ga4: { measurementId: 'G-TEST1234' } }, + }, + { defaultPlugins: false, plugins: [analyticsPlugin()] }, + ) + expect(config.consent?.services[0]?.ga4MeasurementId).toBe('G-TEST1234') + }) +}) diff --git a/packages/ts-ssg/src/plugins/types.ts b/packages/ts-ssg/src/plugins/types.ts index 777f5eb1..34d7313e 100644 --- a/packages/ts-ssg/src/plugins/types.ts +++ b/packages/ts-ssg/src/plugins/types.ts @@ -4,6 +4,7 @@ import type { PageHeadConfigTemplate, PageHeadTemplate, PageTemplateMap, + SiteAssets, SiteConfig, SiteConfigContributions, SiteMdxConfig, @@ -27,7 +28,7 @@ export interface PureStackPlugin { defaults?: boolean /** Theme skins the site config can select with `style.theme.skin`. */ skins?: Record - /** Pure overrides, followed by derived settings; both run before factories. */ + /** Pure overrides, normalization, and derived settings, before factories. */ config?: SiteConfigContributions /** Regor components, built from the resolved site config. */ components?: (config: SiteConfig) => Record @@ -41,6 +42,8 @@ export interface PureStackPlugin { styles?: (config: SiteConfig) => SiteStyles /** Search output, used for full builds and lazy development asset requests. */ search?: (config: SiteConfig) => SiteSearch + /** Per-site document asset processors; core owns their outputs and dependencies. */ + assets?: (config: SiteConfig) => SiteAssets | undefined /** Markdown transforms and highlighter resolution for pages and shared partials. */ markdown?: PureStackMarkdown /** diff --git a/packages/ts-ssg/src/plugins/validation.ts b/packages/ts-ssg/src/plugins/validation.ts index 5d19cb57..8bfb1561 100644 --- a/packages/ts-ssg/src/plugins/validation.ts +++ b/packages/ts-ssg/src/plugins/validation.ts @@ -14,6 +14,7 @@ const PLUGIN_FIELDS = [ 'headConfig', 'styles', 'search', + 'assets', 'markdown', 'pages', 'devMiddleware', @@ -82,9 +83,9 @@ function assertPluginFields(name: string, plugin: Record) { if (!isPlainObject(plugin.config)) fail('needs `config` to be an object of configuration contributions.') for (const [phase, resolve] of Object.entries(plugin.config as object)) { - if (phase !== 'resolve' && phase !== 'finalize') + if (phase !== 'resolve' && phase !== 'normalize' && phase !== 'finalize') fail( - `has an unknown config field "${phase}". Config fields: resolve, finalize.`, + `has an unknown config field "${phase}". Config fields: resolve, normalize, finalize.`, ) if (resolve !== undefined && typeof resolve !== 'function') fail(`needs \`config.${phase}\` to be a configuration resolver.`) @@ -101,6 +102,8 @@ function assertPluginFields(name: string, plugin: Record) { fail('needs `styles` to be a stylesheet provider.') if (plugin.search !== undefined && typeof plugin.search !== 'function') fail('needs `search` to be a search provider.') + if (plugin.assets !== undefined && typeof plugin.assets !== 'function') + fail('needs `assets` to be an asset processor factory.') if (pages !== undefined && typeof pages !== 'function') { fail('needs `pages` to be a function that returns pages.') } diff --git a/packages/ts-ssg/src/test/defaultPlugins.ts b/packages/ts-ssg/src/test/defaultPlugins.ts index 2424d1c7..061aa91d 100644 --- a/packages/ts-ssg/src/test/defaultPlugins.ts +++ b/packages/ts-ssg/src/test/defaultPlugins.ts @@ -4,7 +4,7 @@ import { resolveHeadConfig } from '../build/head-config' import { type BuildContext, renderPageFromFile } from '../build/page' import { type RenderPageInput, renderPage } from '../build/renderer' import type { BuildHooks } from '../build/site' -import { applyConfigResolvers } from '../config/config' +import { applyConfigResolvers } from '../config/core-config' import type { ResolvedContentFile } from '../i18n/content' import { resolvePluginConfigResolvers } from '../plugins/contributions' import { diff --git a/packages/ts-ssg/tsconfig.contracts.json b/packages/ts-ssg/tsconfig.contracts.json new file mode 100644 index 00000000..ee678be3 --- /dev/null +++ b/packages/ts-ssg/tsconfig.contracts.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["type-tests/**/*.ts"], + "exclude": [] +} diff --git a/packages/ts-ssg/type-tests/plugin-contract.ts b/packages/ts-ssg/type-tests/plugin-contract.ts new file mode 100644 index 00000000..5ee609a7 --- /dev/null +++ b/packages/ts-ssg/type-tests/plugin-contract.ts @@ -0,0 +1,74 @@ +import type { SiteConfig } from '@purestack/ts-common' +import { definePlugin, resolveSiteConfig } from '../src/index' + +declare module '@purestack/ts-common' { + interface SiteExtensions { + contractExample?: { quality: number; preset: { widths: number[] } } + } +} + +/** Compiled by the package build; never executed or included in distributable entrypoints. */ +export function checkPluginContractTypes() { + const defaults = resolveSiteConfig() + const accent: string = defaults.style.theme.palette.light.accent + const replaced = resolveSiteConfig( + {}, + { plugins: [{ name: 'purestack:theme' }] }, + ) + // @ts-expect-error Replacing a default plugin may remove its settings. + const absent: string = replaced.style.theme.palette.light.accent + const custom: SiteConfig = resolveSiteConfig({}, { defaultPlugins: false }) + // @ts-expect-error Explicit plugin lists cannot guarantee default settings. + const missing: string = custom.docLayout.sidebarTop + + definePlugin({ + name: 'typed-contract', + config: { + resolve({ config }) { + const root: string = config.rootDir + const partial: number | undefined = + config.extensions.contractExample?.quality + const settings = config.extensions.contractExample + if (settings) { + // @ts-expect-error Plugin settings have not been normalized yet. + const _quality: number = settings.quality + } + return { + extensions: { contractExample: { quality: partial ?? root.length } }, + } + }, + normalize({ config }) { + const preset = config.extensions.contractExample?.preset + if (preset) { + // @ts-expect-error Nested plugin settings may still be partial. + const _widths: number[] = preset.widths + } + return { + extensions: { + contractExample: { quality: 80, preset: { widths: [480] } }, + }, + } + }, + finalize({ config }) { + const quality: number | undefined = + config.extensions.contractExample?.quality + const widths: number[] | undefined = + config.extensions.contractExample?.preset.widths + return { extensions: { finalQuality: quality, finalWidths: widths } } + }, + }, + hooks: { + onConfigResolved(context) { + // @ts-expect-error Asset lifecycle state is private to the builder. + context.assets.resetWrites() + // @ts-expect-error Processors are installed through contributions, not mutable hook state. + context.assetProcessors = [] + }, + onPageRendered(_context, page) { + // @ts-expect-error Ownership receipts are private to the builder. + page.assetReceipt.outputs = [] + }, + }, + }) + return { accent, absent, missing } +} diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index 3052b9f1..6fdf8df5 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-style/src/normalize.css.ts b/packages/ts-style/src/normalize.css.ts index cf674c1f..1f1084b5 100644 --- a/packages/ts-style/src/normalize.css.ts +++ b/packages/ts-style/src/normalize.css.ts @@ -66,6 +66,7 @@ function applyNormalizeMediaRules(theme: string) { .select('img, picture, video, canvas, svg', theme) .display('block') .maxWidth('100%') + styleBuilder.select('img', theme).height('auto') styleBuilder .select('button, input, textarea, select', theme) .font('inherit') diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 0bca0fd3..32975cd5 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-theme/README.md b/packages/ts-theme/README.md index 23fe1161..a960f848 100644 --- a/packages/ts-theme/README.md +++ b/packages/ts-theme/README.md @@ -28,3 +28,5 @@ and signature to the build manifest. The resolved config and active theme share the builder's owned options object. In-place `context.config.style.theme` edits and `themes.setOptions` from config hooks update head colors, CSS variables, and favicon colors together. + +The plugin owns defaults and normalization for `style`, `docLayout`, `pageToc`, and `favicon`. These settings are resolved before factories. Without it, `style`, `docLayout`, and `pageToc` are absent unless another plugin contributes them; check optional settings before accessing them in shared hooks. diff --git a/packages/ts-theme/package.json b/packages/ts-theme/package.json index 9d6fefcf..26d36086 100644 --- a/packages/ts-theme/package.json +++ b/packages/ts-theme/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-theme", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-theme.mjs", "types": "./dist/ts-theme.d.mts", diff --git a/packages/ts-theme/src/config.ts b/packages/ts-theme/src/config.ts new file mode 100644 index 00000000..27318f05 --- /dev/null +++ b/packages/ts-theme/src/config.ts @@ -0,0 +1,86 @@ +import type { + DocLayoutConfig, + PageTocConfig, + SiteConfigInput, + SiteStyleConfig, +} from '@purestack/ts-common' +import { + docLayoutDefaults, + pickSemanticTone, + resolveThemes, + themes, +} from '@purestack/ts-style' +import type { DeepPartial } from '@purestack/ts-util' +import { + pickBoolean, + resolveConfigUrl, + resolveOptionalString, + resolveString, +} from '@purestack/ts-util' + +export function resolveStyleConfig( + input: SiteConfigInput, + fileConfig: SiteConfigInput, +): SiteStyleConfig { + const styleInput = input.style + const styleFile = fileConfig.style + const fileName = resolveString( + styleInput?.fileName, + styleFile?.fileName, + 'site.css', + ) + const href = resolveConfigUrl( + resolveString(styleInput?.href, styleFile?.href, `/assets/${fileName}`), + ) + const themeNames = resolveThemes(styleInput?.themes, styleFile?.themes) + const pretty = pickBoolean(styleInput?.pretty, styleFile?.pretty, false) + const theme = themes.resolve(styleInput?.theme, styleFile?.theme) + return { + fileName, + href, + themes: themeNames, + pretty, + theme, + } +} + +export function resolveFavicon(...values: Array): string | undefined { + for (const value of values) { + const favicon = resolveOptionalString(value) + if (favicon) return favicon + } + return undefined +} + +export function resolvePageTocConfig( + input?: DeepPartial, + file?: DeepPartial, +): PageTocConfig { + const variant = + resolveOptionalString(input?.variant) ?? + resolveOptionalString(file?.variant) + const className = + resolveOptionalString(input?.class) ?? resolveOptionalString(file?.class) + return { + enabled: pickBoolean(input?.enabled, file?.enabled, true), + tone: pickSemanticTone(input?.tone, file?.tone) ?? 'neutral', + ...(variant ? { variant } : {}), + ...(className ? { class: className } : {}), + } +} + +export function resolveDocLayoutConfig( + input?: DeepPartial, + file?: DeepPartial, +): DocLayoutConfig { + return { + sidebarTop: + resolveOptionalString(input?.sidebarTop) ?? + resolveOptionalString(file?.sidebarTop) ?? + docLayoutDefaults.sidebarTop, + sidebarTopMobile: + resolveOptionalString(input?.sidebarTopMobile) ?? + resolveOptionalString(file?.sidebarTopMobile) ?? + docLayoutDefaults.sidebarTopMobile, + } +} diff --git a/packages/ts-theme/src/favicon.ts b/packages/ts-theme/src/favicon.ts index c64e3d3a..6e953844 100644 --- a/packages/ts-theme/src/favicon.ts +++ b/packages/ts-theme/src/favicon.ts @@ -15,6 +15,8 @@ export async function writeGeneratedFavicon(config: SiteConfig): Promise { } function buildFaviconSvg(config: SiteConfig, iconSvg: string) { + if (!config.style) + throw new Error('Generated favicons require resolved style settings.') const viewBox = extractViewBox(iconSvg) ?? '0 0 24 24' const rootAttributesSource = extractSvgRootAttributes(iconSvg) const rootAttributes = rootAttributesSource diff --git a/packages/ts-theme/src/index.ts b/packages/ts-theme/src/index.ts index 259620e5..53ed34b6 100644 --- a/packages/ts-theme/src/index.ts +++ b/packages/ts-theme/src/index.ts @@ -1,6 +1,7 @@ import type { PageTemplateInput, SiteConfig, + SiteConfigResolution, SiteStyles, } from '@purestack/ts-common' import { h } from '@purestack/ts-html' @@ -14,6 +15,13 @@ import { type ThemeOptions, themes, } from '@purestack/ts-style' +import { merge } from '@purestack/ts-util' +import { + resolveDocLayoutConfig, + resolveFavicon, + resolvePageTocConfig, + resolveStyleConfig, +} from './config' import { registerDocLayoutStyles } from './docLayoutStyles' import { writeGeneratedFavicon } from './favicon' import { markdownPresentation } from './markdownPresentation' @@ -28,6 +36,31 @@ export function themePlugin() { return { name: 'purestack:theme', defaults: true, + config: { + resolve({ config, input, file }: SiteConfigResolution) { + return { + style: merge(resolveStyleConfig(input, file), config.style), + docLayout: merge( + resolveDocLayoutConfig(input.docLayout, file.docLayout), + config.docLayout, + ), + pageToc: merge( + resolvePageTocConfig(input.pageToc, file.pageToc), + config.pageToc, + ), + favicon: + config.favicon ?? resolveFavicon(input.favicon, file.favicon), + } + }, + normalize({ config }: SiteConfigResolution) { + return { + style: resolveStyleConfig({ style: config.style }, {}), + docLayout: resolveDocLayoutConfig(config.docLayout), + pageToc: resolvePageTocConfig(config.pageToc), + favicon: resolveFavicon(config.favicon), + } + }, + }, templates: defaultTemplates, markdown: { rehypePostPlugins: [markdownPresentation] }, styles: createSiteStyles, @@ -40,9 +73,11 @@ export function themePlugin() { } function createSiteStyles(config: SiteConfig): SiteStyles { + const style = config.style + if (!style) throw new Error('Theme styles require resolved style settings.') const context = getStyleContext() - themes.setOptions(config.style.theme) - config.style.theme = themes.getOptions() + themes.setOptions(style.theme) + style.theme = themes.getOptions() styleBuilder.reset() registerNormalizeStyles() registerDocLayoutStyles() @@ -55,7 +90,7 @@ function createSiteStyles(config: SiteConfig): SiteStyles { return runWithStyleContext(context, () => styleBuilder.revision) }, links: (cacheKey) => - resolveThemeStyleLinks(config.style.href, config.style.themes, cacheKey), + resolveThemeStyleLinks(style.href, style.themes, cacheKey), write: (input) => runWithStyleContext(context, () => writeStyles( @@ -65,7 +100,7 @@ function createSiteStyles(config: SiteConfig): SiteStyles { !config.mdx.disableHighlighter && config.mdx.highlighter === 'highlightjs', }, - config.style, + style, ), ), } diff --git a/packages/ts-theme/src/templates.ts b/packages/ts-theme/src/templates.ts index 76b9c305..f5c91c33 100644 --- a/packages/ts-theme/src/templates.ts +++ b/packages/ts-theme/src/templates.ts @@ -25,6 +25,9 @@ function renderDocTemplate({ footerHtml, }: PageTemplateInput) { const frontmatter = pageInfo.frontmatter + const { docLayout, pageToc } = site + if (!docLayout || !pageToc) + throw new Error('The doc template requires resolved theme settings.') const layout = resolveDocLayout(frontmatter, navigation, outline) const showFooter = frontmatter.layout.showFooter return h('html') @@ -34,7 +37,7 @@ function renderDocTemplate({ h('body') .attr({ class: layout.bodyClass, - style: `${docLayoutVars.sidebarTop}: ${site.docLayout.sidebarTop}; ${docLayoutVars.sidebarTopMobile}: ${site.docLayout.sidebarTopMobile};`, + style: `${docLayoutVars.sidebarTop}: ${docLayout.sidebarTop}; ${docLayoutVars.sidebarTopMobile}: ${docLayout.sidebarTopMobile};`, }) .push( buildTopBar(headerHtml), @@ -72,13 +75,11 @@ function renderDocTemplate({ .attr({ class: 'doc-toc', id: 'doc-toc' }) .push( h('page-toc').attr({ - tone: frontmatter.layout.tocTone ?? site.pageToc.tone, - ...(site.pageToc.variant - ? { variant: site.pageToc.variant } - : {}), - ...(site.pageToc.class - ? { class: site.pageToc.class } + tone: frontmatter.layout.tocTone ?? pageToc.tone, + ...(pageToc.variant + ? { variant: pageToc.variant } : {}), + ...(pageToc.class ? { class: pageToc.class } : {}), }), ), ] diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index 3201b925..2e698015 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index 4d38f813..ce952e39 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.15", + "version": "1.1.16", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts", diff --git a/packages/ts-util/src/configValues.ts b/packages/ts-util/src/configValues.ts new file mode 100644 index 00000000..9564d5a4 --- /dev/null +++ b/packages/ts-util/src/configValues.ts @@ -0,0 +1,40 @@ +import { urlNormalizer } from './urlNormalizer' + +export function resolveString(...values: Array) { + for (const value of values) { + if (typeof value === 'string' && value.length > 0) return value + } + return '' +} + +export function pickBoolean(input: unknown, file: unknown, fallback = false) { + const searchValues = [input, file] + for (const value of searchValues) { + if (typeof value === 'boolean') return value + } + return fallback +} + +export function resolveOptionalString(value: unknown) { + if (typeof value !== 'string') return undefined + const normalized = value.trim() + return normalized.length > 0 ? normalized : undefined +} + +/** Content-root-relative URL, independent of the current page. */ +export function resolveConfigUrl(value: string): string +export function resolveConfigUrl(value: string | undefined): string | undefined +export function resolveConfigUrl(value: string | undefined) { + if (!value || value.startsWith('/') || urlNormalizer.isSpecialHref(value)) + return value + const { base, suffix } = urlNormalizer.splitSuffix(value) + const segments: string[] = [] + const pathname = base.replaceAll('\\', '/') + for (const part of pathname.split('/')) { + if (!part || part === '.') continue + if (part === '..') segments.pop() + else segments.push(part) + } + const trailing = pathname.endsWith('/') && segments.length > 0 ? '/' : '' + return `/${segments.join('/')}${trailing}${suffix}` +} diff --git a/packages/ts-util/src/index.ts b/packages/ts-util/src/index.ts index 160c2bfd..534e4531 100644 --- a/packages/ts-util/src/index.ts +++ b/packages/ts-util/src/index.ts @@ -10,6 +10,12 @@ export { Base } from './base' export { Cache, type CacheOptions } from './cache' export { clamp } from './clamp' export { clonePlainData } from './clonePlainData' +export { + pickBoolean, + resolveConfigUrl, + resolveOptionalString, + resolveString, +} from './configValues' export { escapeHtml } from './escapeHtml' export { type LoggerLike, logError } from './logging' export { merge } from './merge' diff --git a/yarn.lock b/yarn.lock index c2d6df8d..9b1cc014 100644 --- a/yarn.lock +++ b/yarn.lock @@ -246,6 +246,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.3": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + "@esbuild/aix-ppc64@npm:0.28.2": version: 0.28.2 resolution: "@esbuild/aix-ppc64@npm:0.28.2" @@ -428,6 +437,251 @@ __metadata: languageName: node linkType: hard +"@img/colour@npm:^1.1.0": + version: 1.1.0 + resolution: "@img/colour@npm:1.1.0" + checksum: 10c0/2ebea2c0bbaee73b99badcefa04e1e71d83f36e5369337d3121dca841f4569533c4e2faddda6d62dd247f0d5cca143711f9446c59bcce81e427ba433a7a94a17 + languageName: node + linkType: hard + +"@img/sharp-darwin-arm64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-darwin-arm64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-darwin-arm64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-arm64": + optional: true + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-darwin-x64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-darwin-x64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-darwin-x64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-darwin-x64": + optional: true + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-freebsd-wasm32@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-freebsd-wasm32@npm:0.35.5" + dependencies: + "@img/sharp-wasm32": "npm:0.35.5" + conditions: os=freebsd + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-arm64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-darwin-arm64@npm:1.3.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-libvips-darwin-x64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-darwin-x64@npm:1.3.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-arm64@npm:1.3.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-arm@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-arm@npm:1.3.4" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-ppc64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-ppc64@npm:1.3.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-riscv64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-riscv64@npm:1.3.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-s390x@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-s390x@npm:1.3.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linux-x64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linux-x64@npm:1.3.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-arm64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.3.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-libvips-linuxmusl-x64@npm:1.3.4": + version: 1.3.4 + resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.3.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linux-arm64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-arm64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-arm64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-arm@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-arm@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-arm": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-arm": + optional: true + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-ppc64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-ppc64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-ppc64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-ppc64": + optional: true + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-riscv64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-riscv64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-riscv64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-riscv64": + optional: true + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-s390x@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-s390x@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-s390x": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-s390x": + optional: true + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linux-x64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linux-x64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linux-x64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linux-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-arm64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linuxmusl-arm64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-linuxmusl-x64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-linuxmusl-x64@npm:0.35.5" + dependencies: + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.4" + dependenciesMeta: + "@img/sharp-libvips-linuxmusl-x64": + optional: true + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@img/sharp-wasm32@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-wasm32@npm:0.35.5" + dependencies: + "@emnapi/runtime": "npm:^1.11.3" + checksum: 10c0/da3f39d733135fde6d8b5009d282e12049f8f6df33ac3e4dc5d98868123842b5c888002ea1647e1824af9b4f9274a28826f35ebf2967b58a7416efd2aeb6299f + languageName: node + linkType: hard + +"@img/sharp-webcontainers-wasm32@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-webcontainers-wasm32@npm:0.35.5" + dependencies: + "@img/sharp-wasm32": "npm:0.35.5" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@img/sharp-win32-arm64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-win32-arm64@npm:0.35.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@img/sharp-win32-ia32@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-win32-ia32@npm:0.35.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@img/sharp-win32-x64@npm:0.35.5": + version: 0.35.5 + resolution: "@img/sharp-win32-x64@npm:0.35.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -796,6 +1050,17 @@ __metadata: languageName: unknown linkType: soft +"@purestack/ts-images@workspace:^, @purestack/ts-images@workspace:packages/ts-images": + version: 0.0.0-use.local + resolution: "@purestack/ts-images@workspace:packages/ts-images" + dependencies: + "@purestack/ts-common": "workspace:^" + "@purestack/ts-util": "workspace:^" + logpot: "npm:^1.0.7" + sharp: "npm:^0.35.5" + languageName: unknown + linkType: soft + "@purestack/ts-minidom@workspace:^, @purestack/ts-minidom@workspace:packages/ts-minidom": version: 0.0.0-use.local resolution: "@purestack/ts-minidom@workspace:packages/ts-minidom" @@ -818,6 +1083,7 @@ __metadata: resolution: "@purestack/ts-pagefind@workspace:packages/ts-pagefind" dependencies: "@purestack/ts-common": "workspace:^" + "@purestack/ts-util": "workspace:^" logpot: "npm:^1.0.7" pagefind: "npm:^1.4.0" languageName: unknown @@ -853,6 +1119,7 @@ __metadata: "@purestack/ts-components": "workspace:^" "@purestack/ts-highlight": "workspace:^" "@purestack/ts-html": "workspace:^" + "@purestack/ts-images": "workspace:^" "@purestack/ts-minidom": "workspace:^" "@purestack/ts-page-scripts": "workspace:^" "@purestack/ts-pagefind": "workspace:^" @@ -875,6 +1142,7 @@ __metadata: remark-gfm: "npm:^4.0.1" remark-parse: "npm:^11.0.0" remark-rehype: "npm:^11.1.2" + sharp: "npm:^0.35.5" unified: "npm:^11.0.5" bin: ts-ssg: ./dist/cli.mjs @@ -2202,7 +2470,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -4715,6 +4983,96 @@ __metadata: languageName: node linkType: hard +"sharp@npm:^0.35.5": + version: 0.35.5 + resolution: "sharp@npm:0.35.5" + dependencies: + "@img/colour": "npm:^1.1.0" + "@img/sharp-darwin-arm64": "npm:0.35.5" + "@img/sharp-darwin-x64": "npm:0.35.5" + "@img/sharp-freebsd-wasm32": "npm:0.35.5" + "@img/sharp-libvips-darwin-arm64": "npm:1.3.4" + "@img/sharp-libvips-darwin-x64": "npm:1.3.4" + "@img/sharp-libvips-linux-arm": "npm:1.3.4" + "@img/sharp-libvips-linux-arm64": "npm:1.3.4" + "@img/sharp-libvips-linux-ppc64": "npm:1.3.4" + "@img/sharp-libvips-linux-riscv64": "npm:1.3.4" + "@img/sharp-libvips-linux-s390x": "npm:1.3.4" + "@img/sharp-libvips-linux-x64": "npm:1.3.4" + "@img/sharp-libvips-linuxmusl-arm64": "npm:1.3.4" + "@img/sharp-libvips-linuxmusl-x64": "npm:1.3.4" + "@img/sharp-linux-arm": "npm:0.35.5" + "@img/sharp-linux-arm64": "npm:0.35.5" + "@img/sharp-linux-ppc64": "npm:0.35.5" + "@img/sharp-linux-riscv64": "npm:0.35.5" + "@img/sharp-linux-s390x": "npm:0.35.5" + "@img/sharp-linux-x64": "npm:0.35.5" + "@img/sharp-linuxmusl-arm64": "npm:0.35.5" + "@img/sharp-linuxmusl-x64": "npm:0.35.5" + "@img/sharp-webcontainers-wasm32": "npm:0.35.5" + "@img/sharp-win32-arm64": "npm:0.35.5" + "@img/sharp-win32-ia32": "npm:0.35.5" + "@img/sharp-win32-x64": "npm:0.35.5" + detect-libc: "npm:^2.1.2" + semver: "npm:^7.8.5" + dependenciesMeta: + "@img/sharp-darwin-arm64": + optional: true + "@img/sharp-darwin-x64": + optional: true + "@img/sharp-freebsd-wasm32": + optional: true + "@img/sharp-libvips-darwin-arm64": + optional: true + "@img/sharp-libvips-darwin-x64": + optional: true + "@img/sharp-libvips-linux-arm": + optional: true + "@img/sharp-libvips-linux-arm64": + optional: true + "@img/sharp-libvips-linux-ppc64": + optional: true + "@img/sharp-libvips-linux-riscv64": + optional: true + "@img/sharp-libvips-linux-s390x": + optional: true + "@img/sharp-libvips-linux-x64": + optional: true + "@img/sharp-libvips-linuxmusl-arm64": + optional: true + "@img/sharp-libvips-linuxmusl-x64": + optional: true + "@img/sharp-linux-arm": + optional: true + "@img/sharp-linux-arm64": + optional: true + "@img/sharp-linux-ppc64": + optional: true + "@img/sharp-linux-riscv64": + optional: true + "@img/sharp-linux-s390x": + optional: true + "@img/sharp-linux-x64": + optional: true + "@img/sharp-linuxmusl-arm64": + optional: true + "@img/sharp-linuxmusl-x64": + optional: true + "@img/sharp-webcontainers-wasm32": + optional: true + "@img/sharp-win32-arm64": + optional: true + "@img/sharp-win32-ia32": + optional: true + "@img/sharp-win32-x64": + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/73d61270979fdb89a14d523c071662f1f557814dac62faa5491ca975e0e2b0007665456941b90356a9a511b2fba8920d9088aa7f91b4786554e12852f94a82b0 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -5099,7 +5457,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.3, tslib@npm:^2.2.0, tslib@npm:^2.6.2": +"tslib@npm:^2.0.3, tslib@npm:^2.2.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62