diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5028ce72..463ea9d0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,11 @@ -## Change +## Summary -Describe the problem and resulting behavior. Link a related issue if applicable. +Summarize the change. -## Validation +## Changes + +Describe the changes. -Describe the checks you ran and include screenshots for visible UI changes. +## Validation -- [ ] Relevant tests and documentation updated. -- [ ] CI passes. -- [ ] Required generated assets regenerated manually, if applicable. +Describe the validation. diff --git a/frontend/purestack.studio/guides/extending/plugins.mdx b/frontend/purestack.studio/guides/extending/plugins.mdx index 1e0fe5b6..181e3582 100644 --- a/frontend/purestack.studio/guides/extending/plugins.mdx +++ b/frontend/purestack.studio/guides/extending/plugins.mdx @@ -89,6 +89,33 @@ yarn purestack serve --content ./content The badge appears on the page. Change its `tone` in `plugins/site.ts` while the server runs: the site rebuilds and the page reloads. `build` and `publish` load the same config, so the published site gets the same badge. +## Standard plugins + +PureStack includes three plugins automatically in `build`, `serve`, and `publish`: + +| Package | Factory | Provides | +| --- | --- | --- | +| `@purestack/ts-components` | `componentsPlugin()` | Standard components, icons, and component styles | +| `@purestack/ts-theme` | `themePlugin()` | The `doc` and `splash` templates, generated theme CSS, and theme switching | +| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, navigation, code copying, search, and TOC runtimes | + +The stylesheet provider initializes before component factories, then all config hooks run in plugin order. Names do not affect initialization. Components belong to each build, so hooks can override them through `context.components`. The theme and page runtime plugins supply head templates that run before the selected page template, preserving its ability to inspect or change the completed head. The lifecycle hook set is unchanged. + +To choose the standard integrations yourself, disable the automatic set and list the plugins you want. The factories are exported from `purestack`, `@purestack/ts-ssg`, and their individual packages: + +```ts +import { componentsPlugin, defineConfig, themePlugin } from 'purestack' + +export default defineConfig({ + defaultPlugins: false, + plugins: [themePlugin(), componentsPlugin()], +}) +``` + +This keeps the layouts and components while omitting the page runtimes. Theme switching belongs to the theme plugin; component-specific Tabs and Modal embeds still follow the page's `embed` settings. Without the theme plugin, another plugin must supply your page templates and any CSS output you need. + +An explicitly listed standard plugin replaces the automatic instance by name and keeps its initialization position. You can also use `createDefaultPlugins()` to obtain the complete set. In programmatic builds, use `options.defaultPlugins` alongside `options.plugins`. + ## 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: [...] })`. @@ -496,9 +523,12 @@ Every field except `name` is optional. | Field | Type | | --- | --- | | `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 }` | | `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 | +| `styles` | `(config) => SiteStyles`; one stylesheet provider, initialized before component factories | | `markdown` | `{ remarkPlugins?: PluggableList, rehypePlugins?: PluggableList }` | | `pages` | `({ config, files }) => { path, source }[]`, or a promise of them; `source` is the text, or a function that returns it | | `hooks` | Any of the hooks above | diff --git a/frontend/purestack.studio/siteConfig.json b/frontend/purestack.studio/siteConfig.json index 1a041666..98996576 100644 --- a/frontend/purestack.studio/siteConfig.json +++ b/frontend/purestack.studio/siteConfig.json @@ -1,5 +1,5 @@ { - "$schema": "../../packages/ts-ssg/schema/siteConfig.schema.json", + "$schema": "https://raw.githubusercontent.com/PureStackStudio/PureStack/refs/heads/main/packages/ts-ssg/schema/siteConfig.schema.json", "siteTitle": "PureStack", "favicon": "tabler:stack-2", "style": { diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index 1de3a185..83586c36 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -11,6 +11,7 @@ export type { PageFrontmatter, ParsedFrontmatterSource, } from './frontmatter-types' +export { isTocEnabled } from './isTocEnabled' export type { ClassicLogoConfig, LogoAppearance, @@ -32,6 +33,7 @@ export type { PageNavigationLinks, } from './navMenu-types' export type { + PageHeadTemplate, PageInfo, PageTemplate, PageTemplateInput, @@ -62,6 +64,7 @@ export type { SiteStyleConfig, SiteStyleConfigInput, } from './site-config-types' +export type { SiteStyles, WriteStylesResult } from './site-styles' export type { PageContext, PageOutlineItem, diff --git a/packages/ts-ssg/src/toc/isTocEnabled.ts b/packages/ts-common/src/isTocEnabled.ts similarity index 80% rename from packages/ts-ssg/src/toc/isTocEnabled.ts rename to packages/ts-common/src/isTocEnabled.ts index 007772e7..95d64b62 100644 --- a/packages/ts-ssg/src/toc/isTocEnabled.ts +++ b/packages/ts-common/src/isTocEnabled.ts @@ -1,4 +1,5 @@ -import type { PageFrontmatter, PageOutlineItem } from '@purestack/ts-common' +import type { PageFrontmatter } from './frontmatter-types' +import type { PageOutlineItem } from './ts-ssg-context' export function isTocEnabled( frontmatter: PageFrontmatter, diff --git a/packages/ts-common/src/page-template-types.ts b/packages/ts-common/src/page-template-types.ts index 9ac9ff67..837375ff 100644 --- a/packages/ts-common/src/page-template-types.ts +++ b/packages/ts-common/src/page-template-types.ts @@ -39,3 +39,8 @@ export type PageTemplate = ( ) => TSNode<'html'> | Promise> export type PageTemplateMap = Record + +/** Composes head markup before the page template receives it. */ +export type PageHeadTemplate = ( + input: PageTemplateInput, +) => TSNode<'head'> | Promise> diff --git a/packages/ts-common/src/site-styles.ts b/packages/ts-common/src/site-styles.ts new file mode 100644 index 00000000..f5aeeb06 --- /dev/null +++ b/packages/ts-common/src/site-styles.ts @@ -0,0 +1,18 @@ +import type { ThemeStylesheetLink } from '@purestack/ts-style' + +export interface WriteStylesResult { + revision: number + outPath: string + outputs: string[] + signature: string +} + +/** A plugin's stylesheet output, initialized before component factories. */ +export interface SiteStyles { + readonly revision: number + links(cacheKey?: string): ThemeStylesheetLink[] + write(input: { + outDir: string + cacheKey?: string + }): Promise +} diff --git a/packages/ts-components/README.md b/packages/ts-components/README.md index 948493f8..8462f613 100644 --- a/packages/ts-components/README.md +++ b/packages/ts-components/README.md @@ -8,3 +8,16 @@ by the static site generator and available to custom renderers. `registerStyles()` adds its styles to PureStack's theme builder. Individual component definitions and style registration functions are also exported when you need a smaller selection. + +`componentsPlugin()` integrates these components, SVG icons, and their styles +as default contributions scoped to each build. It is enabled by default by +`@purestack/ts-ssg` and can also be imported from this package: + +```ts +import { componentsPlugin } from '@purestack/ts-components' +``` + +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`. diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 700322c4..cdc11cff 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -54,6 +54,7 @@ "@purestack/ts-css": "workspace:^", "@purestack/ts-page-scripts": "workspace:^", "@purestack/ts-style": "workspace:^", + "@purestack/ts-svg-icons": "workspace:^", "@purestack/ts-util": "workspace:^", "regor": "^1.7.5" }, diff --git a/packages/ts-components/src/index.ts b/packages/ts-components/src/index.ts index 4d637473..29ba678d 100644 --- a/packages/ts-components/src/index.ts +++ b/packages/ts-components/src/index.ts @@ -15,6 +15,7 @@ export type { TsSsgTabsApi, } from '@purestack/ts-page-scripts' export { defineComponents } from './defineComponents' +export { componentsPlugin } from './plugin' export { registerStyles } from './registerStyles' export type { AlertBox } from './standard/alertBox/alertBox' export { defineAlertComponents } from './standard/alertBox/alertBox' diff --git a/packages/ts-components/src/plugin.ts b/packages/ts-components/src/plugin.ts new file mode 100644 index 00000000..44854193 --- /dev/null +++ b/packages/ts-components/src/plugin.ts @@ -0,0 +1,17 @@ +import { registerSemanticToneUtilityStyles } from '@purestack/ts-style' +import { getSvgIcon } from '@purestack/ts-svg-icons' +import { defineComponents } from './defineComponents' +import { registerStyles } from './registerStyles' + +/** The standard server-rendered components and their shared CSS. */ +export function componentsPlugin() { + return { + name: 'purestack:components', + defaults: true, + components(): Record { + registerStyles() + registerSemanticToneUtilityStyles() + return defineComponents(getSvgIcon) + }, + } +} diff --git a/packages/ts-html/src/tsNode.test.ts b/packages/ts-html/src/tsNode.test.ts index 9cea26fd..a0c07a46 100644 --- a/packages/ts-html/src/tsNode.test.ts +++ b/packages/ts-html/src/tsNode.test.ts @@ -3,6 +3,22 @@ import { describe, expect, it } from 'vitest' import { h } from './tsNode' describe('TSNode', () => { + it('inserts before the matching stylesheet while leaving other links alone', () => { + const head = h('head').push( + h('link').attr({ rel: 'icon', href: '/favicon.svg' }), + h('').push(h('link').attr({ rel: 'stylesheet', href: '/site.css' })), + ) + expect( + head.insertBefore('link', h('script').raw('hint()'), { + rel: 'stylesheet', + }), + ).toBe(true) + expect(head.toHtml()).toBe( + '', + ) + expect(head.insertBefore('meta', h('script').raw('missing()'))).toBe(false) + expect(head.toHtml()).not.toContain('missing()') + }) describe('toHtml', () => { it('renders an empty tag', () => { expect(h('div').toHtml()).toBe('
') diff --git a/packages/ts-html/src/tsNode.ts b/packages/ts-html/src/tsNode.ts index 5baf4aa1..469bf094 100644 --- a/packages/ts-html/src/tsNode.ts +++ b/packages/ts-html/src/tsNode.ts @@ -154,6 +154,28 @@ export class TSNode { return this } + /** Inserts before the first child matching a tag and attributes; returns whether it matched. */ + insertBefore( + tag: HtmlTag, + node: TSNode<''>, + attributes: Attributes = {}, + ): boolean { + for (let i = 0; i < this.#children.length; i++) { + const child = this.#children[i] + if ( + child.#tag === tag && + Object.entries(attributes).every( + ([key, value]) => child.#attributes[key] === value, + ) + ) { + this.#children.splice(i, 0, node) + return true + } + if (child.insertBefore(tag, node, attributes)) return true + } + return false + } + /** * Selects the first matching tag in a depth-first traversal (including this node), * replaces it using the provided callback, and returns this root. diff --git a/packages/ts-page-scripts/README.md b/packages/ts-page-scripts/README.md index c968d49c..c7485dfc 100644 --- a/packages/ts-page-scripts/README.md +++ b/packages/ts-page-scripts/README.md @@ -7,3 +7,18 @@ dialogs, search, and other page features. The site generator uses these builders when it adds interactive components to static HTML. The package also exports TypeScript types for the browser APIs exposed by those scripts, such as `TsSsgTabsApi` and `TsSsgModalApi`. + +`pageScriptsPlugin()` supplies a head template that adds page runtimes before +the selected page template runs. It is enabled by default by `@purestack/ts-ssg` and can +also be imported from this package: + +```ts +import { pageScriptsPlugin } from '@purestack/ts-page-scripts' +``` + +It preserves site configuration, page layout gates, consent-aware analytics, +and the site's base path. Theme startup belongs to `@purestack/ts-theme`; +component-specific Tabs/Modal embeds remain managed by the renderer. + +To omit the page runtimes, set `defaultPlugins: false` in `purestack.config.ts` +or build options and list the other plugins you need. diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index cd6787c7..35a3a06a 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -39,6 +39,7 @@ "test": "vitest" }, "dependencies": { - "@purestack/ts-common": "workspace:^" + "@purestack/ts-common": "workspace:^", + "@purestack/ts-html": "workspace:^" } } diff --git a/packages/ts-ssg/src/templates/buildGa4Script.ts b/packages/ts-page-scripts/src/buildGa4Script.ts similarity index 100% rename from packages/ts-ssg/src/templates/buildGa4Script.ts rename to packages/ts-page-scripts/src/buildGa4Script.ts diff --git a/packages/ts-page-scripts/src/index.ts b/packages/ts-page-scripts/src/index.ts index 101e9c00..ebc90493 100644 --- a/packages/ts-page-scripts/src/index.ts +++ b/packages/ts-page-scripts/src/index.ts @@ -8,6 +8,7 @@ export { buildNavMenuScript } from './embed/navMenu.embed' export { buildPagefindSearchScript } from './embed/pagefindSearch.embed' export { buildPageTocScript } from './embed/pageToc.embed' export { buildTabsScript } from './embed/tabs.embed' +export { pageScriptsPlugin } from './plugin' export type { TsSsgMenusApi, TsSsgModalApi, diff --git a/packages/ts-page-scripts/src/plugin.ts b/packages/ts-page-scripts/src/plugin.ts new file mode 100644 index 00000000..32d2a32a --- /dev/null +++ b/packages/ts-page-scripts/src/plugin.ts @@ -0,0 +1,63 @@ +import { isTocEnabled, type PageTemplateInput } from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { buildAuthStateHintScript } from './buildAuthStateHintScript' +import { buildConsentScript } from './buildConsentScript' +import { buildGa4Script } from './buildGa4Script' +import { buildCodeCopyScript } from './embed/codeCopy.embed' +import { buildMenuRuntimeScript } from './embed/menuRuntime.embed' +import { buildNavMenuScript } from './embed/navMenu.embed' +import { buildPagefindSearchScript } from './embed/pagefindSearch.embed' +import { buildPageTocScript } from './embed/pageToc.embed' + +/** The browser behaviors that accompany the default PureStack components. */ +export function pageScriptsPlugin() { + return { name: 'purestack:page-scripts', head: renderScriptsHead } +} + +function renderScriptsHead({ + head, + site: config, + pageInfo, + outline, +}: PageTemplateInput) { + if (config.auth.enabled && config.auth.signedInStorageKey) { + const hint = buildAuthStateHintScript(config.auth.signedInStorageKey) + if (hint) { + const auth = h('script').raw(hint) + // The hint must run before stylesheet loading can delay other scripts. + if ( + !head.insertBefore('link', auth, { rel: 'stylesheet' }) && + !head.insertBefore('script', auth) + ) + head.push(auth) + } + } + if (config.consent.enabled) + head.push(h('script').raw(buildConsentScript(config.consent))) + const ga4 = config.analytics.ga4 + if (ga4.enabled && ga4.measurementId && !config.consent.enabled) { + const payload = buildGa4Script(ga4) + head.push( + h('script').attr({ async: 'true', src: payload.src }), + h('script').raw(payload.init), + ) + } + head.push(h('script').raw(buildMenuRuntimeScript())) + if (pageInfo.frontmatter.layout.showNav !== false) + head.push(h('script').raw(buildNavMenuScript())) + head.push(h('script').raw(buildCodeCopyScript())) + if (config.pagefind.enabled) { + if (config.basePath) + head.push( + h('script').raw( + `window.tsSsgPagefindBasePath=${JSON.stringify(config.basePath)};`, + ), + ) + head.push( + h('script').attr({ type: 'module' }).raw(buildPagefindSearchScript()), + ) + } + if (isTocEnabled(pageInfo.frontmatter, outline)) + head.push(h('script').raw(buildPageTocScript())) + return head +} diff --git a/packages/ts-render/src/renderApp.ts b/packages/ts-render/src/renderApp.ts index 650d7d2b..a8e80165 100644 --- a/packages/ts-render/src/renderApp.ts +++ b/packages/ts-render/src/renderApp.ts @@ -8,6 +8,8 @@ import { componentRegistry } from './componentRegistry' export interface RenderAppOptions { /** Components for this render, on top of the registered ones. */ components: unknown + /** Whether this render also inherits the shared component registry. */ + inheritComponents?: boolean context: TContext /** * Runs on the rendered document, before it becomes HTML. It may be async: @@ -48,7 +50,9 @@ export const renderApp = ( createApp( { components: { - ...componentRegistry.getAll(), + ...(options.inheritComponents === false + ? {} + : componentRegistry.getAll()), ...(isPlainObject(options.components) ? options.components : {}), }, pageContext, diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index b1d6236c..3adfae9c 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -314,6 +314,33 @@ order, inherit into child folders, and skip external URLs. A plugin is a named bundle of extensions: skins, components, templates, Markdown transforms, generated pages, build hooks, and dev server middleware. The [Plugins guide](https://purestack.studio/guides/extending/plugins/) walks through each part. +Three plugins provide the standard site features and are enabled automatically for builds, publishing, and the development server: + +| Package | Factory | Features | +| --- | --- | --- | +| `@purestack/ts-components` | `componentsPlugin()` | Standard Regor components, SVG icons, and component styles | +| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, and theme switching | +| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Auth hint, consent and analytics, menus, code copying, search UI, and table of contents runtimes | + +These factories and `createDefaultPlugins()` are also exported from `@purestack/ts-ssg` and `purestack`. The stylesheet provider initializes before component factories. Components and templates resolve before every plugin's `onConfigResolved` hook runs in plugin order. Names do not change that order. Components are scoped to each build; config hooks can override them through `context.components`. + +Plugins can supply a `head` template, with the same input as a page template and a `TSNode<'head'>` result. Head templates compose in plugin order before the page template, so page templates can inspect or change the complete head. The lifecycle hook set is unchanged. + +The theme plugin supplies `styles(config)`, which returns the `SiteStyles` writer used for full builds and lazy development assets. Only one plugin can supply it. Component and template contributions marked `defaults: true` may be overridden by ordinary contributions; this flag controls merge precedence and never schedules hooks or factories. Conflicts within either group remain errors. + +To choose the integrations explicitly, set `defaultPlugins: false` in `purestack.config.ts` (or `options.defaultPlugins` programmatically): + +```ts +import { componentsPlugin, defineConfig, themePlugin } from '@purestack/ts-ssg' + +export default defineConfig({ + defaultPlugins: false, + plugins: [themePlugin(), componentsPlugin()], +}) +``` + +This keeps the standard layouts and components while omitting the page runtimes. Theme switching stays with the theme plugin, and component-specific Tabs/Modal embeds still follow each page's `embed` settings. Without the theme plugin, provide templates through another plugin. Extension templates and components can still override the standard ones. Explicitly listing a standard plugin while defaults are enabled replaces that plugin by name without adding a second instance; it retains its initialization position. Duplicate names within the supplied plugin list are errors. + A site lists its plugins in `purestack.config.ts`, next to `siteConfig.json`, and the CLI loads it: ```ts diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index dbca59cc..fe49623a 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -50,6 +50,7 @@ "@purestack/ts-render": "workspace:^", "@purestack/ts-style": "workspace:^", "@purestack/ts-svg-icons": "workspace:^", + "@purestack/ts-theme": "workspace:^", "@purestack/ts-util": "workspace:^", "@purestack/ts-util-node": "workspace:^", "esbuild": "^0.28.0", diff --git a/packages/ts-ssg/sample-content-i18n/siteConfig.json b/packages/ts-ssg/sample-content-i18n/siteConfig.json index ea2d4088..a260f639 100644 --- a/packages/ts-ssg/sample-content-i18n/siteConfig.json +++ b/packages/ts-ssg/sample-content-i18n/siteConfig.json @@ -1,5 +1,5 @@ { - "$schema": "../schema/siteConfig.schema.json", + "$schema": "https://raw.githubusercontent.com/PureStackStudio/PureStack/refs/heads/main/packages/ts-ssg/schema/siteConfig.schema.json", "siteTitle": "PureStack i18n Sample", "favicon": "tabler:globe", "logo": { diff --git a/packages/ts-ssg/sample-content/plans.mdx b/packages/ts-ssg/sample-content/plans.mdx index 01b11a4d..f2ef5e22 100644 --- a/packages/ts-ssg/sample-content/plans.mdx +++ b/packages/ts-ssg/sample-content/plans.mdx @@ -70,7 +70,7 @@ nav: summary="For organizations working at scale." price="Custom" period="example plan" - icon="lucide:building-2" + icon="lucide:building" tone="neutral" variant="flat" class="w-full rounded-lg p-5" diff --git a/packages/ts-ssg/sample-content/siteConfig.json b/packages/ts-ssg/sample-content/siteConfig.json index 9e681858..5c70b414 100644 --- a/packages/ts-ssg/sample-content/siteConfig.json +++ b/packages/ts-ssg/sample-content/siteConfig.json @@ -1,5 +1,5 @@ { - "$schema": "../schema/siteConfig.schema.json", + "$schema": "https://raw.githubusercontent.com/PureStackStudio/PureStack/refs/heads/main/packages/ts-ssg/schema/siteConfig.schema.json", "siteTitle": "Waypoint", "favicon": "lucide:compass", "logo": { diff --git a/packages/ts-ssg/src/build/build-config.ts b/packages/ts-ssg/src/build/build-config.ts index c54660de..ea1ed5d9 100644 --- a/packages/ts-ssg/src/build/build-config.ts +++ b/packages/ts-ssg/src/build/build-config.ts @@ -1,5 +1,7 @@ import type { SiteConfig } from '@purestack/ts-common' import { resolveSiteConfig } from '../config/config' +import { resolveBuildPlugins } from '../plugins/default-plugins' +import type { PureStackPlugin } from '../plugins/plugin' import { assertValidPlugins, withPluginSkins } from '../plugins/plugin' import type { BuildInput } from './site' @@ -7,8 +9,10 @@ export interface PublishOptions { enabled?: boolean } -export function resolveBuildSiteConfig(input: BuildInput = {}): SiteConfig { - const plugins = input.options?.plugins ?? [] +export function resolveBuildSiteConfig( + input: BuildInput = {}, + plugins: readonly PureStackPlugin[] = resolveBuildPlugins(input.options), +): SiteConfig { assertValidPlugins(plugins) const config = withPluginSkins(plugins, () => resolveSiteConfig(input.siteConfig), diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index 5e40f11d..494bc60b 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -1,7 +1,6 @@ import fsPromises from 'node:fs/promises' import path from 'node:path' -import type { SiteConfig } from '@purestack/ts-common' -import { styleBuilder, themes } from '@purestack/ts-style' +import type { SiteConfig, WriteStylesResult } from '@purestack/ts-common' import { toOutputAssetRelPath, toPosixPath } from '@purestack/ts-util' import { getLogger, type Logger } from 'logpot' import { @@ -20,14 +19,16 @@ import { resolveContentFile, } from '../../i18n/content' import { buildNavigation } from '../../navigation/navigation' +import { + initializePluginContext, + resolveBuildPlugins, +} from '../../plugins/default-plugins' import { composePluginHooks, type PureStackPlugin, - resolvePluginComponents, resolvePluginContentProcessor, - resolvePluginTemplates, } from '../../plugins/plugin' -import { initBuiltinComponents } from '../../regor/initBuiltinComponents' +import { initRenderer } from '../../regor/initRenderer' import { resolveRouteInfo } from '../../routing/route' import { copyStaticAsset, copyStaticAssets } from '../assets' import { resolveBuildSiteConfig } from '../build-config' @@ -47,7 +48,6 @@ import { buildPagefindIndex } from '../pagefind' import { createScriptCacheKey, ScriptCacheKeyStore } from '../script-cache-key' import type { BuildHooks, BuildInput, BuildResult } from '../site' import { writeSitemap } from '../sitemap' -import { type WriteStylesResult, writeStyles } from '../styles' import { IncrementalChangeApplier } from './change-applier' import { IncrementalContentState } from './content-state' import { ScriptEntrypointManager } from './script-entry-manager' @@ -89,8 +89,8 @@ async function createIncrementalRuntime( ): Promise { const buildOptions = input.options ?? {} const publishOptions = input.publish ?? {} - const plugins = buildOptions.plugins ?? [] - const config = resolveBuildSiteConfig(input) + const plugins = resolveBuildPlugins(buildOptions) + const config = resolveBuildSiteConfig(input, plugins) const hooks = composePluginHooks(plugins) const cleanOutDir = publishOptions.enabled === true || buildOptions.cleanOutDir === true @@ -100,8 +100,7 @@ async function createIncrementalRuntime( ...(await resolveMdxBuildOptions(config.mdx, plugins)), contentProcessor: resolvePluginContentProcessor(plugins), } - themes.setOptions(config.style.theme) - initBuiltinComponents({ includeShikiStyles: isShikiEnabled(config.mdx) }) + initRenderer() const log = getLogger() const existing = await readManifest(config.outDir) const manifest = @@ -116,8 +115,6 @@ async function createIncrementalRuntime( // request; see IncrementalRuntime.ensureContentReady. contentRoutes: new ContentRouteIndex([]), writeErrorPages: buildOptions.writeErrorPages === true, - components: resolvePluginComponents(plugins, config), - templates: resolvePluginTemplates(plugins), mdx, resolveScriptPublicPath: config.scripts.cacheBusting ? (sourceRelPath) => @@ -141,14 +138,6 @@ async function createIncrementalRuntime( }) } -function isShikiEnabled(mdx: SiteConfig['mdx'] | undefined): boolean { - return mdx?.disableHighlighter !== true && mdx?.highlighter === 'shiki' -} - -function isHighlightJsEnabled(mdx: SiteConfig['mdx'] | undefined): boolean { - return mdx?.disableHighlighter !== true && mdx?.highlighter === 'highlightjs' -} - type BuildPreparationResult = { contentFiles: ResolvedContentFile[] } @@ -270,7 +259,7 @@ class IncrementalRuntime { } private async prepareRequestState() { - await this.options.hooks.onConfigResolved?.(this.context) + await initializePluginContext(this.context, this.options.plugins) await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir }) // Keep the records object shared with the script manager, but start this // session with no rendered pages or copied assets, even without --clean. @@ -403,7 +392,7 @@ class IncrementalRuntime { private async prepareBuild( hooks: BuildHooks, ): Promise { - await hooks.onConfigResolved?.(this.context) + await initializePluginContext(this.context, this.options.plugins) await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir }) await writeGeneratedFavicon(this.config) await this.discoverAssets() @@ -462,6 +451,7 @@ class IncrementalRuntime { } private async prepareExistingContent() { + await initializePluginContext(this.context, this.options.plugins) await this.discoverAssets() await this.prepareContent( [...this.staticAssets.values()].map((file) => file.relPath), @@ -484,15 +474,10 @@ class IncrementalRuntime { } private async writeStylesWithHooks(hooks: BuildHooks) { - const { outDir, style } = this.config - const styleResult = await writeStyles( - { - outDir, - includeHljsTheme: isHighlightJsEnabled(this.config.mdx), - cacheKey: this.context.styleCacheKey, - }, - style, - ) + const styleResult = (await this.context.styles?.write({ + outDir: this.config.outDir, + cacheKey: this.context.styleCacheKey, + })) ?? { revision: 0, outPath: '', outputs: [], signature: '' } this.preparedStyleRevision = styleResult.revision await hooks.onStylesWritten?.(this.context, styleResult) return styleResult @@ -681,14 +666,14 @@ class IncrementalRuntime { const revision = this.pageAssetRevision if ( revision === this.preparedAssetRevision && - this.preparedStyleRevision === styleBuilder.revision + this.preparedStyleRevision === (this.context.styles?.revision ?? 0) ) return // Only pages rendered in this session have registered script entries. await this.scriptEntrypoints.buildMissingEntrypoints( this.changeApplier.createResult('requested page assets'), ) - if (this.preparedStyleRevision !== styleBuilder.revision) { + if (this.preparedStyleRevision !== (this.context.styles?.revision ?? 0)) { const styles = await this.writeStylesWithHooks(this.options.hooks) this.manifest.styles = { signature: styles.signature, diff --git a/packages/ts-ssg/src/build/page.footer.test.ts b/packages/ts-ssg/src/build/page.footer.test.ts index 8b6e89e6..3b11ffd0 100644 --- a/packages/ts-ssg/src/build/page.footer.test.ts +++ b/packages/ts-ssg/src/build/page.footer.test.ts @@ -4,7 +4,6 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' - import { resolveSiteConfig } from '../config/config' import { discoverContent, discoverStaticAssets } from '../discover/content' import { @@ -12,10 +11,10 @@ import { resolveContentFiles, resolvePlainContentFile, } from '../i18n/content' -import { initBuiltinComponents } from '../regor/initBuiltinComponents' +import { initRenderer } from '../regor/initRenderer' +import { renderDefaultPage as renderPageFromFile } from '../test/defaultPlugins' import { ContentRouteIndex } from './content-urls' import { - renderPageFromFile, resolveFooterHtmlByDirectory, resolveHeaderHtmlByDirectory, } from './page' @@ -31,7 +30,7 @@ describe('footer hierarchy', () => { beforeAll(() => { disableLogger() logger = getLogger() - initBuiltinComponents() + initRenderer() }) afterAll(async () => { diff --git a/packages/ts-ssg/src/build/page.tabs.test.ts b/packages/ts-ssg/src/build/page.tabs.test.ts index 2cef2afb..d820e6ba 100644 --- a/packages/ts-ssg/src/build/page.tabs.test.ts +++ b/packages/ts-ssg/src/build/page.tabs.test.ts @@ -14,8 +14,8 @@ import { type ResolvedContentFile, resolvePlainContentFile, } from '../i18n/content' +import { renderDefaultPage as renderPageFromFile } from '../test/defaultPlugins' import { ContentRouteIndex } from './content-urls' -import { renderPageFromFile } from './page' async function writeFile(filePath: string, contents = '') { await fs.mkdir(path.dirname(filePath), { recursive: true }) diff --git a/packages/ts-ssg/src/build/page.test.ts b/packages/ts-ssg/src/build/page.test.ts index 6fbdf661..3e8076c5 100644 --- a/packages/ts-ssg/src/build/page.test.ts +++ b/packages/ts-ssg/src/build/page.test.ts @@ -14,9 +14,9 @@ import { resolvePlainContentFile, } from '../i18n/content' import { buildNavigation } from '../navigation/navigation' -import { initBuiltinComponents } from '../regor/initBuiltinComponents' +import { initRenderer } from '../regor/initRenderer' +import { renderDefaultPage as renderPageFromFile } from '../test/defaultPlugins' import { ContentRouteIndex } from './content-urls' -import { renderPageFromFile } from './page' async function writeFile(filePath: string, contents = '') { await fs.mkdir(path.dirname(filePath), { recursive: true }) @@ -29,7 +29,7 @@ describe('page content compilation', () => { beforeAll(() => { disableLogger() logger = getLogger() - initBuiltinComponents() + initRenderer() }) afterAll(async () => { diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index 454a9141..6e5c7ead 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -1,16 +1,17 @@ import path from 'node:path' import type { PageFrontmatter, + PageHeadTemplate, PageInfo, PageNavigation, PageOutlineItem, PageTemplateMap, PageTranslationInfo, SiteConfig, + SiteStyles, } from '@purestack/ts-common' import type { BasicHeadConfig } from '@purestack/ts-html' import { renderApp } from '@purestack/ts-render' -import { resolveThemeStyleLinks } from '@purestack/ts-style' import { isError, toOutputAssetRelPath, withBasePath } from '@purestack/ts-util' import { getLogger } from 'logpot' import { @@ -49,6 +50,8 @@ import type { BuildHooks } from './site' export interface BuildContext { /** Shared by generated stylesheet filenames and links for this build/session. */ styleCacheKey?: string + /** Style output owned by the site's stylesheet provider. */ + styles?: SiteStyles config: SiteConfig contentRoutes: ContentRouteIndex headerHtmlByDir?: Map @@ -58,6 +61,7 @@ export interface BuildContext { writeErrorPages?: boolean components?: Record templates?: PageTemplateMap + headTemplates?: readonly PageHeadTemplate[] navigation?: NavigationTree translationsByKey?: ContentTranslationsByKey mdx?: MdxRenderOptions @@ -70,6 +74,7 @@ export interface PageDocument { file: ResolvedContentFile frontmatter: PageFrontmatter urlPath: string + outline?: PageOutlineItem[] } export interface PageRenderResult { @@ -186,6 +191,7 @@ export async function renderPageFromFile( file, frontmatter, urlPath, + outline: compiled.outline, }), ) const renderTimeMs = @@ -398,20 +404,15 @@ async function renderPageShell(input: RenderPageShellInput): Promise { return await renderPage({ bodyHtml, headConfig, - styleLinks: resolveThemeStyleLinks( - context.config.style.href, - context.config.style.themes, - context.styleCacheKey, - ), + styleLinks: context.styles?.links(context.styleCacheKey), template, templates: context.templates, + headTemplates: context.headTemplates, site: context.config, navigation, outline, pageInfo, ...resolvePagePartials(context, pageInfo), - consent: context.config.consent, - analytics: context.config.analytics, }) } @@ -487,6 +488,7 @@ function renderPageApp( const { scriptEntrypoints, ...baseContext } = appContext return renderApp(htmlShell, { components: context.components, + inheritComponents: false, onRendered: async (document) => { await onDocument(document) resolvePageUrls(document, { diff --git a/packages/ts-ssg/src/build/renderer.test.ts b/packages/ts-ssg/src/build/renderer.test.ts index 4c788586..293c7c68 100644 --- a/packages/ts-ssg/src/build/renderer.test.ts +++ b/packages/ts-ssg/src/build/renderer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { resolveSiteConfig } from '../config/config' import { normalizeFrontmatter } from '../frontmatter/frontmatter' -import { renderPage } from './renderer' +import { renderDefaultShell as renderPage } from '../test/defaultPlugins' describe('renderPage consent integration', () => { it('passes configured sidebar offsets to the documentation layout', async () => { @@ -19,8 +19,8 @@ describe('renderPage consent integration', () => { }, site, }) - expect(html).toContain('--ps-doc-layout-sidebar-top: 8rem;') - expect(html).toContain('--ps-doc-layout-sidebar-top-mobile: 72px;') + expect(html).toMatch(/--ps-doc-layout-sidebar-top:\s*8rem;/) + expect(html).toMatch(/--ps-doc-layout-sidebar-top-mobile:\s*72px(?:;|")/) }) it('injects the critical auth state hint when auth is enabled', async () => { const site = resolveSiteConfig({ @@ -137,8 +137,6 @@ describe('renderPage consent integration', () => { frontmatter: normalizeFrontmatter({}), }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).not.toContain('window.tsSsgConsent') }) @@ -166,8 +164,6 @@ describe('renderPage consent integration', () => { frontmatter: normalizeFrontmatter({}), }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).toContain('window.tsSsgConsent') expect(html).toContain('ga4') @@ -190,8 +186,6 @@ describe('renderPage consent integration', () => { frontmatter: normalizeFrontmatter({}), }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).toContain('window.tsSsgConsent') }) @@ -216,8 +210,6 @@ describe('renderPage consent integration', () => { frontmatter: normalizeFrontmatter({}), }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).toContain('googletagmanager.com/gtag/js?id=G-TEST1234') expect(html).toMatch( @@ -245,8 +237,6 @@ describe('renderPage consent integration', () => { frontmatter: normalizeFrontmatter({}), }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).not.toMatch( /]*\bsrc="https:\/\/www\.googletagmanager\.com\/gtag\/js\?id=G-TEST1234"/, @@ -270,8 +260,6 @@ describe('renderPage consent integration', () => { site, footerHtml: '', - consent: site.consent, - analytics: site.analytics, }) expect(html).toContain('Ready') @@ -288,8 +276,6 @@ describe('renderPage consent integration', () => { }, site, headerHtml: '', - consent: site.consent, - analytics: site.analytics, }) expect(html).toContain('') }) @@ -309,8 +295,6 @@ describe('renderPage consent integration', () => { }, outline: [], site, - consent: site.consent, - analytics: site.analytics, }) expect(html).not.toContain('class="doc-toc"') expect(html).not.toContain('doc-shell--toc') @@ -427,8 +411,6 @@ describe('renderPage consent integration', () => { tone: 'neutral', }, site, - consent: site.consent, - analytics: site.analytics, }) expect(html).not.toContain('class="doc-sidebar"') expect(html).toContain(' { const { bodyHtml, headConfig, styleLinks, template, templates } = input - const head = getHead(headConfig) - const themes = getStyleThemes(styleLinks) - head.push(h('style').raw(buildCriticalThemeStyle(themes.length > 0))) - appendAuthStateHintScript(head, input.site) - appendStyleLinkTags(head, styleLinks) - appendThemeSwitchScript(head, themes) - appendConsentScript(head, input.consent) - appendGa4Script(head, input.analytics, input.consent) - appendMenuScript(head) - if (isNavEnabled(input.pageInfo.frontmatter)) { - appendNavMenuScript(head) - } - appendCodeCopyScript(head) - appendPagefindSearchScript(head, input.site) + let head = getHead(headConfig) const { pageTemplate, templateName } = resolvePageTemplate( template, templates, ) - appendTocScript(head, input.pageInfo.frontmatter, input.outline) - const html = await pageTemplate({ + const templateInput = { head, bodyHtml, headConfig, @@ -74,143 +45,10 @@ export async function renderPage(input: RenderPageInput): Promise { pageInfo: input.pageInfo, headerHtml: input.headerHtml, footerHtml: input.footerHtml, - }) - return html.toHtml() -} - -function appendAuthStateHintScript( - head: ReturnType, - site: SiteConfig, -) { - if (!site.auth.enabled || !site.auth.signedInStorageKey) return - const script = buildAuthStateHintScript(site.auth.signedInStorageKey) - if (!script) return - head.push(h('script').raw(script)) -} - -function getStyleThemes( - styleLinks: ThemeStylesheetLink[] | undefined, -): string[] { - return (styleLinks ?? []) - .map((link) => link.dataTheme) - .filter((theme): theme is string => Boolean(theme)) -} - -function appendStyleLinkTags( - head: ReturnType, - styleLinks: ThemeStylesheetLink[] | undefined, -) { - if (!styleLinks || styleLinks.length === 0) return - for (const link of styleLinks) { - head.push( - h('link').attr({ - rel: link.rel, - href: link.href, - ...(link.media ? { media: link.media } : {}), - ...(link.dataTheme ? { 'data-theme': link.dataTheme } : {}), - }), - ) } -} - -function appendThemeSwitchScript( - head: ReturnType, - themes: string[], -) { - if (themes.length === 0) return - const script = buildThemeSwitchScript(themes) - head.push(h('script').raw(script)) -} - -function appendConsentScript( - head: ReturnType, - consent: ConsentConfig | undefined, -) { - if (!consent?.enabled) return - head.push(h('script').raw(buildConsentScript(consent))) -} - -function appendGa4Script( - head: ReturnType, - analytics: AnalyticsConfig | undefined, - consent: ConsentConfig | undefined, -) { - const ga4 = analytics?.ga4 - if (!ga4?.enabled || !ga4.measurementId) return - if (consent?.enabled) return - const script = buildGa4Script(ga4) - head.push( - h('script').attr({ async: 'true', src: script.src }), - h('script').raw(script.init), - ) -} - -function appendNavMenuScript(head: ReturnType) { - const script = buildNavMenuScript() - head.push(h('script').raw(script)) -} - -function appendMenuScript(head: ReturnType) { - const script = buildMenuRuntimeScript() - head.push(h('script').raw(script)) -} - -function isNavEnabled(frontmatter: PageFrontmatter) { - return frontmatter.layout.showNav !== false -} - -function appendTocScript( - head: ReturnType, - frontmatter: PageFrontmatter, - outline: PageOutlineItem[] | undefined, -) { - if (!isTocEnabled(frontmatter, outline)) return - const script = buildPageTocScript() - head.push(h('script').raw(script)) -} - -function appendCodeCopyScript(head: ReturnType) { - const script = buildCodeCopyScript() - head.push(h('script').raw(script)) -} - -function appendPagefindSearchScript( - head: ReturnType, - site: SiteConfig, -) { - if (!site.pagefind.enabled) return - if (site.basePath) { - head.push( - h('script').raw( - `window.tsSsgPagefindBasePath=${JSON.stringify(site.basePath)};`, - ), - ) - } - const script = buildPagefindSearchScript() - head.push(h('script').attr({ type: 'module' }).raw(script)) -} - -function buildCriticalThemeStyle(hasThemeGate: boolean) { - const options = themes.getOptions() - const lightPalette = options.palette.light - const darkPalette = options.palette.dark - const light = { - background: lightPalette.semanticTone.neutral.canvas, - text: lightPalette.semanticTone.neutral.text.default, - } - const dark = { - background: darkPalette.semanticTone.neutral.canvas, - text: darkPalette.semanticTone.neutral.text.default, + for (const renderHead of input.headTemplates ?? []) { + head = await renderHead({ ...templateInput, head }) } - const css = [ - ':root{color-scheme:light dark;}', - `html,body{background:${light.background};color:${light.text};}`, - '@media (prefers-color-scheme: dark){', - `html,body{background:${dark.background};color:${dark.text};}`, - '}', - ] - if (hasThemeGate) { - css.push('html:not([data-theme-ready]) body{visibility:hidden;}') - } - return css.join('') + const html = await pageTemplate({ ...templateInput, head }) + return html.toHtml() } diff --git a/packages/ts-ssg/src/build/site.ts b/packages/ts-ssg/src/build/site.ts index f3604a37..abec7cc4 100644 --- a/packages/ts-ssg/src/build/site.ts +++ b/packages/ts-ssg/src/build/site.ts @@ -1,4 +1,4 @@ -import type { SiteConfigInput } from '@purestack/ts-common' +import type { SiteConfigInput, WriteStylesResult } from '@purestack/ts-common' import type { ResolvedContentFile } from '../i18n/content' import type { NavigationTree } from '../navigation/navigation' import type { PureStackPlugin } from '../plugins/plugin' @@ -6,7 +6,6 @@ import type { PublishOptions } from './build-config' import { createIncrementalBuilder } from './incremental' import { ensureLogger } from './logger' import type { BuildContext, PageDocument, PageRenderResult } from './page' -import type { WriteStylesResult } from './styles' export type { PublishOptions } from './build-config' @@ -65,6 +64,8 @@ export interface BuildHooks { export interface BuildOptions { cleanOutDir?: boolean writeErrorPages?: boolean + /** Include the standard component, theme, and browser-runtime plugins. */ + defaultPlugins?: boolean /** Extensions to apply, in order. See {@link PureStackPlugin}. */ plugins?: PureStackPlugin[] } diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 7f361019..31895ca9 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -4,7 +4,6 @@ import { getHeadConfig, h, } from '@purestack/ts-html' -import { themes } from '@purestack/ts-style' import { merge } from '@purestack/ts-util' const DEFAULTS: BasicHeadConfig = { @@ -22,35 +21,10 @@ const DEFAULTS: BasicHeadConfig = { } export function getHead(config?: BasicHeadConfig) { - const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push( + return createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', content: 'PureStack v1.1.13', }), - h('meta').attr({ - name: 'color-scheme', - content: 'dark light', - }), - ...buildThemeColorMetaTags(), ) - return head -} - -function buildThemeColorMetaTags() { - const options = themes.getOptions() - const lightColor = options.palette.light.accent - const darkColor = options.palette.dark.accent - - return [ - h('meta').attr({ - name: 'theme-color', - media: '(prefers-color-scheme: light)', - content: lightColor, - }), - h('meta').attr({ - name: 'theme-color', - media: '(prefers-color-scheme: dark)', - content: darkColor, - }), - ] } diff --git a/packages/ts-ssg/src/config/project-config.test.ts b/packages/ts-ssg/src/config/project-config.test.ts index ae261dde..29e58d2e 100644 --- a/packages/ts-ssg/src/config/project-config.test.ts +++ b/packages/ts-ssg/src/config/project-config.test.ts @@ -93,7 +93,11 @@ describe('purestack.config.ts', () => { ], [ 'export default { plugin: [] }', - 'has an unknown field "plugin". Config fields: plugins.', + 'has an unknown field "plugin". Config fields: plugins, defaultPlugins.', + ], + [ + 'export default { defaultPlugins: [] }', + 'needs defaultPlugins to be a boolean.', ], ['export default {', 'Could not bundle'], ["throw new Error('Config failed on purpose.')", 'Could not run'], @@ -122,6 +126,16 @@ describe('purestack.config.ts', () => { }) }) + it('loads and applies default plugin selection without a plugins array', async () => { + const projectRoot = await createProject({ + [PROJECT_CONFIG_FILENAME]: 'export default { defaultPlugins: false }', + }) + const loaded = await loadProjectConfig( + path.join(projectRoot, PROJECT_CONFIG_FILENAME), + ) + expect(withProjectConfig({}, loaded).options?.defaultPlugins).toBe(false) + }) + it('builds with the config when the CLI finds one', async () => { const projectRoot = await createProject({ 'content/siteConfig.json': JSON.stringify({ outDir: '../out' }), diff --git a/packages/ts-ssg/src/config/project-config.ts b/packages/ts-ssg/src/config/project-config.ts index 62ce13fd..cff8f88c 100644 --- a/packages/ts-ssg/src/config/project-config.ts +++ b/packages/ts-ssg/src/config/project-config.ts @@ -9,12 +9,14 @@ import type { PureStackPlugin } from '../plugins/plugin' export const PROJECT_CONFIG_FILENAME = 'purestack.config.ts' -const CONFIG_FIELDS = ['plugins'] +const CONFIG_FIELDS = ['plugins', 'defaultPlugins'] const SKIP_PACKAGE_RESOLUTION = Symbol('skip package resolution') /** The code a site adds to PureStack, loaded from `purestack.config.ts`. */ export interface PureStackConfig { plugins?: PureStackPlugin[] + /** Include the standard component, theme, and browser-runtime plugins. */ + defaultPlugins?: boolean } export function defineConfig(config: PureStackConfig): PureStackConfig { @@ -38,12 +40,18 @@ export function withProjectConfig( input: BuildInput, loaded: LoadedProjectConfig | undefined, ): BuildInput { - if (!loaded?.config.plugins) return input + if (!loaded) return input return { ...input, options: { ...input.options, - plugins: [...(input.options?.plugins ?? []), ...loaded.config.plugins], + ...(loaded.config.defaultPlugins !== undefined + ? { defaultPlugins: loaded.config.defaultPlugins } + : {}), + plugins: [ + ...(input.options?.plugins ?? []), + ...(loaded.config.plugins ?? []), + ], }, } } @@ -155,6 +163,12 @@ function assertProjectConfig( ) } } + if ( + value.defaultPlugins !== undefined && + typeof value.defaultPlugins !== 'boolean' + ) { + throw new Error(`${filePath} needs defaultPlugins to be a boolean.`) + } } function toMessage(error: unknown) { diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts index a77a81cf..994c2c8a 100644 --- a/packages/ts-ssg/src/index.ts +++ b/packages/ts-ssg/src/index.ts @@ -1,13 +1,17 @@ export type { PageContext, PageFrontmatter, + PageHeadTemplate, PageInfo, PageTemplate, PageTemplateInput, PageTemplateMap, SiteConfig, SiteConfigInput, + SiteStyles, + WriteStylesResult, } from '@purestack/ts-common' +export { defaultTemplates } from '@purestack/ts-theme' export type { BuildContext, PageDocument, @@ -22,7 +26,6 @@ export { buildSite, type PublishOptions, } from './build/site' -export type { WriteStylesResult } from './build/styles' export { runCli } from './cli-runner' export { resolveSiteConfig } from './config/config' export { defineConfig, type PureStackConfig } from './config/project-config' @@ -52,6 +55,12 @@ export { resolveNavigationConfig, resolvePageNavigation, } from './navigation/navigation' +export { + componentsPlugin, + createDefaultPlugins, + pageScriptsPlugin, + themePlugin, +} from './plugins/default-plugins' export { definePlugin, type GeneratedPage, @@ -59,7 +68,4 @@ export { type PureStackMarkdown, type PureStackPlugin, } from './plugins/plugin' -export { - defaultTemplates, - resolvePageTemplate, -} from './templates/page-templates' +export { resolvePageTemplate } from './templates/page-templates' diff --git a/packages/ts-ssg/src/plugins/default-plugins.test.ts b/packages/ts-ssg/src/plugins/default-plugins.test.ts new file mode 100644 index 00000000..d5772e96 --- /dev/null +++ b/packages/ts-ssg/src/plugins/default-plugins.test.ts @@ -0,0 +1,515 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { h } from '@purestack/ts-html' +import { runInDom } from '@purestack/ts-minidom' +import { styleBuilder, themes } from '@purestack/ts-style' +import { disableLogger, getLogger, type Logger } from 'logpot' +import { defineComponent, html } from 'regor' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { createIncrementalBuilder } from '../build/incremental' +import { readManifest } from '../build/manifest' +import { type BuildOptions, buildSite } from '../build/site' +import { makeRepoTempDir } from '../test/repoTempDir' +import { + componentsPlugin, + pageScriptsPlugin, + themePlugin, +} from './default-plugins' +import { definePlugin } from './plugin' + +describe('default plugins', () => { + let logger: Logger + let root: string | undefined + + beforeAll(() => { + disableLogger() + logger = getLogger() + }) + afterAll(async () => logger.close()) + afterEach(async () => { + if (root) await fs.rm(root, { recursive: true, force: true }) + root = undefined + }) + + async function project(source = '# Home\n\nAction') { + root = await makeRepoTempDir('.tmp-default-plugins-') + const contentDir = path.join(root, 'content') + await fs.mkdir(contentDir) + await fs.writeFile(path.join(contentDir, 'index.mdx'), source) + return { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + basePath: '/docs', + mdx: { disableHighlighter: true }, + } + } + + it.each([false, true])( + 'preserves overrides and custom styles with explicit plugins %s', + async (explicit) => { + const siteConfig = await project() + let replacement: object + const extension = definePlugin({ + name: 'custom', + components() { + styleBuilder.select('.factory-style', 'light').color('red') + replacement = defineComponent( + html`Custom button`, + ) + return { btn: replacement } + }, + templates: { + doc: ({ head, bodyHtml }) => + h('html').push( + head, + h('body').attr({ class: 'custom-doc' }).raw(bodyHtml), + ), + }, + hooks: { + onConfigResolved(context) { + expect(context.components?.btn).toBe(replacement) + expect(context.templates?.doc).toBe(extension.templates?.doc) + expect(context.components?.pageScript).toBeDefined() + styleBuilder.select('.hook-style', 'light').color('blue') + }, + onPageDocument(_context, page) { + expect( + page.document.head.querySelector('link[data-theme="light"]'), + ).not.toBeNull() + }, + }, + }) + const options: BuildOptions = explicit + ? { + defaultPlugins: false, + plugins: [themePlugin(), componentsPlugin(), extension], + } + : { plugins: [extension] } + await buildSite({ siteConfig, options }) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + expect(output).toContain('class="custom-doc"') + expect(output).toContain( + 'Custom button', + ) + const manifest = await readManifest(siteConfig.outDir) + expect(manifest?.styles.outputs).toHaveLength(2) + for (const file of manifest?.styles.outputs ?? []) { + expect(output).toContain(`/docs/assets/${path.basename(file)}`) + } + const light = manifest?.styles.outputs.find( + (file) => !file.endsWith('.dark.css'), + ) + expect(light).toBeDefined() + const css = await fs.readFile(light ?? '', 'utf8') + expect(css).toContain('.factory-style') + expect(css).toContain('.hook-style') + await runInDom(output, (document) => { + const scripts = [...document.head.querySelectorAll('script')].map( + (node) => node.textContent, + ) + expect(scripts.some((text) => text?.includes('code-copy'))).toBe( + !explicit, + ) + }) + }, + ) + + it('lets config hooks override standard components within their build', async () => { + const siteConfig = await project() + await buildSite({ + siteConfig, + options: { + plugins: [ + { + name: 'registry', + hooks: { + onConfigResolved(context) { + context.components = { + ...context.components, + btn: defineComponent(html`Context override`), + } + }, + }, + }, + ], + }, + }) + expect( + await fs.readFile(path.join(siteConfig.outDir, 'index.html'), 'utf8'), + ).toContain('Context override') + }) + + it('keeps critical colors in sync with theme changes from config hooks', async () => { + const siteConfig = await project('# Home') + await buildSite({ + siteConfig, + options: { + plugins: [ + { + name: 'custom-colors', + hooks: { + onConfigResolved() { + const options = themes.getOptions() + themes.setOptions({ + ...options, + palette: { + ...options.palette, + light: { + ...options.palette.light, + accent: '#123456', + semanticTone: { + ...options.palette.light.semanticTone, + neutral: { + ...options.palette.light.semanticTone.neutral, + canvas: '#abcdef', + }, + }, + }, + }, + }) + }, + }, + }, + ], + }, + }) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + expect( + document + .querySelector('meta[name="theme-color"]') + ?.getAttribute('content'), + ).toBe('#123456') + expect(document.head.querySelector('style')?.textContent).toContain( + 'background:#abcdef', + ) + }) + }) + + it.each([true, false])( + 'runs the auth hint before stylesheet loading and other scripts (theme %s)', + async (theme) => { + const siteConfig = await project('# Home') + await buildSite({ + siteConfig: { ...siteConfig, auth: { enabled: true } }, + options: theme + ? undefined + : { + defaultPlugins: false, + plugins: [ + pageScriptsPlugin(), + { + name: 'plain', + templates: { + doc: ({ head, bodyHtml }) => + h('html').push(head, h('body').raw(bodyHtml)), + }, + }, + ], + }, + }) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + const scripts = [...document.head.querySelectorAll('script')] + expect(scripts[0]?.textContent).toContain('signedInStorageKey') + if (theme) { + expect(scripts[1]?.textContent).toContain('themeSwitchThemes') + const nodes = [...document.head.children] + const stylesheet = document.head.querySelector( + 'link[rel="stylesheet"]', + ) + expect(nodes.indexOf(scripts[0])).toBeLessThan( + nodes.indexOf(stylesheet as Element), + ) + } + }) + }, + ) + + it('builds without any default features when they are disabled', async () => { + const siteConfig = await project() + const options = { + defaultPlugins: false, + plugins: [ + definePlugin({ + name: 'plain', + templates: { + doc: ({ head, bodyHtml }) => + h('html').push(head, h('body').raw(bodyHtml)), + }, + }), + ], + } + // Start with a default build: opting out must also work after initialization. + await buildSite({ siteConfig }) + await buildSite({ siteConfig, options }) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + expect(document.querySelector('btn')).not.toBeNull() + expect( + document.querySelectorAll( + 'script, style, link[rel="stylesheet"], meta[name="color-scheme"]', + ), + ).toHaveLength(0) + }) + expect((await readManifest(siteConfig.outDir))?.styles).toEqual({ + signature: '', + outputs: [], + }) + }) + + it('requires a template when the theme plugin is disabled', async () => { + const siteConfig = await project('# Home') + await expect( + buildSite({ siteConfig, options: { defaultPlugins: false } }), + ).rejects.toThrow('Unknown page template "doc"') + }) + + it('accepts an explicitly configured default plugin without initializing it twice', async () => { + const siteConfig = await project() + const theme = themePlugin() + let initialized = 0 + await buildSite({ + siteConfig, + options: { + plugins: [ + { + ...theme, + styles(config) { + initialized++ + return theme.styles(config) + }, + }, + ], + }, + }) + expect(initialized).toBe(1) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + expect(document.querySelectorAll('link[data-theme]')).toHaveLength(2) + expect(document.querySelector('button')).not.toBeNull() + }) + const manifest = await readManifest(siteConfig.outDir) + const css = await fs.readFile(manifest?.styles.outputs[0] ?? '', 'utf8') + expect(css).toContain('.btn') + }) + + it('keeps default assets and runtimes working across lazy requests and content changes', async () => { + const siteConfig = await project( + '---\nlayout:\n showToc: true\n---\n# Home\n\n## Section\n\nAction', + ) + let outputs: string[] = [] + const builder = await createIncrementalBuilder({ + siteConfig, + options: { + plugins: [ + { + name: 'observe-styles', + hooks: { + onStylesWritten(_context, result) { + outputs = result.outputs + }, + }, + }, + ], + }, + }) + await builder.prepareForRequests() + await builder.renderByUrlPath('/') + await builder.preparePageAssets() + const outputPath = path.join(siteConfig.outDir, 'index.html') + const first = await fs.readFile(outputPath, 'utf8') + expect(outputs).toHaveLength(2) + for (const output of outputs) { + expect(first).toContain(`/docs/assets/${path.basename(output)}`) + expect((await fs.readFile(output, 'utf8')).length).toBeGreaterThan(0) + } + await runInDom(first, (document) => { + expect( + document.querySelectorAll('meta[name="color-scheme"]'), + ).toHaveLength(1) + expect(document.querySelectorAll('link[data-theme]')).toHaveLength(2) + expect(document.querySelector('#doc-toc')).not.toBeNull() + }) + const source = path.join(siteConfig.contentDir, 'index.mdx') + await fs.writeFile(source, '# Changed\n\nReady') + await builder.applyChange(source) + await builder.renderByUrlPath('/') + await builder.preparePageAssets() + const second = await fs.readFile(outputPath, 'utf8') + expect(second).toContain('Changed') + expect(second).toContain('Ready') + await runInDom(second, (document) => { + expect(document.querySelectorAll('link[data-theme]')).toHaveLength(2) + expect(document.querySelector('#doc-toc')).toBeNull() + }) + }) + + it.each([false, true])( + 'keeps component and custom CSS with renamed plugins (reversed %s)', + async (reversed) => { + const siteConfig = await project() + const theme = { ...themePlugin(), name: 'site-theme' } + const components = { ...componentsPlugin(), name: 'site-components' } + const calls: string[] = [] + const extension = definePlugin({ + name: 'custom', + components() { + styleBuilder.select('.factory-style', 'light').color('red') + return {} + }, + hooks: { + onConfigResolved() { + calls.push('custom') + }, + }, + }) + const ordered = ( + reversed ? [components, theme] : [theme, components] + ).map((plugin) => ({ + ...plugin, + hooks: { + onConfigResolved() { + calls.push(plugin.name) + }, + }, + })) + await buildSite({ + siteConfig, + options: { defaultPlugins: false, plugins: [extension, ...ordered] }, + }) + expect(calls).toEqual(['custom', ...ordered.map((plugin) => plugin.name)]) + const manifest = await readManifest(siteConfig.outDir) + const css = await fs.readFile(manifest?.styles.outputs[0] ?? '', 'utf8') + expect(css).toContain('.btn ') + expect(css).toContain('.factory-style') + }, + ) + + it('keeps components isolated when builders are created before another site initializes', async () => { + const siteConfig = await project() + const plain = definePlugin({ + name: 'plain', + templates: { + doc: ({ head, bodyHtml }) => + h('html').push(head, h('body').raw(bodyHtml)), + }, + }) + const bare = await createIncrementalBuilder({ + siteConfig, + options: { defaultPlugins: false, plugins: [plain] }, + }) + const standard = await createIncrementalBuilder({ + siteConfig: { + ...siteConfig, + outDir: path.join(siteConfig.rootDir, 'standard'), + }, + }) + await standard.buildAll('initialize standard components') + await bare.buildAll('render without standard components') + let output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + expect(document.querySelector('btn')).not.toBeNull() + expect(document.querySelector('button')).toBeNull() + }) + // Creating another builder must not clear the first builder's components. + await standard.buildAll('render standard components again') + output = await fs.readFile( + path.join(siteConfig.rootDir, 'standard', 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => + expect(document.querySelector('button')).not.toBeNull(), + ) + }) + + it('gives custom templates the completed head and preserves their edits', async () => { + const siteConfig = await project() + await buildSite({ + siteConfig: { ...siteConfig, auth: { enabled: true } }, + options: { + plugins: [ + { + name: 'nonce', + hooks: { + onConfigResolved(context) { + // Template changes from config hooks also receive the completed head. + context.templates = { + ...context.templates, + doc: ({ head, bodyHtml }) => { + const markup = head.toHtml() + expect(markup).toContain('data-theme="light"') + expect(markup).toContain('signedInStorageKey') + expect(markup).toContain('code-copy') + head.select('style', (node) => + node.attr({ nonce: 'site-nonce' }), + ) + head.select('script', (node) => + node.attr({ nonce: 'site-nonce' }), + ) + return h('html').push(head, h('body').raw(bodyHtml)) + }, + } + }, + }, + }, + ], + }, + }) + const output = await fs.readFile( + path.join(siteConfig.outDir, 'index.html'), + 'utf8', + ) + await runInDom(output, (document) => { + expect(document.head.querySelectorAll('style')).toHaveLength(1) + expect(document.head.querySelector('style')?.getAttribute('nonce')).toBe( + 'site-nonce', + ) + expect(document.head.querySelector('script')?.getAttribute('nonce')).toBe( + 'site-nonce', + ) + }) + }) + + it('rejects multiple stylesheet providers before either initializes', async () => { + const siteConfig = await project() + let calls = 0 + const styleProvider = themePlugin().styles + const styles = (config: Parameters[0]) => { + calls++ + return styleProvider(config) + } + await expect( + buildSite({ + siteConfig, + options: { + defaultPlugins: false, + plugins: [ + { name: 'first', styles }, + { name: 'second', styles }, + ], + }, + }), + ).rejects.toThrow('Plugins "first" and "second" both define styles.') + expect(calls).toBe(0) + }) +}) diff --git a/packages/ts-ssg/src/plugins/default-plugins.ts b/packages/ts-ssg/src/plugins/default-plugins.ts new file mode 100644 index 00000000..388c0062 --- /dev/null +++ b/packages/ts-ssg/src/plugins/default-plugins.ts @@ -0,0 +1,50 @@ +import { componentsPlugin } from '@purestack/ts-components' +import { pageScriptsPlugin } from '@purestack/ts-page-scripts' +import { themePlugin } from '@purestack/ts-theme' +import type { BuildContext } from '../build/page' +import type { BuildOptions } from '../build/site' +import { + assertValidPlugins, + composePluginHooks, + type PureStackPlugin, + resolvePluginComponents, + resolvePluginStyles, + resolvePluginTemplates, +} from './plugin' + +export { componentsPlugin, pageScriptsPlugin, themePlugin } + +/** A fresh set for each build/session; the libraries have no registration side effects. */ +export function createDefaultPlugins(): PureStackPlugin[] { + // CSS initialization precedes component styles; theme startup precedes the + // browser runtimes. Both use the ordinary plugin lifecycle below. + return [themePlugin(), componentsPlugin(), pageScriptsPlugin()] +} + +export function resolveBuildPlugins(options: BuildOptions = {}) { + const supplied = options.plugins ?? [] + assertValidPlugins(supplied) + const replacements = new Map(supplied.map((plugin) => [plugin.name, plugin])) + const standard = createDefaultPlugins() + const names = new Set(standard.map((plugin) => plugin.name)) + const all = + options.defaultPlugins === false + ? supplied + : [ + ...standard.map((plugin) => replacements.get(plugin.name) ?? plugin), + ...supplied.filter((plugin) => !names.has(plugin.name)), + ] + return all +} + +/** Every plugin follows the same initialization sequence. */ +export async function initializePluginContext( + context: BuildContext, + plugins: readonly PureStackPlugin[], +) { + context.styles = resolvePluginStyles(plugins, context.config) + context.components = resolvePluginComponents(plugins, context.config) + context.templates = resolvePluginTemplates(plugins) + context.headTemplates = plugins.flatMap((plugin) => plugin.head ?? []) + await composePluginHooks(plugins).onConfigResolved?.(context) +} diff --git a/packages/ts-ssg/src/plugins/plugin.test.ts b/packages/ts-ssg/src/plugins/plugin.test.ts index f1f2ce13..0f2a7866 100644 --- a/packages/ts-ssg/src/plugins/plugin.test.ts +++ b/packages/ts-ssg/src/plugins/plugin.test.ts @@ -582,7 +582,7 @@ describe('plugins', () => { [null, 'Plugin 1 must be an object.'], [ { name: 'typo', hook: {} }, - 'Plugin "typo" has an unknown field "hook". Plugin fields: name, skins, components, templates, markdown, pages, devMiddleware, hooks.', + 'Plugin "typo" has an unknown field "hook". Plugin fields: name, defaults, skins, components, templates, head, styles, markdown, pages, devMiddleware, hooks.', ], [ { name: 'typo', hooks: { onPageRender: () => {} } }, @@ -600,6 +600,18 @@ describe('plugins', () => { { name: 'bad', components: { card: {} } }, 'Plugin "bad" needs `components` to be a function that returns components.', ], + [ + { name: 'bad', defaults: 'yes' }, + 'Plugin "bad" needs `defaults` to be a boolean.', + ], + [ + { name: 'bad', head: {} }, + 'Plugin "bad" needs `head` to be a head template.', + ], + [ + { name: 'bad', styles: {} }, + 'Plugin "bad" needs `styles` to be a stylesheet provider.', + ], [ { name: 'bad', templates: { landing: '' } }, 'Plugin "bad" needs the template "landing" to be a function.', diff --git a/packages/ts-ssg/src/plugins/plugin.ts b/packages/ts-ssg/src/plugins/plugin.ts index 88120b46..96556280 100644 --- a/packages/ts-ssg/src/plugins/plugin.ts +++ b/packages/ts-ssg/src/plugins/plugin.ts @@ -1,5 +1,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' -import type { PageTemplateMap, SiteConfig } from '@purestack/ts-common' +import type { + PageHeadTemplate, + PageTemplateMap, + SiteConfig, + SiteStyles, +} from '@purestack/ts-common' import { registerSkin, type ThemeSkin, themeSkins } from '@purestack/ts-style' import { escapeHtml, isPlainObject } from '@purestack/ts-util' import type { HLJSApi } from 'highlight.js' @@ -17,12 +22,18 @@ import type { MdxCodeHighlighter, MdxShikiFactory } from '../mdx/highlight' export interface PureStackPlugin { /** Names the plugin in conflict and hook error messages. */ name: string + /** Components and templates supply defaults that ordinary contributions may override. */ + defaults?: boolean /** Theme skins the site config can select with `style.theme.skin`. */ skins?: Record /** Regor components, built from the resolved site config. */ components?: (config: SiteConfig) => Record /** Page templates, selected by the `template` frontmatter field. */ templates?: PageTemplateMap + /** Head template, composed before the selected page template. */ + head?: PageHeadTemplate + /** The site's stylesheet provider, initialized before component factories. */ + styles?: (config: SiteConfig) => SiteStyles /** Markdown transforms and highlighter resolution for pages and shared partials. */ markdown?: PureStackMarkdown /** @@ -90,9 +101,12 @@ export function definePlugin(plugin: PureStackPlugin): PureStackPlugin { const PLUGIN_FIELDS = [ 'name', + 'defaults', 'skins', 'components', 'templates', + 'head', + 'styles', 'markdown', 'pages', 'devMiddleware', @@ -167,6 +181,12 @@ function assertPluginFields(name: string, plugin: Record) { devMiddleware, hooks, } = plugin + if (plugin.defaults !== undefined && typeof plugin.defaults !== 'boolean') + fail('needs `defaults` to be a boolean.') + if (plugin.head !== undefined && typeof plugin.head !== 'function') + fail('needs `head` to be a head template.') + if (plugin.styles !== undefined && typeof plugin.styles !== 'function') + fail('needs `styles` to be a stylesheet provider.') if (pages !== undefined && typeof pages !== 'function') { fail('needs `pages` to be a function that returns pages.') } @@ -265,21 +285,52 @@ export function resolvePluginComponents( plugins: readonly PureStackPlugin[], config: SiteConfig, ) { - return mergeByName(plugins, 'component', (plugin) => { - const components = plugin.components?.(config) - if (components !== undefined && !isPlainObject(components)) { - throw new Error( - `Plugin "${plugin.name}" needs \`components\` to return an object of components.`, - ) - } - return components - }) + return mergeByName( + plugins, + 'component', + (plugin) => { + const components = plugin.components?.(config) + if (components !== undefined && !isPlainObject(components)) { + throw new Error( + `Plugin "${plugin.name}" needs \`components\` to return an object of components.`, + ) + } + return components + }, + true, + ) } export function resolvePluginTemplates( plugins: readonly PureStackPlugin[], ): PageTemplateMap { - return mergeByName(plugins, 'template', (plugin) => plugin.templates) + return mergeByName(plugins, 'template', (plugin) => plugin.templates, true) +} + +export function resolvePluginStyles( + plugins: readonly PureStackPlugin[], + config: SiteConfig, +) { + const providers = plugins.filter((plugin) => plugin.styles) + if (providers.length > 1) { + throw new Error( + `Plugins "${providers[0].name}" and "${providers[1].name}" both define styles.`, + ) + } + const provider = providers[0] + const styles = provider?.styles?.(config) + if ( + provider && + (!styles || + typeof styles.revision !== 'number' || + typeof styles.links !== 'function' || + typeof styles.write !== 'function') + ) { + throw new Error( + `Plugin "${provider.name}" needs \`styles\` to return a stylesheet provider.`, + ) + } + return styles } /** The build's content processor, with every plugin's remark and rehype plugins. */ @@ -396,19 +447,29 @@ function mergeByName( plugins: readonly PureStackPlugin[], kind: string, pick: (plugin: PureStackPlugin) => Record | undefined, + allowDefaults = false, ) { + // Evaluate factories in plugin order, even when their contributions have + // different precedence. Metadata never changes initialization order. + const contributions = plugins.map((plugin) => ({ + plugin, + values: pick(plugin), + })) const merged: Record = {} - const owners = new Map() - for (const plugin of plugins) { - for (const [name, value] of Object.entries(pick(plugin) ?? {})) { - const owner = owners.get(name) - if (owner) { - throw new Error( - `Plugins "${owner}" and "${plugin.name}" both define the ${kind} "${name}".`, - ) + for (const defaults of allowDefaults ? [true, false] : [false]) { + const owners = new Map() + for (const { plugin, values } of contributions) { + if (allowDefaults && (plugin.defaults === true) !== defaults) continue + for (const [name, value] of Object.entries(values ?? {})) { + const owner = owners.get(name) + if (owner) { + throw new Error( + `Plugins "${owner}" and "${plugin.name}" both define the ${kind} "${name}".`, + ) + } + owners.set(name, plugin.name) + merged[name] = value } - owners.set(name, plugin.name) - merged[name] = value } } return merged diff --git a/packages/ts-ssg/src/regor/initBuiltinComponents.ts b/packages/ts-ssg/src/regor/initBuiltinComponents.ts deleted file mode 100644 index 3485ca4b..00000000 --- a/packages/ts-ssg/src/regor/initBuiltinComponents.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { defineComponents, registerStyles } from '@purestack/ts-components' -import { ensureDomGlobals, useDomScope } from '@purestack/ts-minidom' -import { componentRegistry } from '@purestack/ts-render' -import { - registerNormalizeStyles, - registerSemanticToneUtilityStyles, - styleBuilder, -} from '@purestack/ts-style' -import { getSvgIcon } from '@purestack/ts-svg-icons' -import { defineScriptComponents } from '../../../ts-components/src/standard/pageScript/pageScript' -import { registerDocLayoutStyles } from '../templates/docLayoutStyles' -import { registerMarkdownStyles } from '../templates/markdownStyles' - -// Each page renders in a DOM of its own, which async hooks keep while they -// await, so renders in `serve` can overlap. -const pageDomScope = new AsyncLocalStorage() - -export interface BuiltinComponentInitOptions { - includeShikiStyles?: boolean -} - -export function initBuiltinComponents( - options: BuiltinComponentInitOptions = {}, -) { - ensureDomGlobals() - useDomScope(pageDomScope) - styleBuilder.reset() - componentRegistry.clear() - registerNormalizeStyles() - registerDocLayoutStyles() - registerMarkdownStyles({ includeShikiStyles: options.includeShikiStyles }) - registerStyles() - componentRegistry.registerMany( - defineComponents(getSvgIcon), - defineScriptComponents(), - ) - registerSemanticToneUtilityStyles() -} diff --git a/packages/ts-ssg/src/regor/initRenderer.ts b/packages/ts-ssg/src/regor/initRenderer.ts new file mode 100644 index 00000000..577c890a --- /dev/null +++ b/packages/ts-ssg/src/regor/initRenderer.ts @@ -0,0 +1,11 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { ensureDomGlobals, useDomScope } from '@purestack/ts-minidom' + +// Each page renders in a DOM of its own, which async hooks keep while they +// await, so renders in `serve` can overlap. +const pageDomScope = new AsyncLocalStorage() + +export function initRenderer() { + ensureDomGlobals() + useDomScope(pageDomScope) +} diff --git a/packages/ts-ssg/src/templates/page-templates.ts b/packages/ts-ssg/src/templates/page-templates.ts index 4dd892a9..4b06e01f 100644 --- a/packages/ts-ssg/src/templates/page-templates.ts +++ b/packages/ts-ssg/src/templates/page-templates.ts @@ -1,28 +1,13 @@ -import type { - PageFrontmatter, - PageNavigation, - PageOutlineItem, - PageTemplateInput, - PageTemplateMap, -} from '@purestack/ts-common' -import { h } from '@purestack/ts-html' -import { docLayoutVars } from '@purestack/ts-style' -import { isTocEnabled } from '../toc/isTocEnabled' - -export const defaultTemplates: PageTemplateMap = { - doc: renderDocTemplate, - splash: renderSplashTemplate, -} +import type { PageTemplateMap } from '@purestack/ts-common' export function resolvePageTemplate( name: string | undefined, - templates?: PageTemplateMap, + templates: PageTemplateMap = {}, ) { - const registry = { ...defaultTemplates, ...(templates ?? {}) } const templateName = normalizeTemplateName(name) - const pageTemplate = registry[templateName] + const pageTemplate = templates[templateName] if (!pageTemplate) { - const known = Object.keys(registry).sort().join(', ') + const known = Object.keys(templates).sort().join(', ') throw new Error( `Unknown page template "${templateName}". Known templates: ${known || 'none'}`, ) @@ -35,206 +20,3 @@ function normalizeTemplateName(name: string | undefined) { const trimmed = name.trim() return trimmed.length > 0 ? trimmed : 'doc' } - -function renderDocTemplate({ - head, - bodyHtml, - site, - navigation, - outline, - pageInfo, - headerHtml, - footerHtml, -}: PageTemplateInput) { - const frontmatter = pageInfo.frontmatter - const layout = resolveDocLayout(frontmatter, navigation, outline) - const showFooter = frontmatter.layout.showFooter - return h('html') - .attr(resolveHtmlAttrs(pageInfo.locale)) - .push( - head, - h('body') - .attr({ - class: layout.bodyClass, - style: `${docLayoutVars.sidebarTop}: ${site.docLayout.sidebarTop}; ${docLayoutVars.sidebarTopMobile}: ${site.docLayout.sidebarTopMobile};`, - }) - .push( - buildTopBar(headerHtml), - h('div') - .attr({ class: layout.shellClass }) - .push( - ...(layout.showNav - ? [ - h('aside') - .attr({ class: 'doc-sidebar', id: 'doc-sidebar' }) - .push( - h('nav-menu').attr({ - tone: navigation?.tone, - ...(site.navigation.variant - ? { variant: site.navigation.variant } - : {}), - ...(site.navigation.class - ? { class: site.navigation.class } - : {}), - }), - ), - ] - : []), - h('main') - .attr({ class: 'doc-main' }) - .push( - h('article') - .attr({ class: 'doc-content' }) - .raw(bodyHtml) - .push(...(navigation?.pageLinks ? [h('PageLinks')] : [])), - ), - ...(layout.showToc - ? [ - h('aside') - .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 } - : {}), - }), - ), - ] - : []), - ), - h('consent'), - buildFooter(showFooter, footerHtml), - ), - ) -} - -type DocLayout = { - bodyClass: string - shellClass: string - showNav: boolean - showToc: boolean - tocCollapsed: boolean -} - -function resolveDocLayout( - frontmatter: PageFrontmatter, - navigation: PageNavigation | undefined, - outline: PageOutlineItem[] | undefined, -): DocLayout { - const navMode = frontmatter.layout.navMode - const layoutClass = resolveDocLayoutClass(frontmatter) - const showNav = frontmatter.layout.showNav && hasNavItems(navigation) - const showToc = isTocEnabled(frontmatter, outline) - const tocCollapsed = frontmatter.layout.tocCollapsed === true - const bodyClass = buildDocBodyClass( - navMode, - layoutClass, - showNav, - showToc, - tocCollapsed, - ) - const shellClass = buildDocShellClass(showNav, showToc, navMode) - return { bodyClass, shellClass, showNav, showToc, tocCollapsed } -} - -function hasNavItems(navigation: PageNavigation | undefined) { - const itemCount = navigation?.items?.length ?? 0 - const globalCount = navigation?.global?.length ?? 0 - return itemCount + globalCount > 0 -} - -function buildDocBodyClass( - navMode: PageFrontmatter['layout']['navMode'], - layoutClass: string, - showNav: boolean, - showToc: boolean, - tocCollapsed: boolean, -) { - const navClass = - showNav && navMode === 'drawer' ? 'template-doc--nav-drawer' : '' - const hasNavClass = showNav ? 'template-doc--has-nav' : '' - const hasTocClass = showToc ? 'template-doc--has-toc' : '' - const tocCollapsedClass = - showToc && tocCollapsed ? 'template-doc--toc-collapsed' : '' - return [ - 'template-doc', - navClass, - hasNavClass, - hasTocClass, - tocCollapsedClass, - layoutClass, - ] - .filter(Boolean) - .join(' ') -} - -function buildDocShellClass( - showNav: boolean, - showToc: boolean, - navMode: PageFrontmatter['layout']['navMode'], -) { - const classes = ['doc-shell'] - if (!showNav && !showToc) classes.push('doc-shell--single') - if (navMode === 'drawer') classes.push('doc-shell--nav-drawer') - if (showToc) { - classes.push(showNav ? 'doc-shell--toc' : 'doc-shell--toc-only') - } - return classes.join(' ') -} - -function resolveDocLayoutClass(frontmatter: PageFrontmatter) { - return frontmatter.layout.fullWidth ? 'template-doc--full-main' : '' -} - -function renderSplashTemplate({ - head, - bodyHtml, - pageInfo, - headerHtml, - footerHtml, -}: PageTemplateInput) { - const showFooter = pageInfo.frontmatter.layout.showFooter - return h('html') - .attr(resolveHtmlAttrs(pageInfo.locale)) - .push( - head, - h('body') - .attr({ class: 'template-splash' }) - .push( - buildTopBar(headerHtml), - h('main').push(h('section').attr({ class: 'splash' }).raw(bodyHtml)), - h('consent'), - buildFooter(showFooter, footerHtml), - ), - ) -} - -function resolveHtmlAttrs(locale: string | undefined) { - return locale ? { lang: locale } : {} -} - -function buildTopBar(headerHtml?: string) { - if (hasHeaderHtml(headerHtml)) { - return h('').raw(headerHtml) - } - return h('') -} - -function buildFooter(showFooter: boolean, footerHtml?: string) { - if (showFooter && hasFooterHtml(footerHtml)) { - return h('').raw(footerHtml) - } - return h('') -} - -function hasHeaderHtml(headerHtml?: string): headerHtml is string { - return typeof headerHtml === 'string' && headerHtml.trim().length > 0 -} - -function hasFooterHtml(footerHtml?: string): footerHtml is string { - return typeof footerHtml === 'string' && footerHtml.trim().length > 0 -} diff --git a/packages/ts-ssg/src/test/defaultPlugins.ts b/packages/ts-ssg/src/test/defaultPlugins.ts new file mode 100644 index 00000000..6b460e7c --- /dev/null +++ b/packages/ts-ssg/src/test/defaultPlugins.ts @@ -0,0 +1,51 @@ +import { ContentRouteIndex } from '../build/content-urls' +import { type BuildContext, renderPageFromFile } from '../build/page' +import { type RenderPageInput, renderPage } from '../build/renderer' +import type { BuildHooks } from '../build/site' +import type { ResolvedContentFile } from '../i18n/content' +import { + initializePluginContext, + resolveBuildPlugins, +} from '../plugins/default-plugins' +import { composePluginHooks } from '../plugins/plugin' +import { initRenderer } from '../regor/initRenderer' + +async function initialize(context: BuildContext) { + initRenderer() + const components = context.components + const templates = context.templates + const plugins = resolveBuildPlugins() + await initializePluginContext(context, plugins) + context.components = { ...context.components, ...components } + context.templates = { ...context.templates, ...templates } + return plugins +} + +/** Exercises the same default plugins as buildSite for low-level page tests. */ +export async function renderDefaultPage( + context: BuildContext, + file: ResolvedContentFile, + hooks: Pick = {}, +) { + const plugins = await initialize(context) + return renderPageFromFile( + context, + file, + composePluginHooks([...plugins, { name: 'test', hooks }]), + ) +} + +export async function renderDefaultShell(input: RenderPageInput) { + const context: BuildContext = { + config: input.site, + contentRoutes: new ContentRouteIndex([]), + templates: input.templates, + } + await initialize(context) + return renderPage({ + ...input, + templates: context.templates, + headTemplates: context.headTemplates, + styleLinks: context.styles?.links(), + }) +} diff --git a/packages/ts-theme/LICENSE b/packages/ts-theme/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-theme/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-theme/README.md b/packages/ts-theme/README.md new file mode 100644 index 00000000..f1b3bd35 --- /dev/null +++ b/packages/ts-theme/README.md @@ -0,0 +1,13 @@ +# `@purestack/ts-theme` + +PureStack's `doc` and `splash` templates, layout and Markdown styles, theme CSS output, stylesheet links, critical colors, and theme switching. + +```ts +import { themePlugin } from '@purestack/ts-theme' + +const plugin = themePlugin() +``` + +Enabled automatically by `@purestack/ts-ssg`. Supplies default page templates, a head template, and a stylesheet provider. The provider initializes before component factories, regardless of plugin name or position. The head template runs before the selected page template so custom layouts can modify its markup. It honors `style`, `docLayout`, and highlighting settings; the build engine invokes its writer for full builds and lazy development assets. `defaultTemplates` is also exported for custom layouts. + +To choose plugins explicitly, set `defaultPlugins: false` in `purestack.config.ts` or build options. Without this plugin, provide page templates and any desired CSS through your own plugin. diff --git a/packages/ts-theme/package.json b/packages/ts-theme/package.json new file mode 100644 index 00000000..b6fff2ff --- /dev/null +++ b/packages/ts-theme/package.json @@ -0,0 +1,50 @@ +{ + "name": "@purestack/ts-theme", + "version": "1.1.13", + "packageManager": "yarn@4.9.2", + "module": "./dist/ts-theme.mjs", + "types": "./dist/ts-theme.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-theme" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-theme.d.mts", + "import": "./dist/ts-theme.mjs" + } + }, + "license": "MIT", + "files": [ + "dist/*.mjs", + "dist/*.mjs.map", + "dist/*.d.mts", + "dist/*.d.mts.map", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc", + "lint": "biome check ./src --formatter-enabled=false --write", + "test": "vitest" + }, + "dependencies": { + "@purestack/ts-common": "workspace:^", + "@purestack/ts-html": "workspace:^", + "@purestack/ts-page-scripts": "workspace:^", + "@purestack/ts-style": "workspace:^", + "@purestack/ts-util-node": "workspace:^", + "highlight.js": "^11.11.1", + "logpot": "^1.0.7" + } +} diff --git a/packages/ts-ssg/src/templates/docLayoutStyles.ts b/packages/ts-theme/src/docLayoutStyles.ts similarity index 100% rename from packages/ts-ssg/src/templates/docLayoutStyles.ts rename to packages/ts-theme/src/docLayoutStyles.ts diff --git a/packages/ts-theme/src/index.ts b/packages/ts-theme/src/index.ts new file mode 100644 index 00000000..f9ca4119 --- /dev/null +++ b/packages/ts-theme/src/index.ts @@ -0,0 +1,104 @@ +import type { + PageTemplateInput, + SiteConfig, + SiteStyles, +} from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { buildThemeSwitchScript } from '@purestack/ts-page-scripts' +import { + registerNormalizeStyles, + resolveThemeStyleLinks, + styleBuilder, + type ThemeOptions, + themes, +} from '@purestack/ts-style' +import { registerDocLayoutStyles } from './docLayoutStyles' +import { registerMarkdownStyles } from './markdownStyles' +import { defaultTemplates } from './templates' +import { writeStyles } from './writeStyles' + +export { defaultTemplates } from './templates' + +/** The default layouts, theme CSS, and critical theme startup. */ +export function themePlugin() { + return { + name: 'purestack:theme', + defaults: true, + templates: defaultTemplates, + styles: createSiteStyles, + head: renderThemeHead, + } +} + +function createSiteStyles(config: SiteConfig): SiteStyles { + themes.setOptions(config.style.theme) + styleBuilder.reset() + registerNormalizeStyles() + registerDocLayoutStyles() + registerMarkdownStyles({ + includeShikiStyles: + !config.mdx.disableHighlighter && config.mdx.highlighter === 'shiki', + }) + return { + get revision() { + return styleBuilder.revision + }, + links: (cacheKey) => + resolveThemeStyleLinks(config.style.href, config.style.themes, cacheKey), + write: (input) => + writeStyles( + { + ...input, + includeHljsTheme: + !config.mdx.disableHighlighter && + config.mdx.highlighter === 'highlightjs', + }, + config.style, + ), + } +} + +function renderThemeHead({ head, styleLinks = [] }: PageTemplateInput) { + const options = themes.getOptions() + head.push(h('meta').attr({ name: 'color-scheme', content: 'dark light' })) + for (const mode of ['light', 'dark'] as const) { + head.push( + h('meta').attr({ + name: 'theme-color', + media: `(prefers-color-scheme: ${mode})`, + content: options.palette[mode].accent, + }), + ) + } + const names = styleLinks.flatMap((link) => + link.dataTheme ? [link.dataTheme] : [], + ) + head.push(h('style').raw(buildCriticalThemeStyle(options, names.length > 0))) + for (const link of styleLinks) { + head.push( + h('link').attr({ + rel: link.rel, + href: link.href, + ...(link.media ? { media: link.media } : {}), + ...(link.dataTheme ? { 'data-theme': link.dataTheme } : {}), + }), + ) + } + if (names.length > 0) + head.push(h('script').raw(buildThemeSwitchScript(names))) + return head +} + +function buildCriticalThemeStyle(options: ThemeOptions, hasThemeGate: boolean) { + const { light, dark } = options.palette + const css = [ + ':root{color-scheme:light dark;}', + `html,body{background:${light.semanticTone.neutral.canvas};color:${light.semanticTone.neutral.text.default};}`, + '@media (prefers-color-scheme: dark){', + `html,body{background:${dark.semanticTone.neutral.canvas};color:${dark.semanticTone.neutral.text.default};}`, + '}', + ] + if (hasThemeGate) + css.push('html:not([data-theme-ready]) body{visibility:hidden;}') + return css.join('') +} diff --git a/packages/ts-ssg/src/templates/markdownStyles.ts b/packages/ts-theme/src/markdownStyles.ts similarity index 100% rename from packages/ts-ssg/src/templates/markdownStyles.ts rename to packages/ts-theme/src/markdownStyles.ts diff --git a/packages/ts-theme/src/templates.ts b/packages/ts-theme/src/templates.ts new file mode 100644 index 00000000..76b9c305 --- /dev/null +++ b/packages/ts-theme/src/templates.ts @@ -0,0 +1,218 @@ +import type { + PageFrontmatter, + PageNavigation, + PageOutlineItem, + PageTemplateInput, + PageTemplateMap, +} from '@purestack/ts-common' +import { isTocEnabled } from '@purestack/ts-common' +import { h } from '@purestack/ts-html' +import { docLayoutVars } from '@purestack/ts-style' + +export const defaultTemplates: PageTemplateMap = { + doc: renderDocTemplate, + splash: renderSplashTemplate, +} + +function renderDocTemplate({ + head, + bodyHtml, + site, + navigation, + outline, + pageInfo, + headerHtml, + footerHtml, +}: PageTemplateInput) { + const frontmatter = pageInfo.frontmatter + const layout = resolveDocLayout(frontmatter, navigation, outline) + const showFooter = frontmatter.layout.showFooter + return h('html') + .attr(resolveHtmlAttrs(pageInfo.locale)) + .push( + head, + h('body') + .attr({ + class: layout.bodyClass, + style: `${docLayoutVars.sidebarTop}: ${site.docLayout.sidebarTop}; ${docLayoutVars.sidebarTopMobile}: ${site.docLayout.sidebarTopMobile};`, + }) + .push( + buildTopBar(headerHtml), + h('div') + .attr({ class: layout.shellClass }) + .push( + ...(layout.showNav + ? [ + h('aside') + .attr({ class: 'doc-sidebar', id: 'doc-sidebar' }) + .push( + h('nav-menu').attr({ + tone: navigation?.tone, + ...(site.navigation.variant + ? { variant: site.navigation.variant } + : {}), + ...(site.navigation.class + ? { class: site.navigation.class } + : {}), + }), + ), + ] + : []), + h('main') + .attr({ class: 'doc-main' }) + .push( + h('article') + .attr({ class: 'doc-content' }) + .raw(bodyHtml) + .push(...(navigation?.pageLinks ? [h('PageLinks')] : [])), + ), + ...(layout.showToc + ? [ + h('aside') + .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 } + : {}), + }), + ), + ] + : []), + ), + h('consent'), + buildFooter(showFooter, footerHtml), + ), + ) +} + +type DocLayout = { + bodyClass: string + shellClass: string + showNav: boolean + showToc: boolean + tocCollapsed: boolean +} + +function resolveDocLayout( + frontmatter: PageFrontmatter, + navigation: PageNavigation | undefined, + outline: PageOutlineItem[] | undefined, +): DocLayout { + const navMode = frontmatter.layout.navMode + const layoutClass = resolveDocLayoutClass(frontmatter) + const showNav = frontmatter.layout.showNav && hasNavItems(navigation) + const showToc = isTocEnabled(frontmatter, outline) + const tocCollapsed = frontmatter.layout.tocCollapsed === true + const bodyClass = buildDocBodyClass( + navMode, + layoutClass, + showNav, + showToc, + tocCollapsed, + ) + const shellClass = buildDocShellClass(showNav, showToc, navMode) + return { bodyClass, shellClass, showNav, showToc, tocCollapsed } +} + +function hasNavItems(navigation: PageNavigation | undefined) { + const itemCount = navigation?.items?.length ?? 0 + const globalCount = navigation?.global?.length ?? 0 + return itemCount + globalCount > 0 +} + +function buildDocBodyClass( + navMode: PageFrontmatter['layout']['navMode'], + layoutClass: string, + showNav: boolean, + showToc: boolean, + tocCollapsed: boolean, +) { + const navClass = + showNav && navMode === 'drawer' ? 'template-doc--nav-drawer' : '' + const hasNavClass = showNav ? 'template-doc--has-nav' : '' + const hasTocClass = showToc ? 'template-doc--has-toc' : '' + const tocCollapsedClass = + showToc && tocCollapsed ? 'template-doc--toc-collapsed' : '' + return [ + 'template-doc', + navClass, + hasNavClass, + hasTocClass, + tocCollapsedClass, + layoutClass, + ] + .filter(Boolean) + .join(' ') +} + +function buildDocShellClass( + showNav: boolean, + showToc: boolean, + navMode: PageFrontmatter['layout']['navMode'], +) { + const classes = ['doc-shell'] + if (!showNav && !showToc) classes.push('doc-shell--single') + if (navMode === 'drawer') classes.push('doc-shell--nav-drawer') + if (showToc) { + classes.push(showNav ? 'doc-shell--toc' : 'doc-shell--toc-only') + } + return classes.join(' ') +} + +function resolveDocLayoutClass(frontmatter: PageFrontmatter) { + return frontmatter.layout.fullWidth ? 'template-doc--full-main' : '' +} + +function renderSplashTemplate({ + head, + bodyHtml, + pageInfo, + headerHtml, + footerHtml, +}: PageTemplateInput) { + const showFooter = pageInfo.frontmatter.layout.showFooter + return h('html') + .attr(resolveHtmlAttrs(pageInfo.locale)) + .push( + head, + h('body') + .attr({ class: 'template-splash' }) + .push( + buildTopBar(headerHtml), + h('main').push(h('section').attr({ class: 'splash' }).raw(bodyHtml)), + h('consent'), + buildFooter(showFooter, footerHtml), + ), + ) +} + +function resolveHtmlAttrs(locale: string | undefined) { + return locale ? { lang: locale } : {} +} + +function buildTopBar(headerHtml?: string) { + if (hasHeaderHtml(headerHtml)) { + return h('').raw(headerHtml) + } + return h('') +} + +function buildFooter(showFooter: boolean, footerHtml?: string) { + if (showFooter && hasFooterHtml(footerHtml)) { + return h('').raw(footerHtml) + } + return h('') +} + +function hasHeaderHtml(headerHtml?: string): headerHtml is string { + return typeof headerHtml === 'string' && headerHtml.trim().length > 0 +} + +function hasFooterHtml(footerHtml?: string): footerHtml is string { + return typeof footerHtml === 'string' && footerHtml.trim().length > 0 +} diff --git a/packages/ts-ssg/src/build/styles.ts b/packages/ts-theme/src/writeStyles.ts similarity index 94% rename from packages/ts-ssg/src/build/styles.ts rename to packages/ts-theme/src/writeStyles.ts index 29c97a25..23af309d 100644 --- a/packages/ts-ssg/src/build/styles.ts +++ b/packages/ts-theme/src/writeStyles.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import fs from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' -import type { SiteStyleConfig } from '@purestack/ts-common' +import type { SiteStyleConfig, WriteStylesResult } from '@purestack/ts-common' import { orderThemes, resolveThemeFileName, @@ -11,13 +11,6 @@ import { import { ensureDir } from '@purestack/ts-util-node' import { getLogger } from 'logpot' -export interface WriteStylesResult { - revision: number - outPath: string - outputs: string[] - signature: string -} - export interface WriteStylesInput { cacheKey?: string outDir: string diff --git a/packages/ts-theme/tsconfig.json b/packages/ts-theme/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-theme/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/yarn.lock b/yarn.lock index 17670ed1..9dad7f8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -762,6 +762,7 @@ __metadata: "@purestack/ts-page-scripts": "workspace:^" "@purestack/ts-render": "workspace:^" "@purestack/ts-style": "workspace:^" + "@purestack/ts-svg-icons": "workspace:^" "@purestack/ts-util": "workspace:^" regor: "npm:^1.7.5" languageName: unknown @@ -796,6 +797,7 @@ __metadata: resolution: "@purestack/ts-page-scripts@workspace:packages/ts-page-scripts" dependencies: "@purestack/ts-common": "workspace:^" + "@purestack/ts-html": "workspace:^" languageName: unknown linkType: soft @@ -824,6 +826,7 @@ __metadata: "@purestack/ts-render": "workspace:^" "@purestack/ts-style": "workspace:^" "@purestack/ts-svg-icons": "workspace:^" + "@purestack/ts-theme": "workspace:^" "@purestack/ts-util": "workspace:^" "@purestack/ts-util-node": "workspace:^" esbuild: "npm:^0.28.0" @@ -867,6 +870,20 @@ __metadata: languageName: unknown linkType: soft +"@purestack/ts-theme@workspace:^, @purestack/ts-theme@workspace:packages/ts-theme": + version: 0.0.0-use.local + resolution: "@purestack/ts-theme@workspace:packages/ts-theme" + dependencies: + "@purestack/ts-common": "workspace:^" + "@purestack/ts-html": "workspace:^" + "@purestack/ts-page-scripts": "workspace:^" + "@purestack/ts-style": "workspace:^" + "@purestack/ts-util-node": "workspace:^" + highlight.js: "npm:^11.11.1" + logpot: "npm:^1.0.7" + languageName: unknown + linkType: soft + "@purestack/ts-util-node@workspace:^, @purestack/ts-util-node@workspace:packages/ts-util-node": version: 0.0.0-use.local resolution: "@purestack/ts-util-node@workspace:packages/ts-util-node"