diff --git a/frontend/components/studioFeature.ts b/frontend/components/studioFeature.ts index a3b1ffd8..5f04b1eb 100644 --- a/frontend/components/studioFeature.ts +++ b/frontend/components/studioFeature.ts @@ -32,6 +32,8 @@ const studioFeatureTemplate = html` } -const studioPackageTemplate = html` +const studioPackageTemplate = html` {{ number }} {{ packageName }} diff --git a/frontend/docs/componentGuide.ts b/frontend/docs/componentGuide.ts index 6639d220..09316054 100644 --- a/frontend/docs/componentGuide.ts +++ b/frontend/docs/componentGuide.ts @@ -28,7 +28,7 @@ export function defineComponentGuideComponents() {
{{ name }}{{ kind }}

{{ summary }}

diff --git a/frontend/purestack.studio/components/actions/buttons/buttons.mdx b/frontend/purestack.studio/components/actions/buttons/buttons.mdx index 3ebf0fdd..d77967a7 100644 --- a/frontend/purestack.studio/components/actions/buttons/buttons.mdx +++ b/frontend/purestack.studio/components/actions/buttons/buttons.mdx @@ -321,7 +321,7 @@ The form handles `@reset.prevent` so its reactive value stays synchronized with -[Open the playground](#playground) · [Read the component definition](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-components/src/standard/btn/btn.ts) +[Open the playground](#playground) · Read the component definition ### Appearance diff --git a/frontend/purestack.studio/components/footer.mdx b/frontend/purestack.studio/components/footer.mdx index a93abc12..53a52660 100644 --- a/frontend/purestack.studio/components/footer.mdx +++ b/frontend/purestack.studio/components/footer.mdx @@ -4,5 +4,8 @@ tone="neutral" variant="none" > - + diff --git a/frontend/purestack.studio/components/header.mdx b/frontend/purestack.studio/components/header.mdx index 9279842b..5f33233a 100644 --- a/frontend/purestack.studio/components/header.mdx +++ b/frontend/purestack.studio/components/header.mdx @@ -1 +1,10 @@ - + + + diff --git a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx index da923258..623168aa 100644 --- a/frontend/purestack.studio/components/site/top-bar/top-bar.mdx +++ b/frontend/purestack.studio/components/site/top-bar/top-bar.mdx @@ -231,7 +231,8 @@ TopBar renders a fixed doc-nav-toggle ID and should appear once per document. It

- No slots. Register defineTopBarComponents and the selected logo component, + Use the actions slot to add links or controls beside the theme toggle. + Register defineTopBarComponents and the selected logo component, SearchBox, ThemeToggle, SignIn, Flex and Icon families. Standard SSG registration provides these dependencies.

diff --git a/frontend/purestack.studio/footer.mdx b/frontend/purestack.studio/footer.mdx index 910a939d..9e05d6b3 100644 --- a/frontend/purestack.studio/footer.mdx +++ b/frontend/purestack.studio/footer.mdx @@ -13,11 +13,11 @@
OPEN SOURCEGitHub ↗GitHub ↗Issues ↗MIT license ↗
@@ -29,6 +29,8 @@ >Made for what comes next.Back to top ↑ diff --git a/frontend/purestack.studio/guides/extending/plugins.mdx b/frontend/purestack.studio/guides/extending/plugins.mdx index 6c4a7d5e..1e0fe5b6 100644 --- a/frontend/purestack.studio/guides/extending/plugins.mdx +++ b/frontend/purestack.studio/guides/extending/plugins.mdx @@ -177,7 +177,7 @@ Besides the prepared `head` and the page's `bodyHtml`, a template receives the p ## Transform Markdown -`markdown` adds [remark](https://github.com/remarkjs/remark) plugins, which change the Markdown tree, and [rehype](https://github.com/rehypejs/rehype) plugins, which change the HTML tree. Every page, header, and footer goes through these steps: +`markdown` adds remark plugins, which change the Markdown tree, and rehype plugins, which change the HTML tree. Every page, header, and footer goes through these steps: 1. PureStack reads the Markdown. Regor markup, such as ``, stays raw HTML. 2. Your remark plugins run. @@ -506,4 +506,4 @@ Every field except `name` is optional. ## A complete example -The PureStack Studio site you are reading uses one plugin for its skin, components, page layout, styles, and generated previews. Read the [Studio plugin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studioPlugin.ts) and the [config that adds it](https://github.com/PureStackStudio/PureStack/blob/main/frontend/purestack.studio/purestack.config.ts); the CLI builds and serves the site with no other code. +The PureStack Studio site you are reading uses one plugin for its skin, components, page layout, styles, and generated previews. Read the Studio plugin and the config that adds it; the CLI builds and serves the site with no other code. diff --git a/frontend/purestack.studio/guides/extending/regor.mdx b/frontend/purestack.studio/guides/extending/regor.mdx index dc6ad089..a94d9cec 100644 --- a/frontend/purestack.studio/guides/extending/regor.mdx +++ b/frontend/purestack.studio/guides/extending/regor.mdx @@ -17,7 +17,7 @@ highlights: # Regor -[Regor](https://github.com/koculu/Regor) is the component framework PureStack is built on. It runs in two places: +Regor is the component framework PureStack is built on. It runs in two places: @@ -55,6 +55,8 @@ A PureStack page can load any browser library, including React or Vue, because P - + diff --git a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx index a7b8ee5e..7903413b 100644 --- a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx +++ b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx @@ -15,7 +15,7 @@ nav: The `purestack` command builds a static site from a content directory. It discovers Markdown and Regor MDX pages, creates routes and navigation, generates theme styles, copies assets, and builds a search index when enabled. The same command also runs a development server and prepares a clean folder for publishing. -Install `purestack` in your project with `yarn add purestack`. The commands below use the installed `purestack` executable through Yarn. Run them from your project root. You can also explore the [repository's sample site](https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg/sample-content). +Install `purestack` in your project with `yarn add purestack`. The commands below use the installed `purestack` executable through Yarn. Run them from your project root. You can also explore the repository's sample site. ## Build a first site diff --git a/frontend/purestack.studio/guides/getting-started/site-config.mdx b/frontend/purestack.studio/guides/getting-started/site-config.mdx index 7ed61ea8..8e3d8363 100644 --- a/frontend/purestack.studio/guides/getting-started/site-config.mdx +++ b/frontend/purestack.studio/guides/getting-started/site-config.mdx @@ -27,7 +27,7 @@ content/ _nav.json ``` -The CLI requires this file. A custom TypeScript runner can also pass site configuration directly. Most runner values take precedence over file values; for `navigation`, the file values take precedence where both specify the same field. Omitted fields use the defaults described below. The [JSON Schema](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-ssg/schema/siteConfig.schema.json) lists every supported field and is useful for editor completion. +The CLI requires this file. A custom TypeScript runner can also pass site configuration directly. Most runner values take precedence over file values; for `navigation`, the file values take precedence where both specify the same field. Omitted fields use the defaults described below. The JSON Schema lists every supported field and is useful for editor completion. ## Start with a small config @@ -93,7 +93,7 @@ The `--content` CLI option chooses the content directory. A custom runner can se } ``` -The logo can use `imageSrc` and `imageSrcDark` instead of an icon, or a `monogram` mark. `imageSrc` takes priority over `icon`. `subtitle`, `appearance`, `wordmarkStyle`, `shape`, `tone`, colors, and size fields let you tune the mark and wordmark. Set `logo.href` to `null` or `""` for a noninteractive logo. For the full set of presentation fields, use the [schema](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-ssg/schema/siteConfig.schema.json) alongside the [SiteLogo component](/components/site/site-logo/). +The logo can use `imageSrc` and `imageSrcDark` instead of an icon, or a `monogram` mark. `imageSrc` takes priority over `icon`. `subtitle`, `appearance`, `wordmarkStyle`, `shape`, `tone`, colors, and size fields let you tune the mark and wordmark. Set `logo.href` to `null` or `""` for a noninteractive logo. For the full set of presentation fields, use the schema alongside the [SiteLogo component](/components/site/site-logo/). Relative paths in `siteConfig.json`, such as `imageSrc`, `style.href`, or `privacyPolicyUrl`, start at the content directory, where the file lives. `assets/logo.svg` becomes `/assets/logo.svg` on every page. diff --git a/frontend/purestack.studio/guides/getting-started/vscode-extension.mdx b/frontend/purestack.studio/guides/getting-started/vscode-extension.mdx index 592273d8..fa6a55d7 100644 --- a/frontend/purestack.studio/guides/getting-started/vscode-extension.mdx +++ b/frontend/purestack.studio/guides/getting-started/vscode-extension.mdx @@ -35,7 +35,7 @@ To develop the extension or install a local build, run this command from a PureS yarn pack-vscode-ext ``` -In VS Code, run **Extensions: Install from VSIX...** from the Command Palette and select the generated `.vsix` in `packages/ts-ssg-vscode/`. Reload the window and open your project folder. The [source README](https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg-vscode) also describes the Extension Development Host workflow. +In VS Code, run **Extensions: Install from VSIX...** from the Command Palette and select the generated `.vsix` in `packages/ts-ssg-vscode/`. Reload the window and open your project folder. The source README also describes the Extension Development Host workflow. ## Pick the right file @@ -81,7 +81,7 @@ export function defineMetricItemComponent() { } ``` -The editor highlights the expressions inside the double braces, recognizes directives such as `r-if` and `:tone`, and can navigate from the component or its public props to their TypeScript declarations. Formatting preserves the interpolation text while arranging the surrounding markup. [View the source](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-components/src/standard/landing/metricItem.ts). +The editor highlights the expressions inside the double braces, recognizes directives such as `r-if` and `:tone`, and can navigate from the component or its public props to their TypeScript declarations. Formatting preserves the interpolation text while arranging the surrounding markup. View the source. ## Find and use components diff --git a/frontend/purestack.studio/guides/header.mdx b/frontend/purestack.studio/guides/header.mdx index 9279842b..5f33233a 100644 --- a/frontend/purestack.studio/guides/header.mdx +++ b/frontend/purestack.studio/guides/header.mdx @@ -1 +1,10 @@ - + + + diff --git a/frontend/purestack.studio/guides/styling/themes.mdx b/frontend/purestack.studio/guides/styling/themes.mdx index 10fa990e..67dee7f5 100644 --- a/frontend/purestack.studio/guides/styling/themes.mdx +++ b/frontend/purestack.studio/guides/styling/themes.mdx @@ -88,4 +88,4 @@ themes.forEach((theme, palette) => { }) ``` -Register such styles from a plugin's `onConfigResolved` hook, which runs before the build writes theme assets. For a real example, inspect the [Studio plugin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/studioPlugin.ts), with its [skin](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioSkin.ts) and [styles](https://github.com/PureStackStudio/PureStack/blob/main/frontend/theme/studioStyles.ts). +Register such styles from a plugin's `onConfigResolved` hook, which runs before the build writes theme assets. For a real example, inspect the Studio plugin, with its skin and styles. diff --git a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx index f61df418..be9278f0 100644 --- a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx +++ b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx @@ -112,7 +112,7 @@ PureStack also has editorial role classes. They set a combination of font size,

PureStack team

``` -These roles include their own vertical margins. If one sits inside a tight component, adjust that margin explicitly with `m-0` or a side-specific spacing class. The [text utility sample](https://github.com/PureStackStudio/PureStack/blob/main/packages/ts-ssg/sample-content/guide/text-utilities-sample.mdx) shows the roles in full editorial sections. +These roles include their own vertical margins. If one sits inside a tight component, adjust that margin explicitly with `m-0` or a side-specific spacing class. The text utility sample shows the roles in full editorial sections. ### Larger text contexts diff --git a/frontend/purestack.studio/header.mdx b/frontend/purestack.studio/header.mdx index bb9b24b2..4648345f 100644 --- a/frontend/purestack.studio/header.mdx +++ b/frontend/purestack.studio/header.mdx @@ -9,15 +9,27 @@ Get started diff --git a/frontend/purestack.studio/index.mdx b/frontend/purestack.studio/index.mdx index 05a75d85..2759aa77 100644 --- a/frontend/purestack.studio/index.mdx +++ b/frontend/purestack.studio/index.mdx @@ -14,7 +14,7 @@ nav:
@@ -598,6 +606,8 @@ card.toCSS() { ...defineThemeToggleComponents(), ...defineTopBarComponents(), } - const html = await renderApp(``, { - components, - context: createTestContext({ - site: { - logo: { - brand: 'Calc Core', - subtitle: 'Backend engine', - suffix: '.', - href: '/docs/', - icon: 'iconoir:cube', - size: 'lg', - appearance: 'badge', - markStyle: 'solid', - wordmarkStyle: 'gradient', - brandColor: '#111111', - accentColor: '#ff0066', - }, - auth: { - enabled: true, - }, - pagefind: { - enabled: true, + const html = await renderApp( + ` + + `, + { + components, + context: createTestContext({ + site: { + logo: { + brand: 'Calc Core', + subtitle: 'Backend engine', + suffix: '.', + href: '/docs/', + icon: 'iconoir:cube', + size: 'lg', + appearance: 'badge', + markStyle: 'solid', + wordmarkStyle: 'gradient', + brandColor: '#111111', + accentColor: '#ff0066', + }, + auth: { + enabled: true, + }, + pagefind: { + enabled: true, + }, }, - }, - }), - }) + }), + }, + ) cleanup() expect(html).toContain('>Calc Core') @@ -139,6 +144,10 @@ describe('TopBar rendering', () => { expect(html).toContain('name="q"') expect(html).toContain('class="sign-in position-relative topbar__account"') expect(html).toContain('data-icon="lucide:log-in"') + expect(html).toContain('href="https://x.com/purestackstudio"') + expect(html).toContain('data-icon="tabler:brand-x"') + expect(html).toContain('target="_blank"') + expect(html).toContain('rel="noopener"') expect((html.match(/class="flex/g) ?? []).length).toBeGreaterThan(1) expect(html).toContain('tone-fill-surface-alt') expect(html).toContain('tone-border-surface-alt') diff --git a/packages/ts-components/src/standard/topBar/topBar.ts b/packages/ts-components/src/standard/topBar/topBar.ts index f5a7fd7d..21dc538d 100644 --- a/packages/ts-components/src/standard/topBar/topBar.ts +++ b/packages/ts-components/src/standard/topBar/topBar.ts @@ -72,20 +72,23 @@ const topBarTemplate = html` - - ` diff --git a/packages/ts-components/src/standard/topBar/topBarStyle.ts b/packages/ts-components/src/standard/topBar/topBarStyle.ts index 48c996a8..176199f7 100644 --- a/packages/ts-components/src/standard/topBar/topBarStyle.ts +++ b/packages/ts-components/src/standard/topBar/topBarStyle.ts @@ -58,6 +58,11 @@ export function registerTopBarShellStyles( .borderRadius('0 !important') styleBuilder.select('.topbar__controls', theme).marginLeft('auto') + styleBuilder + .select('.topbar__actions', theme) + .display('flex') + .alignItems('center') + .gap('0rem') styleBuilder .select('.topbar', theme) .media(mediaBelow(BREAKPOINTS.sm)) diff --git a/packages/ts-css/src/baseStyle.ts b/packages/ts-css/src/baseStyle.ts index 0d87f386..864c51c6 100644 --- a/packages/ts-css/src/baseStyle.ts +++ b/packages/ts-css/src/baseStyle.ts @@ -6,11 +6,13 @@ import { hyphenizeCss } from './hyphenize' export abstract class RootStyle { static nextId = 1 + static revision = 0 id: number selector: string constructor(selector?: string) { this.id = BaseStyle.nextId++ this.selector = selector ?? '' + RootStyle.revision += 1 } abstract toCSS(): string } @@ -25,7 +27,11 @@ export class BaseStyle extends RootStyle { } set(key: string, value: string | number) { - this.props.set(hyphenizeCss(key), autoVar(value)) + key = hyphenizeCss(key) + value = autoVar(value) + if (this.props.get(key) === value) return this + this.props.set(key, value) + RootStyle.revision += 1 return this } @@ -84,7 +90,9 @@ export class BaseStyle extends RootStyle { use(css: T) { this.#asBaseStyle(css).props.forEach((value, key) => { + if (this.props.get(key) === value) return this.props.set(key, value) + RootStyle.revision += 1 }) return this } diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index be70b463..47eb2320 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -74,6 +74,21 @@ export class IncrementalContentState { private unindexedPages = new Map() private readonly renderInFlight = new Map>() private readonly contentIndex: ManifestContentIndex + private changeBatch: + | { + content?: Promise + navigation?: Promise + } + | undefined + + async withChangeBatch(apply: () => Promise): Promise { + this.changeBatch = {} + try { + return await apply() + } finally { + this.changeBatch = undefined + } + } constructor(private readonly input: IncrementalContentStateInput) { this.contentIndex = new ManifestContentIndex( @@ -185,6 +200,14 @@ export class IncrementalContentState { * the same pages cost nothing beyond discovery. */ async refreshContent() { + if (this.changeBatch) { + this.changeBatch.content ??= this.discoverContent() + return this.changeBatch.content + } + return this.discoverContent() + } + + private async discoverContent() { const { config, context, plugins } = this.input const contentFiles = await discoverSiteContent(config, plugins) const generated = this.trackGeneratedPages(contentFiles) @@ -263,6 +286,14 @@ export class IncrementalContentState { * it changes; an edit that leaves it alone touches no other page. */ async refreshNavigation() { + if (this.changeBatch) { + this.changeBatch.navigation ??= this.rebuildNavigation() + return this.changeBatch.navigation + } + return this.rebuildNavigation() + } + + private async rebuildNavigation() { const { config, context } = this.input const contentFiles = await this.refreshContent() const navigation = await buildNavigation( diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index 0b6fcbf9..a4695509 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -1,8 +1,9 @@ import fs from 'node:fs/promises' import path from 'node:path' +import { styleBuilder } from '@purestack/ts-style' import { disableLogger, getLogger, type Logger } from 'logpot' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { resolveSiteConfig } from '../../config/config' import { parseFrontmatterSource } from '../../frontmatter/frontmatter' import type { PureStackPlugin } from '../../plugins/plugin' @@ -59,6 +60,161 @@ describe('incremental builder', () => { await logger?.close() }) + it('keeps style changes during CSS generation pending for the next request', async () => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + await fs.mkdir(contentDir) + await fs.writeFile(path.join(contentDir, 'index.md'), '# Home') + let writes = 0 + const builder = await createIncrementalBuilder({ + siteConfig: { rootDir: base, contentDir, outDir }, + options: { + plugins: [ + { + name: 'count-css', + hooks: { + onStylesWritten() { + writes += 1 + }, + }, + }, + ], + }, + }) + await builder.prepareForRequests() + await builder.renderByUrlPath('/') + const render = styleBuilder.render.bind(styleBuilder) + let changed = false + const spy = vi + .spyOn(styleBuilder, 'render') + .mockImplementation(async (theme, pretty) => { + const css = await render(theme, pretty) + if (!changed) { + changed = true + styleBuilder.select('.concurrent-style', 'light').color('red') + } + return css + }) + try { + await builder.preparePageAssets() + expect(writes).toBe(1) + await builder.preparePageAssets() + expect(writes).toBe(2) + expect( + await fs.readFile(path.join(outDir, 'assets', 'site.css'), 'utf8'), + ).toContain('.concurrent-style') + await builder.preparePageAssets() + expect(writes).toBe(2) + } finally { + spy.mockRestore() + } + }) + }) + + it.each(['none', 'hybrid'] as const)( + 'shares discovery across watcher bursts with %s navigation', + async (mode) => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + await fs.mkdir(contentDir) + const files = Array.from({ length: 10 }, (_, index) => + path.join(contentDir, `page${index}.md`), + ) + for (const file of files) await fs.writeFile(file, '# Original') + let discoveries = 0 + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir, + navigation: { mode }, + }, + options: { + plugins: [ + { + name: 'count-discoveries', + pages() { + discoveries += 1 + return [] + }, + }, + ], + }, + }) + await builder.prepareForRequests() + await builder.renderByUrlPath('/page0/') + discoveries = 0 + expect(await builder.applyChanges([...files, files[0]])).toHaveLength( + 10, + ) + expect(discoveries).toBe(1) + + await fs.writeFile(files[0], '# Updated') + await fs.rm(files[1]) + const added = path.join(contentDir, 'added.md') + await fs.writeFile(added, '# Added') + await builder.applyChanges([files[0], files[1], added]) + expect(discoveries).toBe(2) + expect(await builder.renderByUrlPath('/page0/')).toBe(true) + expect( + await fs.readFile(path.join(outDir, 'page0', 'index.html'), 'utf8'), + ).toContain('Updated') + expect(await builder.renderByUrlPath('/added/')).toBe(true) + expect(await builder.renderByUrlPath('/page1/')).toBe(false) + }) + }, + ) + + it('stops a change batch on abort and clears its discovery cache after failure', async () => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + await fs.mkdir(contentDir) + const first = path.join(contentDir, 'first.md') + const second = path.join(contentDir, 'second.md') + await fs.writeFile(first, '# First') + await fs.writeFile(second, '# Second') + const controller = new AbortController() + let abort = false + let fail = false + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir: path.join(base, 'out'), + }, + options: { + plugins: [ + { + name: 'control-discovery', + pages() { + if (fail) throw new Error('discovery failed') + if (abort) controller.abort() + return [] + }, + }, + ], + }, + }) + await builder.prepareForRequests() + abort = true + expect( + await builder.applyChanges([first, second], controller.signal), + ).toHaveLength(1) + expect(await builder.applyChanges([second], controller.signal)).toEqual( + [], + ) + abort = false + fail = true + await expect(builder.applyChanges([first])).rejects.toThrow( + 'discovery failed', + ) + fail = false + expect(await builder.applyChanges([first, second])).toHaveLength(2) + }) + }) + it('ignores directory notifications and refreshes removed subtrees without deleting directories as files', async () => { await withTempDir(async (base) => { const contentDir = path.join(base, 'content') diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index a09317fb..62e2ae88 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -1,7 +1,7 @@ import fsPromises from 'node:fs/promises' import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' -import { themes } from '@purestack/ts-style' +import { styleBuilder, themes } from '@purestack/ts-style' import { toOutputAssetRelPath, toPosixPath } from '@purestack/ts-util' import { getLogger, type Logger } from 'logpot' import { @@ -174,6 +174,7 @@ class IncrementalRuntime { private assetWork: Promise = Promise.resolve() private pageAssetRevision = 0 private preparedAssetRevision = -1 + private preparedStyleRevision = -1 private renderOnRequest = false private staticAssets = new Map() private searchRevision = -1 @@ -227,6 +228,7 @@ class IncrementalRuntime { prepareForRequests: this.prepareForRequests, buildAll: this.buildAll, applyChange: this.applyChange, + applyChanges: this.applyChanges, renderIfDirtyByOutPath: this.renderIfDirtyByOutPath, renderByUrlPath: this.renderByUrlPath, preparePageAssets: this.preparePageAssets, @@ -422,6 +424,7 @@ class IncrementalRuntime { }, style, ) + this.preparedStyleRevision = styleResult.revision await hooks.onStylesWritten?.(this.context, styleResult) return styleResult } @@ -549,6 +552,27 @@ class IncrementalRuntime { return result } + applyChanges = async ( + filePaths: readonly string[], + signal?: AbortSignal, + ): Promise => { + if (signal?.aborted || filePaths.length === 0) return [] + await this.ensureContentReady() + const apply = async () => { + const results: IncrementalBuildResult[] = [] + for (const filePath of new Set(filePaths)) { + if (signal?.aborted) break + const result = await this.applyChange(filePath) + results.push(result) + if (result.fullRebuild) break + } + return results + } + return this.renderOnRequest + ? this.contentState.withChangeBatch(apply) + : apply() + } + renderIfDirtyByOutPath = async (outPath: string): Promise => { await this.ensureContentReady() return this.contentState.renderIfDirtyByOutPath(outPath) @@ -566,15 +590,21 @@ class IncrementalRuntime { await this.ensureContentReady() await this.queueAssetWork(async () => { const revision = this.pageAssetRevision - if (revision === this.preparedAssetRevision) return + if ( + revision === this.preparedAssetRevision && + this.preparedStyleRevision === styleBuilder.revision + ) + return // Only pages rendered in this session have registered script entries. await this.scriptEntrypoints.buildMissingEntrypoints( this.changeApplier.createResult('requested page assets'), ) - const styles = await this.writeStylesWithHooks(this.options.hooks) - this.manifest.styles = { - signature: styles.signature, - outputs: styles.outputs, + if (this.preparedStyleRevision !== styleBuilder.revision) { + const styles = await this.writeStylesWithHooks(this.options.hooks) + this.manifest.styles = { + signature: styles.signature, + outputs: styles.outputs, + } } this.preparedAssetRevision = revision }) diff --git a/packages/ts-ssg/src/build/incremental/types.ts b/packages/ts-ssg/src/build/incremental/types.ts index dc64d923..3d4a3f2a 100644 --- a/packages/ts-ssg/src/build/incremental/types.ts +++ b/packages/ts-ssg/src/build/incremental/types.ts @@ -16,6 +16,11 @@ export interface IncrementalBuilder { prepareForRequests: () => Promise buildAll: (reason: string) => Promise applyChange: (filePath: string) => Promise + /** Shares discovery/navigation across a burst; stops before the next file on abort. */ + applyChanges: ( + filePaths: readonly string[], + signal?: AbortSignal, + ) => Promise renderIfDirtyByOutPath: (outPath: string) => Promise renderByUrlPath: (urlPath: string, locale?: string) => Promise /** Writes styles and discovered scripts before a dev response is served. */ diff --git a/packages/ts-ssg/src/build/styles.ts b/packages/ts-ssg/src/build/styles.ts index a1e18a47..e66c039d 100644 --- a/packages/ts-ssg/src/build/styles.ts +++ b/packages/ts-ssg/src/build/styles.ts @@ -9,8 +9,10 @@ import { styleBuilder, } from '@purestack/ts-style' import { ensureDir } from '@purestack/ts-util-node' +import { getLogger } from 'logpot' export interface WriteStylesResult { + revision: number outPath: string outputs: string[] signature: string @@ -25,16 +27,23 @@ export async function writeStyles( input: WriteStylesInput, style: SiteStyleConfig, ): Promise { + const log = getLogger() const { outDir, includeHljsTheme = false } = input const { fileName, themes, pretty } = style const resultPaths: string[] = [] const hash = crypto.createHash('sha256') const orderedThemes = orderThemes(themes) styleBuilder.ensureThemes(orderedThemes) + // render() collects CSS synchronously, before awaiting formatting or file writes. + const rendering = Promise.all( + orderedThemes.map((theme) => styleBuilder.render(theme, pretty)), + ) + const revision = styleBuilder.revision + const renderedThemes = await rendering let lightOutPath: string | undefined - for (const theme of orderedThemes) { - const rendered = await styleBuilder.render(theme, pretty) + for (const [index, theme] of orderedThemes.entries()) { + const rendered = renderedThemes[index] const css = await resolveOutputCss( rendered, theme, @@ -45,6 +54,7 @@ export async function writeStyles( const outPath = path.join(outDir, 'assets', cssName) await ensureDir(outPath) await fs.writeFile(outPath, css) + log.info('css generated', { theme, outPath }) hash.update(css) hash.update('\0') resultPaths.push(outPath) @@ -54,6 +64,7 @@ export async function writeStyles( } return { + revision, outPath: lightOutPath ?? path.join(outDir, 'assets', resolveThemeFileName(fileName, 'light')), diff --git a/packages/ts-ssg/src/dev/server.test.ts b/packages/ts-ssg/src/dev/server.test.ts index 15ff7772..5ea44968 100644 --- a/packages/ts-ssg/src/dev/server.test.ts +++ b/packages/ts-ssg/src/dev/server.test.ts @@ -30,6 +30,54 @@ describe('dev server', () => { root = undefined }) + it.each([true, false])( + 'does not generate sitemap or robots with fullRender %s', + async (fullRender) => { + root = await makeRepoTempDir('.tmp-ts-ssg-dev-sitemap-') + const contentDir = path.join(root, 'content') + const outDir = path.join(root, 'out') + await writeFile(path.join(contentDir, 'index.md'), '# Home') + await writeFile( + path.join(contentDir, 'siteConfig.json'), + JSON.stringify({ + sitemap: { + enabled: true, + baseUrl: 'https://example.com', + robots: { enabled: true }, + }, + }), + ) + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + watch: false, + fullRender, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir, + sitemap: { + enabled: true, + baseUrl: 'https://example.com', + robots: { enabled: true }, + }, + }, + }, + }) + const response = await fetch(`http://${HOST}:${port}/`) + expect(response.status).toBe(200) + await response.text() + await expect( + fs.stat(path.join(outDir, 'sitemap.xml')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect( + fs.stat(path.join(outDir, 'robots.txt')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + }, + ) + it.each([true, false])( 'renders only requested pages and copies requested assets (clean %s)', async (cleanOutDir) => { diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index 83ee4d6a..2d10c178 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -83,6 +83,14 @@ export async function startDevServer( const logger = getLogger() const baseBuildInput: BuildInput = { ...input.build, + siteConfig: { + ...input.build?.siteConfig, + // Disabling sitemap generation also skips robots.txt; neither is needed in the dev server. + sitemap: { + ...input.build?.siteConfig?.sitemap, + enabled: false, + }, + }, options: { writeErrorPages: true, ...input.build?.options, @@ -116,6 +124,7 @@ export async function startDevServer( let liveReloadVersion = 0 let initialSetupDone = false let shuttingDown = false + const shutdownController = new AbortController() let watcher: { close: () => void } | undefined const requestState = createRebuildRequestState() @@ -150,7 +159,7 @@ export async function startDevServer( if (requestState.inFlight) return requestState.inFlight = true try { - while (requestState.pending) { + while (requestState.pending && !shuttingDown) { requestState.pending = false if (requestState.changedPaths.size > 0) { await rebuildChanged() @@ -188,8 +197,10 @@ export async function startDevServer( setupReady = prepared.promise try { await previousSetup + if (shuttingDown) return // Output cleanup must not race HTML reads or streamed asset responses. await Promise.all([...activeResponses]) + if (shuttingDown) return if (options?.reloadConfig) { buildInput = await projectConfig.load(baseBuildInput) watchConfigDependencies() @@ -210,7 +221,7 @@ export async function startDevServer( }) initialSetupDone = true } - notifyReload(reason) + if (!shuttingDown) notifyReload(reason) } catch (error) { logError(log, error, 'dev setup failed') } finally { @@ -231,15 +242,15 @@ export async function startDevServer( } let requiresFull = false let touched = false - for (const filePath of paths) { - let change: Awaited> - try { - change = await incremental.applyChange(filePath) - } catch (error) { - logError(log, error, 'incremental apply failed') - requiresFull = true - break - } + let changes: Awaited> = [] + try { + changes = await incremental.applyChanges(paths, shutdownController.signal) + } catch (error) { + logError(log, error, 'incremental apply failed') + requiresFull = true + } + if (shuttingDown) return + for (const change of changes) { if (change.fullRebuild) { requiresFull = true break @@ -310,6 +321,9 @@ export async function startDevServer( const shutdown = async () => { if (shuttingDown) return shuttingDown = true + shutdownController.abort() + requestState.pending = false + requestState.changedPaths.clear() if (requestState.timer) { clearTimeout(requestState.timer) requestState.timer = undefined diff --git a/packages/ts-style/src/styles.ts b/packages/ts-style/src/styles.ts index 7d3b3702..0de296ca 100644 --- a/packages/ts-style/src/styles.ts +++ b/packages/ts-style/src/styles.ts @@ -25,6 +25,9 @@ export function getThemeClass(theme: ThemeName) { export type StyleBuilder = typeof styleBuilder export const styleBuilder = { + get revision() { + return Style.revision + }, get(theme: ThemeName) { if (!theme) { throw new Error(