From 7e83bf3742fe88a6e28fa543a85443473843789f Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Fri, 16 Oct 2026 16:29:11 +0200 Subject: [PATCH] Exclude imported source files from static output --- .../components/forms/appearance.ts | 4 +- .../forms/drop-files/drop-files.mdx | 4 +- .../components/forms/drop-files/playground.ts | 6 +- .../purestack.studio/guides/styling/icons.mdx | 2 +- package.json | 2 +- packages/purestack/package.json | 2 +- packages/purestack/src/index.ts | 2 +- packages/ts-common/package.json | 2 +- packages/ts-components/package.json | 2 +- .../src/standard/dropFiles/dropFiles.ts | 2 +- packages/ts-css/package.json | 2 +- packages/ts-html/package.json | 2 +- packages/ts-minidom/package.json | 2 +- packages/ts-page-scripts/package.json | 2 +- packages/ts-render/package.json | 2 +- packages/ts-ssg-vscode/package.json | 2 +- packages/ts-ssg/README.md | 2 +- packages/ts-ssg/package.json | 2 +- packages/ts-ssg/src/build/assets.ts | 11 +- packages/ts-ssg/src/build/content-urls.ts | 5 +- .../src/build/incremental/change-applier.ts | 3 + .../src/build/incremental/content-state.ts | 18 +- .../src/build/incremental/incremental.md | 7 +- .../src/build/incremental/incremental.test.ts | 345 ++++++++++++++++++ .../ts-ssg/src/build/incremental/index.ts | 194 +++++++--- .../build/incremental/script-entry-manager.ts | 25 +- .../ts-ssg/src/build/incremental/support.ts | 14 +- packages/ts-ssg/src/config/head.ts | 2 +- packages/ts-style/package.json | 2 +- packages/ts-svg-icons/package.json | 2 +- packages/ts-util-node/package.json | 2 +- packages/ts-util/package.json | 2 +- 32 files changed, 587 insertions(+), 89 deletions(-) diff --git a/frontend/purestack.studio/components/forms/appearance.ts b/frontend/purestack.studio/components/forms/appearance.ts index e8d8d158..d72440f8 100644 --- a/frontend/purestack.studio/components/forms/appearance.ts +++ b/frontend/purestack.studio/components/forms/appearance.ts @@ -26,7 +26,7 @@ import { lucide_list, lucide_list_ordered, lucide_loader_circle, - lucide_trash_2, + lucide_trash, lucide_underline, lucide_x, tabler_align_center, @@ -52,7 +52,7 @@ const icons: Record = { 'lucide:list': lucide_list, 'lucide:list-ordered': lucide_list_ordered, 'lucide:loader-circle': lucide_loader_circle, - 'lucide:trash-2': lucide_trash_2, + 'lucide:trash': lucide_trash, 'lucide:underline': lucide_underline, 'lucide:x': lucide_x, 'tabler:align-center': tabler_align_center, diff --git a/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx b/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx index f5a65b14..4a79b97b 100644 --- a/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx +++ b/frontend/purestack.studio/components/forms/drop-files/drop-files.mdx @@ -104,7 +104,7 @@ All eighteen supported treatments, shown in accent. Tone and variant are indepen ``` @@ -189,7 +189,7 @@ The drop zone opens the native picker with Enter or Space. File acceptance is a

Fallback file icon when no type-specific mapping matches.

- +

Icon on each remove button.

diff --git a/frontend/purestack.studio/components/forms/drop-files/playground.ts b/frontend/purestack.studio/components/forms/drop-files/playground.ts index 86e55566..447fbf45 100644 --- a/frontend/purestack.studio/components/forms/drop-files/playground.ts +++ b/frontend/purestack.studio/components/forms/drop-files/playground.ts @@ -13,7 +13,7 @@ import { lucide_file_archive, lucide_file_image, lucide_file_text, - lucide_trash_2, + lucide_trash, } from '@purestack/ts-svg-icons' import { type ComputedRef, @@ -65,7 +65,7 @@ const dropFilesExampleTemplate = html` + removeIcon="lucide:trash" :iconMap="fileIcons" :context="{ onChange: filesChanged }"/> {{ activity }}
  • {{ file.name }} · {{ file.type || 'Unknown type' }} · {{ file.sizeLabel }} · {{ file.icon }}
` @@ -97,7 +97,7 @@ const component = defineComponent(dropFilesExampleTemplate, { const icons: Record = { 'lucide:cloud-upload': lucide_cloud_upload, 'lucide:file': lucide_file, - 'lucide:trash-2': lucide_trash_2, + 'lucide:trash': lucide_trash, 'lucide:file-image': lucide_file_image, 'lucide:file-text': lucide_file_text, 'lucide:file-archive': lucide_file_archive, diff --git a/frontend/purestack.studio/guides/styling/icons.mdx b/frontend/purestack.studio/guides/styling/icons.mdx index c7d867b1..c0eebb6c 100644 --- a/frontend/purestack.studio/guides/styling/icons.mdx +++ b/frontend/purestack.studio/guides/styling/icons.mdx @@ -274,7 +274,7 @@ The lookup function throws for an unregistered name, so a missing icon causes an | --- | --- | | `FormSelectField`, `BtnGroup` drop-down | `lucide:chevron-down` | | Autocomplete inputs | `lucide:chevron-down`, `lucide:loader-circle` while loading, `lucide:x` to remove a choice | -| `DropFiles` | `lucide:cloud-upload`, `lucide:file`, `lucide:trash-2`, and `lucide:file-image`, `lucide:file-text`, or `lucide:file-archive` for common file types | +| `DropFiles` | `lucide:cloud-upload`, `lucide:file`, `lucide:trash`, and `lucide:file-image`, `lucide:file-text`, or `lucide:file-archive` for common file types | | `SignIn` | `lucide:log-in`, `tabler:user-filled` | | Comparison and pricing features | `lucide:check`, `iconoir:check` | | `Composer` toolbar | `lucide:bold`, `lucide:italic`, `lucide:underline`, `tabler:align-left`, `tabler:align-center`, `tabler:align-right`, `lucide:list`, `lucide:list-ordered`, `lucide:link`, `lucide:eraser`, `lucide:code` | diff --git a/package.json b/package.json index b2bf01ab..43896d03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 78ccbf64..ed4aa234 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 8af10807..3455841b 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.12' +export const version: string = '1.1.13' export * from '@purestack/ts-ssg' diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 5ef74f17..94742a05 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 20d666fb..700322c4 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-components/src/standard/dropFiles/dropFiles.ts b/packages/ts-components/src/standard/dropFiles/dropFiles.ts index 4f37be06..6a59eaca 100644 --- a/packages/ts-components/src/standard/dropFiles/dropFiles.ts +++ b/packages/ts-components/src/standard/dropFiles/dropFiles.ts @@ -80,7 +80,7 @@ export interface DropFiles { const DEFAULT_UPLOAD_ICON = 'lucide:cloud-upload' const DEFAULT_FILE_ICON = 'lucide:file' -const DEFAULT_REMOVE_ICON = 'lucide:trash-2' +const DEFAULT_REMOVE_ICON = 'lucide:trash' const DEFAULT_ICON_MAP: DropFilesIconMap = { 'image/*': 'lucide:file-image', diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index d6521296..8b8823b6 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index e41209f2..907e209c 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 981ccc3b..0f36c318 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 107a8b2a..cd6787c7 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index ee8d4351..85b891be 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 0304eb4a..06defd28 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.12", + "version": "1.1.13", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 42adb9be..b1d6236c 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -491,7 +491,7 @@ Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` h - Regor MDX (`.mdx`, `.rmdx`): `remark-parse` + `remark-gfm` with Regor component markup preservation - Plugin remark and rehype plugins, in plugin order - HTML output via HAST + rehype -- `` becomes a code block holding that file before compilation, anywhere a code block works; `src` resolves from the file the tag is in and must stay inside the content folder, and `lang` overrides the language inferred from the extension. Tags inside code stay as written, and the dev server re-renders the pages, headers, and footers that show a file when it changes. +- `` becomes a code block holding that file before compilation, anywhere a code block works; `src` resolves from the file the tag is in and must stay inside the content folder, and `lang` overrides the language inferred from the extension. Both import tags register their files during rendering, including nested imports and imports in headers, footers, and generated pages. Full builds copy static assets after rendering and exclude these registered files. In lazy dev mode, an importing page's first render excludes its imports and removes any copies already in the output folder; removing the last import makes the file available as a static asset again after re-rendering. Tags inside code stay as written, and the dev server re-renders the pages, headers, and footers that show a file when it changes. - H2/H3 outline extraction for page TOC - Code highlighting with highlight.js or Shiki diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 9fd7bb58..dbca59cc 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/src/build/assets.ts b/packages/ts-ssg/src/build/assets.ts index 1bd9637a..68b0db63 100644 --- a/packages/ts-ssg/src/build/assets.ts +++ b/packages/ts-ssg/src/build/assets.ts @@ -20,6 +20,11 @@ export interface StaticAssetBuildOptions { getScriptCacheKey?: (relPath: string) => string | undefined } +export interface CopyStaticAssetsOptions extends StaticAssetBuildOptions { + /** Already discovered assets selected by the build after rendering. */ + files?: readonly StaticAssetFile[] +} + export interface CopyStaticAssetResult { outPath: string copied: boolean @@ -29,10 +34,12 @@ export interface CopyStaticAssetResult { export async function copyStaticAssets( contentDir: string, outDir: string, - options: StaticAssetBuildOptions = {}, + options: CopyStaticAssetsOptions = {}, ): Promise { const log = getLogger() - const assets = await discoverStaticAssets(contentDir) + const assets = options.files + ? [...options.files] + : await discoverStaticAssets(contentDir) const tsDependencyIndex: Record = {} for (const asset of assets) { const outPath = resolveStaticOutPath(outDir, asset, options) diff --git a/packages/ts-ssg/src/build/content-urls.ts b/packages/ts-ssg/src/build/content-urls.ts index ab514f80..fd4cea06 100644 --- a/packages/ts-ssg/src/build/content-urls.ts +++ b/packages/ts-ssg/src/build/content-urls.ts @@ -8,8 +8,9 @@ import { resolveRouteInfo } from '../routing/route' const CONTENT_EXT_LIST = [...CONTENT_EXTS] /** - * Every page and asset the build publishes from the content folder, keyed by - * posix source path, so content URLs are checked without touching the disk. + * Discovered pages and assets, keyed by posix source path, so content URLs are + * checked without touching the disk. Render-time copy exclusions are separate: + * an edit can replace an import with a link before its asset is copied again. */ export class ContentRouteIndex { private readonly pagesByRelPath = new Map() diff --git a/packages/ts-ssg/src/build/incremental/change-applier.ts b/packages/ts-ssg/src/build/incremental/change-applier.ts index a9ef864f..1a10dfd4 100644 --- a/packages/ts-ssg/src/build/incremental/change-applier.ts +++ b/packages/ts-ssg/src/build/incremental/change-applier.ts @@ -33,6 +33,7 @@ interface IncrementalChangeApplierInput { scriptEntrypoints: ScriptEntrypointManager persistManifest: () => Promise renderOnRequest: () => boolean + isGeneratedOutput: (relPath: string) => boolean } export class IncrementalChangeApplier { @@ -275,6 +276,8 @@ export class IncrementalChangeApplier { return } + if (this.input.isGeneratedOutput(relPath)) return + const assetFile = toAssetFile(this.input.config.contentDir, relPath, ext) const assetCopy = await copyStaticAsset( this.input.config.contentDir, diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 47eb2320..9ca8ec35 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -46,6 +46,7 @@ interface IncrementalContentStateInput { renderOnRequest: () => boolean persistManifest: () => Promise getManifest: () => BuildManifest + getAssetRelPaths: () => string[] } interface RebuildNavigatedContentInput { @@ -110,6 +111,19 @@ export class IncrementalContentState { this.dirtyPages.clear() } + /** Dependencies registered while rendering pages, headers, and footers. */ + getImportedFiles() { + const imported = new Set(this.input.context.partialImportedFiles) + for (const files of this.importedFilesByPage.values()) { + for (const file of files) imported.add(file) + } + return imported + } + + isPageOutput(outPath: string) { + return this.contentIndex.getRelPathByOutPath(outPath) !== undefined + } + /** Pages marked to render on their next request since the last call. */ takeMarkedPageCount() { const count = this.markedPages @@ -268,8 +282,8 @@ export class IncrementalContentState { return { changed, removed } } - /** Picks up added or removed assets from the manifest. */ - refreshAssets(assetRelPaths = Object.keys(this.input.getManifest().assets)) { + /** Source URL validation uses discovery, independently of copy exclusions. */ + refreshAssets(assetRelPaths = this.input.getAssetRelPaths()) { const { context } = this.input this.updateContentRoutes(context.contentRoutes.withAssets(assetRelPaths)) } diff --git a/packages/ts-ssg/src/build/incremental/incremental.md b/packages/ts-ssg/src/build/incremental/incremental.md index bac5d836..6118c4b5 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.md +++ b/packages/ts-ssg/src/build/incremental/incremental.md @@ -60,13 +60,16 @@ HTML from earlier sessions. No background loop renders the rest of the site. This is the canonical full build path: - Resolves user hooks from `input.hooks`. -- Prepares output directory and copies static assets. +- Prepares the output directory and discovers static asset candidates. - Discovers content, runs page generators, and rebuilds navigation, once (`prepareContent`). Discovered pages join the URL index right away, so a request during the build finds a new page. - Renders all pages sequentially. +- Collects registered imports from pages and partials, then copies the remaining + static assets. Generated pages and script bundles own their output paths; + static copying and import cleanup must not overwrite or remove them. - Writes styles and captures style outputs/signature. -- Discovers assets to build an authoritative manifest snapshot. +- Uses copied assets to build an authoritative manifest snapshot. - Writes the manifest to disk with `writeManifest`. - Emits `onBuildComplete` with the final result (including content/asset counts) after manifest state is refreshed. diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index e1738eb1..e8486229 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -1178,6 +1178,351 @@ describe('incremental builder', () => { describe('code imports', () => { const text = (html: string) => html.replace(/<[^>]+>/g, '') + it('removes a deleted asset left by an earlier session before it is requested', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '# Home', + 'example.txt': 'previous output', + }) + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir: site.contentDir, + outDir: path.join(base, 'out'), + navigation: { mode: 'none' }, + }, + }) + await builder.prepareForRequests() + const sourcePath = path.join(site.contentDir, 'example.txt') + await fs.rm(sourcePath) + await builder.applyChange(sourcePath) + + expect(await fileExists(path.join(base, 'out', 'example.txt'))).toBe( + false, + ) + }) + }) + + it.each([false, true])( + 'preserves a rendered page when an HTML source shares its output (imported: %s)', + async (imported) => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': imported + ? '# Rendered home\n\n' + : '# Rendered home', + 'index.html': '

Raw HTML source

', + }) + + expect(await site.read('/')).toContain('Rendered home') + expect( + (await readManifest(path.join(base, 'out')))?.assets['index.html'], + ).toBeUndefined() + }) + }, + ) + + it.each([false, true])( + 'can replace an import with a link on the first render (lazy: %s)', + async (lazy) => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '', + 'example.txt': 'downloadable example', + }) + if (lazy) await site.builder.prepareForRequests() + + await site.change('index.mdx', '[Read the example](./example.txt)') + if (lazy) await site.builder.renderByUrlPath('/') + + expect(await site.read('/')).toContain('href="/example.txt"') + expect(await site.read('/')).not.toContain('points to a missing file') + }) + }, + ) + + it.skipIf(process.platform !== 'win32')( + 'protects page output names case-insensitively on Windows', + async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '# Rendered home', + 'INDEX.html': '

Raw HTML source

', + }) + + expect(await site.read('/')).toContain('Rendered home') + }) + }, + ) + + it('does not delete a page directory when excluding an extensionless import', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '', + 'sample.mdx': '# Sample page', + sample: 'extensionless example', + }) + + expect(await site.read('/sample/')).toContain('Sample page') + }) + }) + + it('regenerates pages when their data file is also imported as code', async () => { + await withTempDir(async (base) => { + const site = await createSite( + base, + 'none', + { + 'index.mdx': '', + 'data.json': '{"title":"First title"}', + }, + [ + { + name: 'data-pages', + async pages({ config }) { + const data = JSON.parse( + await fs.readFile( + path.join(config.contentDir, 'data.json'), + 'utf8', + ), + ) as { title: string } + return [{ path: 'data.mdx', source: `# ${data.title}` }] + }, + }, + ], + ) + await site.change('data.json', '{"title":"Second title"}') + + expect(await site.renderIfDirty('/data/')).toBe(true) + expect(await site.read('/data/')).toContain('Second title') + expect(await fileExists(path.join(base, 'out', 'data.json'))).toBe( + false, + ) + }) + }) + + it.each(['app.js', 'app.JS'])( + 'preserves a script bundle when an imported JS file shares its output (%s)', + async (examplePath) => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + await fs.mkdir(contentDir) + const sourcePath = path.join(contentDir, 'index.mdx') + const source = '' + await fs.writeFile(sourcePath, source) + await fs.writeFile( + path.join(contentDir, 'app.ts'), + "console.log('runtime bundle')", + ) + await fs.writeFile( + path.join(contentDir, examplePath), + "console.log('example source')", + ) + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir, + scripts: { cacheBusting: false }, + mdx: { disableHighlighter: true }, + navigation: { mode: 'none' }, + }, + }) + await builder.buildAll('initial') + const bundlePath = path.join(outDir, 'app.js') + const original = await fs.readFile(bundlePath, 'utf8') + expect(original).toContain('runtime bundle') + + await fs.writeFile( + sourcePath, + `${source}\n`, + ) + await builder.applyChange(sourcePath) + expect(await fs.readFile(bundlePath, 'utf8')).toBe(original) + + await fs.writeFile(sourcePath, source) + await builder.applyChange(sourcePath) + expect(await fs.readFile(bundlePath, 'utf8')).toBe(original) + }) + }, + ) + + it.each([false, true])( + 'updates static output as imports are edited (render on request: %s)', + async (lazy) => { + await withTempDir(async (base) => { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + await fs.mkdir(contentDir) + const write = async (relPath: string, source: string) => { + const filePath = path.join(contentDir, relPath) + await fs.writeFile(filePath, source) + return filePath + } + const initial = '' + await write('index.mdx', initial) + await write('example.css', '.example { color: red; }') + await write('data.json', '{"answer":42}') + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir, + navigation: { mode: 'none' }, + mdx: { disableHighlighter: true }, + }, + }) + if (lazy) { + await builder.prepareForRequests() + // Lazy builds learn imports from rendering, without scanning pages. + expect(await builder.prepareAssetByUrlPath('/example.css')).toBe( + true, + ) + expect(await builder.renderByUrlPath('/')).toBe(true) + } else await builder.buildAll('initial') + + expect(await fileExists(path.join(outDir, 'example.css'))).toBe(false) + expect(await builder.prepareAssetByUrlPath('/example.css')).toBe( + false, + ) + if (lazy) { + expect(await builder.prepareAssetByUrlPath('/data.json')).toBe(true) + } + expect(await fileExists(path.join(outDir, 'data.json'))).toBe(true) + + // Adding an import removes an asset that was already copied. + await builder.applyChange( + await write( + 'index.mdx', + `${initial}\n`, + ), + ) + expect(await builder.renderByUrlPath('/')).toBe(true) + expect(await fileExists(path.join(outDir, 'data.json'))).toBe(false) + expect(await builder.prepareAssetByUrlPath('/data.json')).toBe(false) + expect( + text(await fs.readFile(path.join(outDir, 'index.html'), 'utf8')), + ).toContain('{"answer":42}') + expect( + (await readManifest(outDir))?.assets['data.json'], + ).toBeUndefined() + + // Imported file edits still refresh the page without copying a file. + const edit = await builder.applyChange( + await write('example.css', '.example { color: blue; }'), + ) + expect(edit.changedAssets).toBe(0) + expect(await builder.renderByUrlPath('/')).toBe(true) + expect( + text(await fs.readFile(path.join(outDir, 'index.html'), 'utf8')), + ).toContain('color: blue') + expect(await fileExists(path.join(outDir, 'example.css'))).toBe(false) + + // Removing the last import makes the file a public asset again. + await builder.applyChange(await write('index.mdx', initial)) + expect(await builder.renderByUrlPath('/')).toBe(true) + if (lazy) { + expect(await builder.prepareAssetByUrlPath('/data.json')).toBe(true) + } + expect( + await fs.readFile(path.join(outDir, 'data.json'), 'utf8'), + ).toBe('{"answer":42}') + await fs.rm(path.join(contentDir, 'data.json')) + await builder.applyChange(path.join(contentDir, 'data.json')) + expect(await fileExists(path.join(outDir, 'data.json'))).toBe(false) + expect( + (await readManifest(outDir))?.assets['data.json'], + ).toBeUndefined() + }) + }, + ) + + it('keeps a file excluded until its last importing page releases it', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'index.mdx': '', + 'other.mdx': '', + 'example.txt': 'shared example', + }) + const assetPath = path.join(base, 'out', 'example.txt') + + await site.change('index.mdx', '# Home') + expect(await fileExists(assetPath)).toBe(false) + + await site.remove('other.mdx') + expect(await fs.readFile(assetPath, 'utf8')).toBe('shared example') + }) + }) + + it('copies assets after render registrations, including nested, partial, and generated imports', async () => { + await withTempDir(async (base) => { + const outDir = path.join(base, 'out') + const site = await createSite( + base, + 'none', + { + 'index.mdx': [ + '# Home', + '', + '', + '``', + '', + '```mdx\n\n```', + ].join('\n'), + '_shared/note.mdx': '', + '_shared/_nested.md': '', + '_unused.mdx': '', + 'header.mdx': '', + 'footer.rmdx': '', + 'example.py': 'print(42)', + 'header.txt': 'header sample', + 'footer.sql': 'SELECT 42;', + 'generated.json': '{"generated":true}', + 'inline.json': '{}', + 'fenced.json': '{}', + 'unused.json': '{}', + 'public.css': 'body {}', + }, + [ + { + name: 'generated-import', + pages: () => [ + { + path: 'generated.mdx', + source: () => '', + }, + ], + hooks: { + onPageWritten: async () => { + expect( + await fileExists(path.join(outDir, 'public.css')), + ).toBe(false) + }, + }, + }, + ], + ) + const manifest = await readManifest(outDir) + expect(Object.keys(manifest?.assets ?? {}).sort()).toEqual([ + 'fenced.json', + 'inline.json', + 'public.css', + 'unused.json', + ]) + for (const relPath of [ + 'example.py', + 'header.txt', + 'footer.sql', + 'generated.json', + ]) { + expect(await fileExists(path.join(outDir, relPath))).toBe(false) + } + expect(text(await site.read('/'))).toContain('print(42)') + expect(text(await site.read('/generated/'))).toContain('generated') + }) + }) + it.each(['auto', 'none'] as const)( 'shows an imported file as code and renders its pages again when it changes (navigation %s)', async (mode) => { diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index fb4719ea..5e40f11d 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -151,7 +151,6 @@ function isHighlightJsEnabled(mdx: SiteConfig['mdx'] | undefined): boolean { type BuildPreparationResult = { contentFiles: ResolvedContentFile[] - assetFiles: StaticAssetFile[] } type BuildSummaryInput = { @@ -178,6 +177,7 @@ class IncrementalRuntime { private preparedStyleRevision = -1 private renderOnRequest = false private staticAssets = new Map() + private importedFiles = new Set() private searchRevision = -1 private manifestWork: Promise = Promise.resolve() @@ -212,6 +212,8 @@ class IncrementalRuntime { renderOnRequest: () => this.renderOnRequest, persistManifest: () => this.persistManifest(), getManifest: () => this.manifest, + getAssetRelPaths: () => + [...this.staticAssets.values()].map((file) => file.relPath), }) this.changeApplier = new IncrementalChangeApplier({ config: options.config, @@ -220,6 +222,7 @@ class IncrementalRuntime { contentState: this.contentState, scriptEntrypoints: this.scriptEntrypoints, renderOnRequest: () => this.renderOnRequest, + isGeneratedOutput: (relPath) => this.isGeneratedOutput(relPath), persistManifest: () => this.persistManifest(), }) } @@ -263,7 +266,7 @@ class IncrementalRuntime { this.renderOnRequest = true this.contentReady ??= this.settleContentReady(this.prepareRequestState()) await this.contentReady - if (switchingFromBuild) await this.discoverRequestAssets() + if (switchingFromBuild) await this.discoverAssets() } private async prepareRequestState() { @@ -276,16 +279,17 @@ class IncrementalRuntime { delete this.manifest.assets[key] this.scriptCacheKeys.clear() await writeGeneratedFavicon(this.config) - await this.discoverRequestAssets() + await this.discoverAssets() const files = await this.prepareContent( [...this.staticAssets.values()].map((file) => file.relPath), this.options.hooks, ) + await this.syncImportedAssets() this.contentState.markAllPagesDirty(files) this.contentState.takeMarkedPageCount() } - private async discoverRequestAssets() { + private async discoverAssets() { const files = await discoverStaticAssets(this.config.contentDir) this.staticAssets = new Map( files.map((file) => [ @@ -295,6 +299,60 @@ class IncrementalRuntime { ) } + private publicStaticAssets() { + return [...this.staticAssets.values()].filter( + (file) => + !this.importedFiles.has(toPosixPath(file.relPath)) && + !this.isGeneratedOutput(file.relPath), + ) + } + + private isGeneratedOutput(relPath: string) { + const outPath = path.join(this.config.outDir, relPath) + return ( + this.contentState.isPageOutput(outPath) || + (path.extname(relPath).toLowerCase() === '.js' && + this.scriptEntrypoints.ownsOutputPath(outPath)) + ) + } + + /** Uses the render dependency records; no content is read or parsed here. */ + private syncImportedAssets(duringBuild = false): Promise { + return this.queueAssetWork(async () => { + const imported = this.contentState.getImportedFiles() + let changed = imported.size !== this.importedFiles.size + for (const relPath of imported) { + if (this.importedFiles.has(relPath)) continue + changed = true + const asset = this.staticAssets.get(`/${relPath}`) + if (!asset) continue + if (!this.isGeneratedOutput(asset.relPath)) { + const outPath = path.join(this.config.outDir, asset.relPath) + const stats = await fsPromises + .stat(outPath) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') + return undefined + throw error + }) + // An extensionless source file can share its name with a page folder. + if (stats && !stats.isDirectory()) await removeFile(outPath) + } + delete this.manifest.assets[asset.relPath] + } + if (!duringBuild && !this.renderOnRequest) { + for (const relPath of this.importedFiles) { + if (imported.has(relPath)) continue + const asset = this.staticAssets.get(`/${relPath}`) + if (asset) await this.prepareStaticAsset(asset) + } + } + this.importedFiles = imported + if (!changed) return + if (!duringBuild) await this.persistManifest() + }) + } + buildAll = async (reason: string): Promise => { this.context.styleCacheKey = createScriptCacheKey() const buildStartMs = Date.now() @@ -307,6 +365,15 @@ class IncrementalRuntime { const prepared = await preparing this.scriptEntrypoints.clearPageEntrypoints() const pages = await this.contentState.renderAllPages(prepared.contentFiles) + await this.syncImportedAssets(true) + const copiedAssets = await copyStaticAssets( + this.config.contentDir, + this.config.outDir, + { files: this.publicStaticAssets() }, + ) + this.scriptEntrypoints.rebuildDependencyIndex( + copiedAssets.tsDependencyIndex, + ) const scriptAssetFiles = await this.scriptEntrypoints.syncState({ result: this.changeApplier.createResult(reason), persist: false, @@ -316,7 +383,7 @@ class IncrementalRuntime { this.preparedAssetRevision = this.pageAssetRevision const finalResult = await this.finalizeBuild({ ...prepared, - assetFiles: [...prepared.assetFiles, ...scriptAssetFiles], + assetFiles: [...copiedAssets.files, ...scriptAssetFiles], pages, styleResult, hooks, @@ -339,22 +406,13 @@ class IncrementalRuntime { await hooks.onConfigResolved?.(this.context) await prepareOutDir(this.config.outDir, { clean: this.options.cleanOutDir }) await writeGeneratedFavicon(this.config) - const copiedAssets = await copyStaticAssets( - this.config.contentDir, - this.config.outDir, - { - minifyScripts: this.options.minifyScripts, - getScriptCacheKey: this.resolveScriptCacheKey, - }, - ) - this.scriptEntrypoints.rebuildDependencyIndex( - copiedAssets.tsDependencyIndex, - ) + await this.discoverAssets() + const assetFiles = [...this.staticAssets.values()] const contentFiles = await this.prepareContent( - copiedAssets.files.map((file) => file.relPath), + assetFiles.map((file) => file.relPath), hooks, ) - return { contentFiles, assetFiles: copiedAssets.files } + return { contentFiles } } /** @@ -398,11 +456,19 @@ class IncrementalRuntime { this.contentReady ??= this.settleContentReady( this.renderOnRequest ? this.prepareRequestState() - : this.prepareContent(Object.keys(this.manifest.assets)), + : this.prepareExistingContent(), ) return this.contentReady } + private async prepareExistingContent() { + await this.discoverAssets() + await this.prepareContent( + [...this.staticAssets.values()].map((file) => file.relPath), + ) + await this.syncImportedAssets() + } + private settleContentReady(preparing: Promise): Promise { const ready = preparing.then( () => undefined, @@ -515,6 +581,17 @@ class IncrementalRuntime { } this.contentState.takeMarkedPageCount() const ext = path.extname(relPath).toLowerCase() + const imported = this.importedFiles.has(toPosixPath(relPath)) + const navigationFile = + path.basename(relPath).toUpperCase() === + DEFAULT_NAV_FILENAME.toUpperCase() + const knownAsset = this.staticAssets.get( + `/${toPosixPath(toOutputAssetRelPath(relPath))}`, + ) + if (!isContentFile(relPath, ext) && ext !== '.ts') { + await this.discoverAssets() + this.contentState.refreshAssets() + } if ( this.renderOnRequest && !isContentFile(relPath, ext) && @@ -522,35 +599,40 @@ class IncrementalRuntime { !isDefaultHeaderFile(relPath) && !isDefaultFooterFile(relPath) && ext !== '.ts' && - path.basename(relPath).toUpperCase() !== - DEFAULT_NAV_FILENAME.toUpperCase() + !navigationFile ) { const cachedAsset = this.manifest.assets[relPath] - const knownAsset = this.staticAssets.get( - `/${toPosixPath(toOutputAssetRelPath(relPath))}`, - ) // A missing, empty directory can also arrive as an event. Only invalidate // output for paths previously discovered or copied as actual files. - if (cachedAsset || knownAsset) { + if ( + !imported && + !this.isGeneratedOutput(relPath) && + (cachedAsset || knownAsset) + ) { await removeFile( cachedAsset?.outPath ?? path.join(this.config.outDir, toOutputAssetRelPath(relPath)), ) } delete this.manifest.assets[relPath] - await this.discoverRequestAssets() - this.contentState.refreshAssets( - [...this.staticAssets.values()].map((file) => file.relPath), - ) await this.contentState.refreshGeneratedPages() - result.changedAssets += 1 + if (!imported) result.changedAssets += 1 } else if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { await this.contentState.refreshPartials() - } else if (!isSharedContentFile(relPath) || ext === '.ts') { + } else if ( + ext === '.ts' || + navigationFile || + (!isSharedContentFile(relPath) && + (isContentFile(relPath, ext) || !imported)) + ) { // Shared TypeScript can be a script dependency as well as an import. await this.changeApplier.applyFileChange(filePath, relPath, result) + } else if (imported && !isContentFile(relPath, ext)) { + // Copy exclusion must not suppress generators that read this data file. + await this.contentState.refreshGeneratedPages() } await this.contentState.refreshImporters(relPath) + await this.syncImportedAssets() result.markedPages = this.contentState.takeMarkedPageCount() return result } @@ -578,7 +660,9 @@ class IncrementalRuntime { renderIfDirtyByOutPath = async (outPath: string): Promise => { await this.ensureContentReady() - return this.contentState.renderIfDirtyByOutPath(outPath) + const rendered = await this.contentState.renderIfDirtyByOutPath(outPath) + await this.syncImportedAssets() + return rendered } renderByUrlPath = async ( @@ -586,7 +670,9 @@ class IncrementalRuntime { locale?: string, ): Promise => { await this.ensureContentReady() - return this.contentState.renderByUrlPath(urlPath, locale) + const rendered = await this.contentState.renderByUrlPath(urlPath, locale) + await this.syncImportedAssets() + return rendered } preparePageAssets = async (): Promise => { @@ -643,27 +729,39 @@ class IncrementalRuntime { ? `${pathname}index.html` : `${pathname}/index.html`, ) - if (!asset) return false + if ( + !asset || + this.importedFiles.has(toPosixPath(asset.relPath)) || + this.isGeneratedOutput(asset.relPath) + ) + return false await this.queueAssetWork(async () => { - if (this.manifest.assets[asset.relPath]) return - const signature = await readSignature(asset.absPath) - if (!signature) return - const copied = await copyStaticAsset( - this.config.contentDir, - this.config.outDir, - asset, - ) - if (!copied.copied) return - this.manifest.assets[asset.relPath] = { - relPath: asset.relPath, - ext: asset.ext, - outPath: copied.outPath, - ...signature, + if (!this.importedFiles.has(toPosixPath(asset.relPath))) { + await this.prepareStaticAsset(asset) } }) return true } + private async prepareStaticAsset(asset: StaticAssetFile) { + if (this.isGeneratedOutput(asset.relPath)) return + if (this.manifest.assets[asset.relPath]) return + const signature = await readSignature(asset.absPath) + if (!signature) return + const copied = await copyStaticAsset( + this.config.contentDir, + this.config.outDir, + asset, + ) + if (!copied.copied) return + this.manifest.assets[asset.relPath] = { + relPath: asset.relPath, + ext: asset.ext, + outPath: copied.outPath, + ...signature, + } + } + private queueAssetWork(work: () => Promise): Promise { const task = this.assetWork.then(work) this.assetWork = task.catch(() => undefined) diff --git a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts index 2d6d3c7a..aab808e6 100644 --- a/packages/ts-ssg/src/build/incremental/script-entry-manager.ts +++ b/packages/ts-ssg/src/build/incremental/script-entry-manager.ts @@ -2,11 +2,11 @@ import path from 'node:path' import type { SiteConfig } from '@purestack/ts-common' import type { StaticAssetFile } from '../../discover/content' -import { copyStaticAsset } from '../assets' +import { copyStaticAsset, resolveStaticOutPath } from '../assets' import type { AssetManifestEntry } from '../manifest' import { readSignature } from '../manifest' import type { ScriptCacheKeyStore } from '../script-cache-key' -import { removeFile, toAssetFile } from './support' +import { outputPathKey, removeFile, toAssetFile } from './support' import type { IncrementalBuildResult } from './types' interface ScriptEntrypointManagerInput { @@ -71,6 +71,27 @@ export class ScriptEntrypointManager { return this.input.cacheBusting } + ownsOutputPath(outPath: string) { + for (const relPath of this.collectDesiredEntrypoints()) { + const asset = toAssetFile( + this.input.config.contentDir, + relPath, + path.extname(relPath), + ) + const scriptOutPath = resolveStaticOutPath( + this.input.config.outDir, + asset, + { + scriptCacheKey: this.input.cacheBusting + ? this.input.scriptCacheKeys.ensure(relPath) + : undefined, + }, + ) + if (outputPathKey(scriptOutPath) === outputPathKey(outPath)) return true + } + return false + } + resolveImpactedEntryRelPaths(changedRelPath: string) { const normalized = this.normalizeRelPath(changedRelPath) const impacted = new Set() diff --git a/packages/ts-ssg/src/build/incremental/support.ts b/packages/ts-ssg/src/build/incremental/support.ts index 93d9ceeb..d4dba5c3 100644 --- a/packages/ts-ssg/src/build/incremental/support.ts +++ b/packages/ts-ssg/src/build/incremental/support.ts @@ -85,23 +85,23 @@ export class ManifestContentIndex { setOutPath(relPath: string, outPath: string) { const prevOutPath = this.relPathToOutPath.get(relPath) if (prevOutPath && prevOutPath !== outPath) { - this.outPathToRelPath.delete(prevOutPath) + this.outPathToRelPath.delete(outputPathKey(prevOutPath)) } this.relPathToOutPath.set(relPath, outPath) - this.outPathToRelPath.set(outPath, relPath) + this.outPathToRelPath.set(outputPathKey(outPath), relPath) } remove(relPath: string) { const outPath = this.relPathToOutPath.get(relPath) const urlPath = this.relPathToUrlPath.get(relPath) - if (outPath) this.outPathToRelPath.delete(outPath) + if (outPath) this.outPathToRelPath.delete(outputPathKey(outPath)) if (urlPath) this.urlPathToRelPath.delete(urlPath) this.relPathToOutPath.delete(relPath) this.relPathToUrlPath.delete(relPath) } getRelPathByOutPath(outPath: string) { - return this.outPathToRelPath.get(outPath) + return this.outPathToRelPath.get(outputPathKey(outPath)) } getRelPathByUrlPath(urlPath: string) { @@ -179,6 +179,12 @@ export async function discoverSiteContent( return contentFiles } +/** Compare output ownership using the host filesystem's path casing. */ +export function outputPathKey(outPath: string) { + const normalized = path.normalize(outPath) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + export function normalizeUrlPath(urlPath: string) { return urlNormalizer.normalizeUrlPath(urlPath) } diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index 4729953a..7f361019 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -25,7 +25,7 @@ export function getHead(config?: BasicHeadConfig) { const head = createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.12', + content: 'PureStack v1.1.13', }), h('meta').attr({ name: 'color-scheme', diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index 51e823ae..11c38f67 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 681989a6..e983787a 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index 007c2a9f..61fcf3ce 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index ca989712..a7b15ee6 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.12", + "version": "1.1.13", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts",