Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 6 additions & 23 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { find } from '@11ty/dependency-tree-typescript'
import { assertInsideDest } from './lib/helpers/path.js'
import { getCopyGlob } from './lib/build-static/index.js'
import { getCopyDirs } from './lib/build-copy/index.js'
import { classifyFile, isProcessedFile, globalBundleAssets, pageBundleAssets, layoutBundleAssets } from './lib/file-conventions.js'
import { builder } from './lib/builder.js'
import { buildEsbuildWatch } from './lib/build-esbuild/index.js'
import { buildPages } from './lib/build-pages/index.js'
Expand All @@ -46,12 +47,9 @@ import {
esbuildSettingsNames,
markdownItSettingsNames,
domstackManifestSettingsNames,
pageClientNames,
layoutClientSuffixs,
globalClientNames,
globalStyleNames,
pageStyleName,
pageWorkerSuffixs,
serviceWorkerNames,
} from './lib/identify-pages.js'
import { ensureDest } from './lib/helpers/ensure-dest.js'
Expand Down Expand Up @@ -347,7 +345,7 @@ export class DomStack {
ignored: (filePath, stats) => {
return (
anymatch(filePath) ||
Boolean((stats?.isFile() && !/\.(js|mjs|cjs|ts|mts|cts|css|html|md)$/.test(filePath)))
Boolean((stats?.isFile() && !isProcessedFile(filePath)))
)
},
persistent: true,
Expand Down Expand Up @@ -456,16 +454,7 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)
const changedDir = relative(this.#src, dirname(changedPath))

// Check if this is an esbuild entry point by basename pattern
const isEsbuildEntry = (
pageClientNames.includes(changedBasename) ||
layoutClientSuffixs.some(s => changedBasename.endsWith(s)) ||
changedBasename.endsWith(layoutStyleSuffix) ||
pageWorkerSuffixs.some(s => changedBasename.endsWith(s)) ||
serviceWorkerNames.includes(changedBasename) ||
globalClientNames.includes(changedBasename) ||
globalStyleNames.includes(changedBasename) ||
changedBasename === pageStyleName
)
const isEsbuildEntry = classifyFile(changedBasename)?.bundleScope

if (isEsbuildEntry) {
this.#logger.info(`"${changedBasename}" ${event}, restarting esbuild...`)
Expand Down Expand Up @@ -798,19 +787,13 @@ ${siteData.errors.map(err => ` ${err.message}`).join('\n')}`)

// esbuildEntryPoints: absolute filepaths of all esbuild entry points
const esbuildEntryPoints = /** @type {Set<string>} */ (new Set())
if (siteData.globalClient) esbuildEntryPoints.add(resolve(siteData.globalClient.filepath))
if (siteData.globalStyle) esbuildEntryPoints.add(resolve(siteData.globalStyle.filepath))
for (const asset of globalBundleAssets(siteData)) esbuildEntryPoints.add(resolve(asset.filepath))
if (siteData.serviceWorker) esbuildEntryPoints.add(resolve(siteData.serviceWorker.filepath))
for (const page of siteData.pages) {
if (page.clientBundle) esbuildEntryPoints.add(resolve(page.clientBundle.filepath))
if (page.pageStyle) esbuildEntryPoints.add(resolve(page.pageStyle.filepath))
if (page.workers) {
for (const w of Object.values(page.workers)) esbuildEntryPoints.add(resolve(w.filepath))
}
for (const asset of pageBundleAssets(page)) esbuildEntryPoints.add(resolve(asset.filepath))
}
for (const layout of Object.values(siteData.layouts)) {
if (layout.layoutClient) esbuildEntryPoints.add(resolve(layout.layoutClient.filepath))
if (layout.layoutStyle) esbuildEntryPoints.add(resolve(layout.layoutStyle.filepath))
for (const asset of layoutBundleAssets(layout)) esbuildEntryPoints.add(resolve(asset.filepath))
}

this.#layoutDepMap = layoutDepMap
Expand Down
17 changes: 4 additions & 13 deletions lib/build-esbuild/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { writeFile } from 'fs/promises'
import { join, relative, basename, resolve, extname } from 'path'
import esbuild from 'esbuild'
import { globalBundleAssets, pageBundleAssets, layoutBundleAssets } from '../file-conventions.js'
import { resolveVars } from '../build-pages/resolve-vars.js'
import {
createDomstackManifestRecord,
Expand Down Expand Up @@ -168,9 +169,7 @@ function updateOutputFileInfo (outputMap, fileInfo) {
* @returns {Promise<esbuild.BuildOptions>}
*/
async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {}) {
const entryPoints = []
if (siteData.globalClient) entryPoints.push(join(src, siteData.globalClient.relname))
if (siteData.globalStyle) entryPoints.push(join(src, siteData.globalStyle.relname))
const entryPoints = /** @type {(string | { in: string, out: string })[]} */ (globalBundleAssets(siteData).map(asset => join(src, asset.relname)))

if (siteData.defaultLayout) {
entryPoints.push(
Expand All @@ -180,19 +179,11 @@ async function createBrowserBuildOpts (src, dest, siteData, opts, modeOpts = {})
}

for (const page of siteData.pages) {
if (page.clientBundle) entryPoints.push(join(src, page.clientBundle.relname))
if (page.pageStyle) entryPoints.push(join(src, page.pageStyle.relname))

if (page.workers) {
for (const workerFile of Object.values(page.workers)) {
entryPoints.push(join(src, workerFile.relname))
}
}
for (const asset of pageBundleAssets(page)) entryPoints.push(join(src, asset.relname))
}

for (const layout of Object.values(siteData.layouts)) {
if (layout.layoutClient) entryPoints.push(join(src, layout.layoutClient.relname))
if (layout.layoutStyle) entryPoints.push(join(src, layout.layoutStyle.relname))
for (const asset of layoutBundleAssets(layout)) entryPoints.push(join(src, asset.relname))
}

const browserVars = await resolveVars({
Expand Down
3 changes: 2 additions & 1 deletion lib/build-static/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @import { BuildStep, BuildStepResult } from '../builder.js'
*/
import { processedExtensions } from '../file-conventions.js'
import { copy } from 'cpx2'
import { createCopiedDomstackManifestRecords } from '../helpers/cpx2-report.js'

Expand All @@ -22,7 +23,7 @@ import { createCopiedDomstackManifestRecords } from '../helpers/cpx2-report.js'
*/
export function getCopyGlob (src) {
// Always ignore files we typically process. Otherwise it gets really confusing.
return `${src}/**/!(*.ts|*.tsx|*.mts|*.cts|*.js|*.jsx|*.cjs|*.mjs|*.css|*.html|*.md)`
return `${src}/**/!(${processedExtensions.map(ext => `*.${ext}`).join('|')})`
}

/**
Expand Down
114 changes: 114 additions & 0 deletions lib/file-conventions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @import { PageInfo, Layout, PageFileAsset } from './identify-pages.js'
* @import { SiteData } from './builder.js'
* @typedef {'page' | 'layout' | 'global' | 'service-worker' | null} BundleScope
* @typedef {'page' | 'layout' | 'pages-file' | 'template' | 'full' | 'pages' | 'markdown' | 'manifest' | 'bundle'} ChangeKind
* @typedef {{ names: string[], suffixes: string[], draftNames: string[], bundleScope: BundleScope, change: ChangeKind }} FileConvention
*/
import { extname } from 'node:path'
import { nodeHasTS } from './helpers/has-ts.js'

const javascriptExtensions = ['js', 'mjs', 'cjs']
const typescriptExtensions = ['ts', 'mts', 'cts']
const clientExtensions = ['tsx', ...typescriptExtensions, 'jsx', ...javascriptExtensions]
export const processedExtensions = [...clientExtensions, 'css', 'html', 'md']
export const layoutStyleSuffix = '.layout.css'
export const pageStyleName = 'style.css'

/**
* @param {string[]} names
* @param {ChangeKind} change
* @param {BundleScope} [bundleScope]
* @param {string[]} [suffixes]
* @param {string[]} [draftNames]
* @returns {FileConvention}
*/
function convention (names, change, bundleScope = null, suffixes = [], draftNames = []) {
return { names, suffixes, draftNames, bundleScope, change }
}

/** @param {boolean} [supportsTypeScript] */
export function createFileConventions (supportsTypeScript = nodeHasTS) {
const serverExtensions = supportsTypeScript ? [...typescriptExtensions, ...javascriptExtensions] : javascriptExtensions
const names = (/** @type {string} */ stem, extensions = serverExtensions) => extensions.map(ext => `${stem}.${ext}`)
return {
page: convention(names('page'), 'page', null, [], names('page.draft')),
htmlPage: convention(['page.html'], 'page', null, [], ['page.draft.html']),
markdownPage: convention(['page.md'], 'page', null, [], ['page.draft.md']),
readmePage: convention(['README.md'], 'page', null, [], ['README.draft.md']),
pageVars: convention(names('page.vars'), 'page'),
pageClient: convention(names('client', clientExtensions), 'bundle', 'page'),
pageStyle: convention([pageStyleName], 'bundle', 'page'),
pageWorker: convention([], 'bundle', 'page', names('.worker')),
layout: convention([], 'layout', null, names('.layout')),
layoutClient: convention([], 'bundle', 'layout', names('.layout.client', clientExtensions)),
layoutStyle: convention([], 'bundle', 'layout', [layoutStyleSuffix]),
template: convention([], 'template', null, names('.template')),
pages: convention([], 'pages-file', null, names('.pages')),
globalStyle: convention(['global.css', 'global.style.css'], 'bundle', 'global'),
globalClient: convention(names('global.client', clientExtensions), 'bundle', 'global'),
serviceWorker: convention(names('service-worker'), 'bundle', 'service-worker'),
globalVars: convention(names('global.vars'), 'full'),
globalData: convention(names('global.data'), 'pages'),
esbuildSettings: convention(names('esbuild.settings'), 'full'),
markdownSettings: convention(names('markdown-it.settings'), 'markdown'),
manifestSettings: convention(names('domstack-manifest.settings'), 'manifest'),
}
}

export const fileConventions = createFileConventions()

/**
* Classify a basename; dynamic dependency membership belongs to the watch planner.
* @param {string} name
* @param {ReturnType<typeof createFileConventions>} [conventions]
*/
export function classifyFile (name, conventions = fileConventions) {
return Object.values(conventions).find(rule =>
rule.names.includes(name) || rule.draftNames.includes(name) || rule.suffixes.some(suffix => name.endsWith(suffix))
)
}

/** @param {string} filepath */
export function isProcessedFile (filepath) {
return processedExtensions.includes(extname(filepath).slice(1))
}

// Preserve existing discovery exports and filename priority for downstream users.
export const jsPageNames = fileConventions.page.names
export const jsPageDraftNames = fileConventions.page.draftNames
export const pageVarsNames = fileConventions.pageVars.names
export const pageClientNames = fileConventions.pageClient.names
export const pageWorkerSuffixs = fileConventions.pageWorker.suffixes
export const layoutSuffixs = fileConventions.layout.suffixes
export const layoutClientSuffixs = fileConventions.layoutClient.suffixes
export const templateSuffixs = fileConventions.template.suffixes
export const pagesSuffixs = fileConventions.pages.suffixes
export const globalStyleNames = fileConventions.globalStyle.names
export const globalClientNames = fileConventions.globalClient.names
export const serviceWorkerNames = fileConventions.serviceWorker.names
export const globalVarsNames = fileConventions.globalVars.names
export const globalDataNames = fileConventions.globalData.names
export const esbuildSettingsNames = fileConventions.esbuildSettings.names
export const markdownItSettingsNames = fileConventions.markdownSettings.names
export const domstackManifestSettingsNames = fileConventions.manifestSettings.names

/** @param {SiteData} siteData */
export function globalBundleAssets (siteData) {
return presentAssets([siteData.globalClient, siteData.globalStyle])
}

/** @param {PageInfo} page */
export function pageBundleAssets (page) {
return presentAssets([page.clientBundle, page.pageStyle, ...Object.values(page.workers ?? {})])
}

/** @param {Layout} layout */
export function layoutBundleAssets (layout) {
return presentAssets([layout.layoutClient, layout.layoutStyle])
}

/** @param {(PageFileAsset | undefined)[]} assets */
function presentAssets (assets) {
return assets.filter(asset => asset !== undefined)
}
62 changes: 62 additions & 0 deletions lib/file-conventions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { classifyFile, createFileConventions, isProcessedFile } from './file-conventions.js'
import { getCopyGlob } from './build-static/index.js'

test('server extensions follow runtime support while browser clients always support JSX and TypeScript', () => {
const withTypes = createFileConventions(true)
const withoutTypes = createFileConventions(false)
assert.deepEqual(withTypes.page.names, ['page.ts', 'page.mts', 'page.cts', 'page.js', 'page.mjs', 'page.cjs'])
assert.deepEqual(withoutTypes.page.names, ['page.js', 'page.mjs', 'page.cjs'])
assert.deepEqual(withTypes.page.draftNames, withTypes.page.names.map(name => name.replace('page.', 'page.draft.')))
assert.equal(classifyFile('page.ts', withoutTypes), undefined)
assert.equal(classifyFile('counter.worker.mts', withoutTypes), undefined)
assert.equal(classifyFile('service-worker.ts', withoutTypes), undefined)
for (const extension of ['tsx', 'ts', 'mts', 'cts', 'jsx', 'js', 'mjs', 'cjs']) {
assert.equal(classifyFile(`client.${extension}`, withoutTypes)?.bundleScope, 'page')
assert.equal(classifyFile(`root.layout.client.${extension}`, withoutTypes)?.bundleScope, 'layout')
assert.equal(classifyFile(`global.client.${extension}`, withoutTypes)?.bundleScope, 'global')
}
})

test('file roles distinguish structural settings, output owners, and asset scopes', () => {
const cases = [
['page.draft.html', 'page', null],
['README.draft.md', 'page', null],
['root.layout.js', 'layout', null],
['archive.pages.js', 'pages-file', null],
['feed.template.js', 'template', null],
['global.vars.js', 'full', null],
['global.data.js', 'pages', null],
['esbuild.settings.js', 'full', null],
['markdown-it.settings.js', 'markdown', null],
['domstack-manifest.settings.js', 'manifest', null],
['style.css', 'bundle', 'page'],
['counter.worker.js', 'bundle', 'page'],
['root.layout.css', 'bundle', 'layout'],
['global.style.css', 'bundle', 'global'],
['service-worker.js', 'bundle', 'service-worker'],
]
for (const [name, change, scope] of cases) {
assert.equal(typeof name, 'string')
const rule = classifyFile(/** @type {string} */ (name))
assert.equal(rule?.change, change, name ?? '')
assert.equal(rule?.bundleScope, scope, name ?? '')
}
assert.equal(classifyFile('arbitrary.js'), undefined)
assert.equal(classifyFile('style.css.map'), undefined)
})

test('all convention entries are observed and excluded from static copying', () => {
const glob = getCopyGlob('/site')
for (const rule of Object.values(createFileConventions(true))) {
for (const name of [...rule.names, ...rule.draftNames, ...rule.suffixes.map(suffix => `example${suffix}`)]) {
assert.equal(isProcessedFile(name), true, name)
assert.ok(glob.includes(`*.${name.split('.').at(-1)}`), name)
}
}
assert.equal(isProcessedFile('client.tsx'), true)
assert.equal(isProcessedFile('client.jsx'), true)
assert.equal(isProcessedFile('image.png'), false)
assert.equal(isProcessedFile('client.js.map'), false)
})
Loading