Skip to content
Open
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
18 changes: 16 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
- Questions are welcome, however unless there is a official support contract established between the maintainers and the requester, support is not guaranteed.
- Contributors reserve the right to walk away from this project at any moment with or without notice.

## Generated default layout

Edit `lib/defaults/default.root.layout.ts`, then run `npm run build:defaults` to regenerate `lib/defaults/default.root.layout.js`.
The JavaScript is checked in so normal development and tests work immediately after checkout, without a declaration build or a runtime TypeScript loader.
Do not edit the generated JavaScript directly.
Both layouts are published, and eject copies the requested language (rewriting the TypeScript type import to the public package entry).
Declaration cleanup and `npm run clean` deliberately preserve the generated JavaScript.

## Releasing

Changelog, and releasing is automated with npm scripts and actions. To create a release:
Expand All @@ -29,6 +37,12 @@ If for some reason that isn't working or a local release is preferred, follow th

- Ensure a clean working git workspace.
- Run `npm version {patch,minor,major}`.
- This wills update the version number and generate the changelog.
- This updates the version number, builds the default JavaScript and version-dependent manifest schema, and uses `releasearoni version --add` to stage both generated files alongside the changelog before npm creates the version commit and tag.
- Run `npm publish`.
- This will push your local git branch and tags to the default remote, perform a [gh-release](https://ghub.io/gh-release), and create an npm publication
- `releasearoni` runs the full build before pushing the branch and tags and creating the GitHub release.
- The `prepack` hook cleans old declarations before regenerating the default JavaScript and declarations for both `npm pack` and `npm publish`, so tarballs include both layout languages and their types even after a full release build.
- Post-publish cleanup removes temporary declarations and site output, but preserves versioned JavaScript so it does not dirty the version commit.

Generation belongs in `version`, not `preversion` (which runs before the version update) or `postversion` (which runs after the commit and tag).
The release workflow's pre-version reset/clean is safe because the initial generated JavaScript is tracked and the version hook rebuilds it before staging.
Run `npm run test:version-build` to verify generation, staging, tagging, and cleanup in a disposable repository without versioning this checkout.
1 change: 1 addition & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
- Type builds are only needed during publish time or when debugging types.
- After running a type build, clean up the generated build files and do not leave them sitting around.
- Use the cleanup scripts in `package.json` for generated type build files.
- The generated `lib/defaults/default.root.layout.js` is versioned runtime code, not temporary declaration output; regenerate it with `npm run build:defaults` after editing its TypeScript source and never remove it during cleanup.
- For formatting-only ESLint failures, use `npx eslint <path> --fix` for a quick targeted fix before rerunning lint.
- When handling PR review comments, validate that each comment is correct before making changes; maintainer comments are almost always valid, but review bot comments may be wrong, and after addressing a comment, always reply with what was done.
49 changes: 36 additions & 13 deletions bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @import { BsInstance } from '@domstack/sync'
*/

import { readFile } from 'node:fs/promises'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { basename, resolve, join, relative } from 'node:path'
import { parseArgs } from 'node:util'
import { printHelpText } from 'argsclopts'
Expand Down Expand Up @@ -74,6 +74,15 @@ const options = {
short: 'e',
help: 'eject the DOMStack default layout, style and client into the src flag directory',
},
language: {
type: 'string',
default: 'js',
help: 'language for --eject: ts or js (default: js)',
},
yes: {
type: 'boolean',
help: 'skip confirmation for --eject',
},
watch: {
type: 'boolean',
short: 'w',
Expand Down Expand Up @@ -143,10 +152,10 @@ async function run () {

// Eject task
if (argv['eject']) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
const language = argv['language']
if (language !== 'ts' && language !== 'js') {
throw new Error('--language must be ts or js')
}

const localPkg = await packageDirectory({ cwd: src })

Expand All @@ -162,9 +171,10 @@ async function run () {
const relativeSrc = relative(process.cwd(), src)
const relativePkg = relative(process.cwd(), localPkgJson)

const targetLayoutPath = `layouts/root.layout.${targetIsModule ? 'js' : 'mjs'}`
const extension = language === 'ts' ? (targetIsModule ? 'ts' : 'mts') : targetIsModule ? 'js' : 'mjs'
const targetLayoutPath = `layouts/root.layout.${extension}`
const targetGlobalStylePath = 'globals/global.css'
const targetGlobalClientPath = `globals/global.client.${targetIsModule ? 'js' : 'mjs'}`
const targetGlobalClientPath = `globals/global.client.${language === 'ts' ? 'ts' : extension}`

const tbPkgContents = await getPkg()
const mineVersion = tbPkgContents?.['dependencies']?.['mine.css']
Expand All @@ -185,18 +195,31 @@ domstack eject actions:
- Add fragtml@${fragtmlVersion} to ${relativePkg}
- Add highlight.js@${highlightVersion} to ${relativePkg}
`)
const answer = await askYesNo(rl, 'Continue?')
if (answer === false) {
console.log('No action taken. Exiting.')
process.exit(0)
if (!argv['yes']) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
let answer
try {
answer = await askYesNo(rl, 'Continue?')
} finally {
rl.close()
}
if (!answer) {
console.log('No action taken. Exiting.')
process.exit(0)
}
}

const defaultLayoutPath = join(__dirname, 'lib/defaults/default.root.layout.js')
const defaultLayoutPath = join(__dirname, `lib/defaults/default.root.layout.${language}`)
const defaultGlobalStylePath = join(__dirname, 'lib/defaults/default.style.css')
const defaultGlobalClientPath = join(__dirname, 'lib/defaults/default.client.js')

const layoutSource = await readFile(defaultLayoutPath, 'utf8')
const layout = language === 'ts'
? layoutSource.replace("from '#types'", "from '@domstack/static/types.js'")
: layoutSource
await mkdir(join(src, 'layouts'), { recursive: true })
await Promise.all([
copyFile(defaultLayoutPath, join(src, targetLayoutPath)),
writeFile(join(src, targetLayoutPath), layout),
copyFile(defaultGlobalStylePath, join(src, targetGlobalStylePath)),
copyFile(defaultGlobalClientPath, join(src, targetGlobalClientPath)),
])
Expand Down
20 changes: 19 additions & 1 deletion docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Usage: domstack [options]
--noEsbuildMeta skip writing the esbuild metafile to disk
--domstackManifest write the domstack manifest to disk
--eject, -e eject the DOMStack default layout, style and client into the src flag directory
--language language for --eject: ts or js (default: js)
--yes skip confirmation for --eject
--watch, -w build, watch and serve the site build
--watch-only watch and build the src folder without serving
--verbose show debug logs, including the build tree and individual copy operations
Expand Down Expand Up @@ -66,13 +68,29 @@ When you run `domstack --eject`, it will:
2.
Create a default global CSS file at `globals/global.css`
3.
Create a default client-side JavaScript file at `globals/global.client.js`
Create a default client-side JavaScript file at `globals/global.client.js` (or `.mjs` depending on your package.json type)
4.
Add the necessary dependencies to your package.json:
- mine.css
- fragtml
- highlight.js

Use `domstack --eject --language ts` to write `layouts/root.layout.ts` and `globals/global.client.ts` instead.
For packages without `"type": "module"`, the TypeScript layout uses `.mts` so Node loads it as ESM without changing your package type.
The CSS and added dependencies are the same for both languages.
JavaScript remains the default (`--language js`), with `.js` files in module packages and `.mjs` files otherwise.
Only `ts` and `js` are accepted language values.

DOMStack maintains one canonical TypeScript root layout and generates its JavaScript counterpart at build and release time.
Both files are published; the runtime loads JavaScript directly without a custom loader, and eject copies the selected language rather than compiling it.
The TypeScript output uses the public type-only `@domstack/static/types.js` entry instead of DOMStack's private `#types` alias.
Keep `@domstack/static` installed for those types; no runtime type import or separate TypeScript compilation step is needed.
The client is currently comment-only, but receives a `.ts` extension when TypeScript is selected.

For automation, run `domstack --eject --language ts --yes --src src` to skip the confirmation prompt.
Without `--yes`, eject asks for confirmation before writing files or updating dependencies.
Eject overwrites its target files, so review or back up existing customizations before proceeding.

It is recommended to eject early in your project so that you can customize the root layout as you see fit, and decouple yourself from potential unwanted changes in the default layout as new versions of DOMStack are released.

[domstack-sync]: https://www.npmjs.com/package/@domstack/sync
6 changes: 5 additions & 1 deletion docs/layouts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,11 @@ const defaultRootLayout: LayoutFunction<RootLayoutVars, string | HtmlResult, str
export default defaultRootLayout
```

If your `src` folder doesn't have a `root.layout.ts` file somewhere in it, `domstack` will use the default [`default.root.layout.js`](https://github.com/bcomnes/domstack/blob/master/lib/defaults/default.root.layout.js) file it ships.
If your `src` folder doesn't have a root layout in any supported JavaScript or TypeScript extension, `domstack` uses the JavaScript generated from its canonical [`default.root.layout.ts`](https://github.com/bcomnes/domstack/blob/master/lib/defaults/default.root.layout.ts) source.
Both files ship in the package, but the runtime loads `default.root.layout.js` directly without TypeScript stripping or a custom loader.
To wrap or reuse the upstream layout, import `@domstack/static/lib/defaults/default.root.layout.js`, not the `.ts` source; Node does not strip TypeScript inside `node_modules`.
The JavaScript has no runtime imports of DOMStack's private types.
Use [`domstack --eject --language ts` or `--language js`](../cli/README.md#ejecting-the-defaults) to customize it in your preferred language.
The default `root` layout includes a special boolean variable called `defaultStyle` that lets you disable a default page style (provided by [mine.css](http://github.com/bcomnes/mine.css)) that it ships with.

## Layout styles
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default neostandard({
ts: true,
ignores: [
...resolveIgnoresFromGitignore(),
'lib/defaults/default.root.layout.js',
'test-cases/build-errors/src/**/*.js',
'test-cases/page-build-errors/src/**/*.js',
],
Expand Down
3 changes: 2 additions & 1 deletion lib/build-pages/page-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createSubscribedData, extractDataDeps } from './data-deps.js'
import { DomStackDataError } from '../helpers/domstack-error.js'
import pretty from 'pretty'
import { resolveLayoutChain } from './resolve-layout-chain.js'
import { pathToFileURL } from 'node:url'

/**
* @typedef {Object<string, string>} WorkerFiles
Expand All @@ -28,7 +29,7 @@ import { resolveLayoutChain } from './resolve-layout-chain.js'
* @returns {Promise<{ render: InternalLayoutFunction<T, U, V, D>, vars: Partial<T>, parentLayout: string | undefined }>} The resolved layout module exports.
*/
export async function resolveLayout (layoutPath) {
const { default: layout, vars, parentLayout } = await import(layoutPath)
const { default: layout, vars, parentLayout } = await import(pathToFileURL(layoutPath).href)
if (typeof layout !== 'function') throw new TypeError(`Layout "${layoutPath}" must export a default render function`)
if (parentLayout !== undefined && (typeof parentLayout !== 'string' || !parentLayout.trim())) {
throw new TypeError(`Layout "${layoutPath}" parentLayout must be a non-empty string`)
Expand Down
51 changes: 18 additions & 33 deletions lib/defaults/default.root.layout.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,19 @@
/**
* @import { LayoutFunction } from '#types'
* @import { HtmlResult } from 'fragtml/types.js'
*/
import { html, raw, render } from 'fragtml'

/**
* @typedef {{
* title: string,
* siteName: string,
* defaultStyle: boolean,
* basePath: string
* }} DefaultRootLayoutVars
*/

/**
* Build all of the bundles using esbuild.
*
* @type {LayoutFunction<DefaultRootLayoutVars, string | HtmlResult, string>}
*/
export default function defaultRootLayout ({
// Generated from default.root.layout.ts by npm run build:defaults. Do not edit.
var __freeze = Object.freeze;
var __defProp = Object.defineProperty;
var __template = (cooked, raw2) => __freeze(__defProp(cooked, "raw", { value: __freeze(raw2 || cooked.slice()) }));
var _a;
import { html, raw, render } from "fragtml";
function defaultRootLayout({
vars: {
title,
siteName = 'domstack',
basePath,
siteName = "domstack",
basePath
/* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */
},
scripts,
styles,
children,
children
/* pages */
/* page */
}) {
Expand All @@ -36,19 +22,18 @@ export default function defaultRootLayout ({
<html>
<head>
<meta charset="utf-8" />
<title>${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName}</title>
<title>${title ? `${title}` : ""}${title && siteName ? " | " : ""}${siteName}</title>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<meta name="color-scheme" content="light dark" />
${scripts
? scripts.map(script => html`<script type="module" src="${script.startsWith('/') ? `${basePath ?? ''}${script}` : script}"></script>`)
: null}
${styles
? styles.map(style => html`<link rel="stylesheet" href="${style.startsWith('/') ? `${basePath ?? ''}${style}` : style}" />`)
: null}
${scripts ? scripts.map((script) => html(_a || (_a = __template(['<script type="module" src="', '"><\/script>'])), script.startsWith("/") ? `${basePath ?? ""}${script}` : script)) : null}
${styles ? styles.map((style) => html`<link rel="stylesheet" href="${style.startsWith("/") ? `${basePath ?? ""}${style}` : style}" />`) : null}
</head>
<body class="safe-area-inset">
<main class="mine-layout app-main">${typeof children === 'string' ? raw(children) : children}</main>
<main class="mine-layout app-main">${typeof children === "string" ? raw(children) : children}</main>
</body>
</html>
`)
`);
}
export {
defaultRootLayout as default
};
17 changes: 15 additions & 2 deletions lib/defaults/default.root.layout.test.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { readFile } from 'node:fs/promises'
import { transform } from 'esbuild'
import canonicalRootLayout from './default.root.layout.ts'
import { load } from 'cheerio'
import { html, raw, render } from 'fragtml'
import defaultRootLayout from './default.root.layout.js'

test('checked-in JavaScript matches the canonical TypeScript build', async () => {
const source = await readFile(new URL('./default.root.layout.ts', import.meta.url), 'utf8')
const generated = await readFile(new URL('./default.root.layout.js', import.meta.url), 'utf8')
const { code } = await transform(source, { loader: 'ts', format: 'esm', target: 'es2022', legalComments: 'inline' })
assert.equal(generated, `// Generated from default.root.layout.ts by npm run build:defaults. Do not edit.\n${code}`)
assert.doesNotMatch(generated, /#types|import type|@domstack\/static\/types/)
})

test('string and HtmlResult children preserve whitespace through the root layout', async () => {
const code = 'first line\n indented line\n\n\tlast line\n'
const contents = `<pre><code>${code}</code></pre><pre>${code}</pre><textarea>${code}</textarea>`
for (const children of [contents, html`<article>${raw(contents)}</article>`]) {
const output = await defaultRootLayout({
const params = {
children,
vars: { title: '<Title>', siteName: 'Test', defaultStyle: true, basePath: '' },
data: {},
// This layout does not inspect page metadata.
page: /** @type {any} */ ({}),
})
}
const output = defaultRootLayout(params)
assert.equal(output, canonicalRootLayout(params))
const $ = load(output)
assert.equal($('title').text(), '<Title> | Test', 'metadata remains escaped')
assert.equal($('main pre code').text(), code, 'fenced code preserves exact whitespace')
Expand Down
44 changes: 44 additions & 0 deletions lib/defaults/default.root.layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { LayoutFunctionParams } from '#types'
import type { HtmlResult } from 'fragtml/types.js'
import { html, raw, render } from 'fragtml'

export type DefaultRootLayoutVars = {
title: string
siteName: string
defaultStyle: boolean
basePath: string
}
export default function defaultRootLayout ({
vars: {
title,
siteName = 'domstack',
basePath,
/* defaultStyle = true Set this to false in global or page to disable the default style in the default layout */
},
scripts,
styles,
children,
/* pages */
/* page */
}: LayoutFunctionParams<DefaultRootLayoutVars, string | HtmlResult>): string {
return render(html`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${title ? `${title}` : ''}${title && siteName ? ' | ' : ''}${siteName}</title>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<meta name="color-scheme" content="light dark" />
${scripts
? scripts.map(script => html`<script type="module" src="${script.startsWith('/') ? `${basePath ?? ''}${script}` : script}"></script>`)
: null}
${styles
? styles.map(style => html`<link rel="stylesheet" href="${style.startsWith('/') ? `${basePath ?? ''}${style}` : style}" />`)
: null}
</head>
<body class="safe-area-inset">
<main class="mine-layout app-main">${typeof children === 'string' ? raw(children) : children}</main>
</body>
</html>
`)
}
Loading