Skip to content

Commit 8fbe5bc

Browse files
committed
Introduce ts-format for purestack markup in ts and mdx files (extracted from our vs-code ext).
1 parent baae22a commit 8fbe5bc

34 files changed

Lines changed: 2416 additions & 1491 deletions

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@
6262
"verify-toolchain": "tsx scripts/verify-toolchain.ts",
6363
"pack-vscode-ext": "yarn --cwd packages/ts-ssg-vscode build && yarn --cwd packages/ts-ssg-vscode package",
6464
"longest": "yarn tsx ./scripts/longest-functions.ts",
65-
"format": "biome format --write",
65+
"format": "biome format --write && yarn format:markup --write",
66+
"format:markup": "tsx scripts/format-markup.ts",
67+
"format:check": "biome format && yarn format:markup --check",
6668
"pre-commit": "yarn check-dependencies && yarn check-eol && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run"
6769
}
6870
}

‎packages/purestack/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
},
4646
"dependencies": {
4747
"@purestack/ts-components": "workspace:^",
48+
"@purestack/ts-format": "workspace:^",
4849
"@purestack/ts-ssg": "workspace:^",
4950
"@purestack/ts-style": "workspace:^",
5051
"@purestack/ts-svg-icons": "workspace:^",

‎packages/purestack/src/cli/application.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const help = `PureStack
1616
purestack serve --content <directory> Preview with live reload
1717
purestack build --content <directory> Build static files
1818
purestack publish --content <dir> Prepare a local release artifact
19+
purestack format [paths] --write Format PureStack markup and templates
1920
purestack skills update Refresh this site's AI agent skill
2021
purestack --version Print the installed version
2122
@@ -31,9 +32,17 @@ export async function runApplication(
3132
return 0
3233
}
3334
if (!args.length || ['--help', '-h', 'help'].includes(args[0])) {
34-
console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help)
35+
if (args[0] === 'help' && args[1] === 'format') {
36+
const { formatHelp } = await import('./format')
37+
console.log(formatHelp)
38+
} else
39+
console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help)
3540
return 0
3641
}
42+
if (args[0] === 'format') {
43+
const { runFormatCli } = await import('./format')
44+
return await runFormatCli(args.slice(1), environment.cwd)
45+
}
3746
if (args[0] === 'skills') {
3847
if (args[1] !== 'update' || args.length > 2)
3948
throw new Error('Usage: purestack skills update')
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { runFormatCli } from './format'
6+
7+
afterEach(() => vi.restoreAllMocks())
8+
9+
describe('purestack format', () => {
10+
it('checks by default, writes on request, and returns correct exit statuses', async () => {
11+
vi.spyOn(console, 'log').mockImplementation(() => {})
12+
const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-format-cli-'))
13+
try {
14+
const file = path.join(cwd, 'page.mdx')
15+
const source = '<Panel><Badge /></Panel>'
16+
await writeFile(file, source)
17+
expect(await runFormatCli([], cwd)).toBe(1)
18+
expect(await readFile(file, 'utf8')).toBe(source)
19+
expect(await runFormatCli(['page.mdx', '--write'], cwd)).toBe(0)
20+
expect(await readFile(file, 'utf8')).toBe('<Panel><Badge/></Panel>')
21+
expect(await runFormatCli(['--check'], cwd)).toBe(0)
22+
await expect(runFormatCli(['--write', '--check'], cwd)).rejects.toThrow(
23+
'Choose either',
24+
)
25+
} finally {
26+
await rm(cwd, { recursive: true, force: true })
27+
}
28+
})
29+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import path from 'node:path'
2+
import { parseArgs } from 'node:util'
3+
import {
4+
type FormatFilesOptions,
5+
formatFiles,
6+
} from '@purestack/ts-format/files'
7+
8+
export const formatHelp = `Usage: purestack format [files or directories] [options]
9+
10+
Format PureStack component markup in .mdx/.rmdx and html/svg templates in TypeScript.
11+
Markdown prose, frontmatter, and TypeScript outside those templates stay untouched.
12+
Defaults to checking the current folder. Git-ignored and generated directories are skipped.
13+
14+
--write Save formatted files
15+
--check Check without writing (default); exit with 1 if changes are needed
16+
-h, --help Show help
17+
18+
Examples:
19+
purestack format content --write
20+
purestack format content --check`
21+
22+
export async function runFormatCli(
23+
args: string[],
24+
cwd: string,
25+
options: FormatFilesOptions = {},
26+
) {
27+
const { values, positionals } = parseArgs({
28+
args,
29+
allowPositionals: true,
30+
options: {
31+
write: { type: 'boolean', default: false },
32+
check: { type: 'boolean', default: false },
33+
help: { type: 'boolean', short: 'h', default: false },
34+
},
35+
})
36+
if (values.help) {
37+
console.log(formatHelp)
38+
return 0
39+
}
40+
if (values.write && values.check)
41+
throw new Error('Choose either --write or --check.')
42+
const result = await formatFiles(positionals.length ? positionals : ['.'], {
43+
...options,
44+
cwd,
45+
write: values.write,
46+
})
47+
for (const file of result.changed) {
48+
console.log(
49+
`${values.write ? 'Formatted' : 'Needs formatting:'} ${path.relative(cwd, file)}`,
50+
)
51+
}
52+
console.log(
53+
`Checked ${result.checked} files; ${result.changed.length} ${values.write ? 'formatted' : 'need formatting'}.`,
54+
)
55+
if (result.errors.length) {
56+
console.error(
57+
`\nFormatting failed for ${new Set(result.errors.map((error) => error.filePath)).size} files:`,
58+
)
59+
for (const error of result.errors)
60+
console.error(`${path.relative(cwd, error.filePath)}: ${error.message}`)
61+
}
62+
return result.errors.length || (!values.write && result.changed.length)
63+
? 1
64+
: 0
65+
}

‎packages/ts-format/LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Ahmed Yasin Koculu
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎packages/ts-format/README.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# @purestack/ts-format
2+
3+
The shared PureStack formatter used by PureStack CLI and the VS Code extension.
4+
Formats component markup in MDX/RMDX and `html`/`svg` tagged templates in TypeScript.
5+
Markdown prose, frontmatter, code samples, expressions, and surrounding TypeScript
6+
are preserved. Biome handles the rest of the project's formatting.
7+
8+
```ts
9+
import { formatSource } from '@purestack/ts-format'
10+
11+
const result = await formatSource(source, { filePath: 'content/page.mdx' })
12+
// result.code, result.changed, result.edits, result.diagnostics
13+
```
14+
15+
Options include `printWidth` (80), `tabSize` (2), `useTabs` (false), and an optional
16+
`onlyWithinRange` with UTF-16 source offsets. Edits refer to the original source.
17+
Line endings are preserved. Diagnostics identify markup that could not be formatted.
18+
19+
For files and directories:
20+
21+
```ts
22+
import { formatFiles } from '@purestack/ts-format/files'
23+
24+
const result = await formatFiles(['content'], { cwd: process.cwd(), write: true })
25+
// result.checked, result.changed, result.errors
26+
```
27+
28+
File operations default to checking without writing, honor Git ignores for directory
29+
scans, skip symlinks and generated directories, and collect failures across files.
30+
A file with formatting errors is never written. `exclude` accepts additional file
31+
or directory paths relative to `cwd`. Without Git, directory scans still work and
32+
skip known output, dependency, and cache directories.
33+
34+
From a PureStack project:
35+
36+
```sh
37+
purestack format content --write
38+
purestack format content --check
39+
```
40+
41+
In this repository, `yarn format:markup --write` formats PureStack markup only.
42+
`yarn format` runs Biome followed by this formatter; `yarn format:check` checks both.

‎packages/ts-format/package.json‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@purestack/ts-format",
3+
"version": "1.1.23",
4+
"packageManager": "yarn@4.18.1",
5+
"module": "./dist/ts-format.mjs",
6+
"types": "./dist/ts-format.d.mts",
7+
"type": "module",
8+
"sideEffects": false,
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/PureStackStudio/PureStack.git",
12+
"directory": "packages/ts-format"
13+
},
14+
"homepage": "https://purestack.studio",
15+
"bugs": {
16+
"url": "https://github.com/PureStackStudio/PureStack/issues"
17+
},
18+
"publishConfig": {
19+
"access": "public"
20+
},
21+
"exports": {
22+
".": {
23+
"types": "./dist/ts-format.d.mts",
24+
"import": "./dist/ts-format.mjs"
25+
},
26+
"./files": {
27+
"types": "./dist/files.d.mts",
28+
"import": "./dist/files.mjs"
29+
}
30+
},
31+
"license": "MIT",
32+
"files": [
33+
"dist/*.mjs",
34+
"dist/*.mjs.map",
35+
"dist/*.d.mts",
36+
"dist/*.d.mts.map",
37+
"README.md",
38+
"LICENSE"
39+
],
40+
"dependencies": {
41+
"prettier": "^3.8.1",
42+
"typescript-parser": "npm:typescript@6.0.3"
43+
},
44+
"scripts": {
45+
"build": "tsc",
46+
"lint": "biome check ./src --formatter-enabled=false --write",
47+
"test": "vitest"
48+
}
49+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { findMdxExpressionEnd } from './markup'
2+
3+
export interface RegorExpressionPlaceholder {
4+
placeholder: string
5+
source: string
6+
}
7+
8+
/**
9+
* Protects Regor {{ ... }} expressions while the surrounding markup is
10+
* formatted as HTML. The expression source is restored byte-for-byte.
11+
*/
12+
export function maskRegorExpressions(source: string) {
13+
const expressions: RegorExpressionPlaceholder[] = []
14+
let placeholderContent = ''
15+
let cursor = 0
16+
17+
while (cursor < source.length) {
18+
const start = source.indexOf('{{', cursor)
19+
if (start === -1) {
20+
placeholderContent += source.slice(cursor)
21+
break
22+
}
23+
24+
placeholderContent += source.slice(cursor, start)
25+
26+
const end = findRegorExpressionEnd(source, start)
27+
if (end === -1) {
28+
// Keep incomplete input untouched rather than guessing its boundary.
29+
placeholderContent += source.slice(start)
30+
break
31+
}
32+
33+
const placeholder = createPlaceholder(source, expressions.length)
34+
expressions.push({ placeholder, source: source.slice(start, end) })
35+
placeholderContent += placeholder
36+
cursor = end
37+
}
38+
39+
return { expressions, placeholderContent }
40+
}
41+
42+
export function restoreRegorExpressions(
43+
formatted: string,
44+
expressions: RegorExpressionPlaceholder[],
45+
) {
46+
let restored = formatted
47+
48+
for (const expression of expressions) {
49+
restored = restored.split(expression.placeholder).join(expression.source)
50+
}
51+
52+
return restored
53+
}
54+
55+
function findRegorExpressionEnd(source: string, start: number) {
56+
// Reuse the expression scanner already used by MDX. Starting at the first
57+
// brace makes {{ ... }} two outer brace levels, so nested object literals,
58+
// quoted braces and template-literal expressions remain balanced correctly.
59+
const end = findMdxExpressionEnd(source, start)
60+
if (end === -1) return -1
61+
62+
return source.slice(end - 2, end) === '}}' ? end : -1
63+
}
64+
65+
function createPlaceholder(source: string, index: number) {
66+
let placeholder = `__PURESTACK_REGOR_EXPR_${index}__`
67+
68+
while (source.includes(placeholder)) {
69+
placeholder = `_${placeholder}_`
70+
}
71+
72+
return placeholder
73+
}

0 commit comments

Comments
 (0)