From 8fbe5bcaa54c11f353ef3dd6d2e6dea0bdf61973 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Fri, 23 Oct 2026 04:09:08 +0200 Subject: [PATCH 1/5] Introduce ts-format for purestack markup in ts and mdx files (extracted from our vs-code ext). --- package.json | 4 +- packages/purestack/package.json | 1 + packages/purestack/src/cli/application.ts | 11 +- packages/purestack/src/cli/format.test.ts | 29 + packages/purestack/src/cli/format.ts | 65 ++ packages/ts-format/LICENSE | 21 + packages/ts-format/README.md | 42 ++ packages/ts-format/package.json | 49 ++ packages/ts-format/src/expressions.ts | 73 +++ packages/ts-format/src/fences.ts | 438 +++++++++++++ packages/ts-format/src/files.test.ts | 69 +++ packages/ts-format/src/files.ts | 158 +++++ packages/ts-format/src/format.test.ts | 66 ++ packages/ts-format/src/html.ts | 32 + packages/ts-format/src/index.ts | 33 + packages/ts-format/src/markup.ts | 150 +++++ packages/ts-format/src/mdx.ts | 547 +++++++++++++++++ packages/ts-format/src/source.ts | 122 ++++ packages/ts-format/src/templates.ts | 272 ++++++++ packages/ts-format/tsconfig.json | 23 + packages/ts-ssg-vscode/package.json | 3 +- packages/ts-ssg-vscode/src/formatting.ts | 41 ++ packages/ts-ssg-vscode/src/htmlFormatting.ts | 39 +- packages/ts-ssg-vscode/src/markupSupport.ts | 148 +---- .../ts-ssg-vscode/src/mdxFenceFormatting.ts | 457 ++------------ packages/ts-ssg-vscode/src/mdxFormatting.ts | 579 +----------------- .../ts-ssg-vscode/src/regorExpressions.ts | 78 +-- .../ts-ssg-vscode/src/templateFormatting.ts | 312 ++-------- .../ts-ssg-vscode/src/typescriptRuntime.ts | 2 +- .../syntaxes/source.mdx.tmLanguage | 6 +- packages/ts-ssg-vscode/tsconfig.json | 10 +- scripts/format-markup.ts | 13 + tsconfig.json | 1 + yarn.lock | 13 +- 34 files changed, 2416 insertions(+), 1491 deletions(-) create mode 100644 packages/purestack/src/cli/format.test.ts create mode 100644 packages/purestack/src/cli/format.ts create mode 100644 packages/ts-format/LICENSE create mode 100644 packages/ts-format/README.md create mode 100644 packages/ts-format/package.json create mode 100644 packages/ts-format/src/expressions.ts create mode 100644 packages/ts-format/src/fences.ts create mode 100644 packages/ts-format/src/files.test.ts create mode 100644 packages/ts-format/src/files.ts create mode 100644 packages/ts-format/src/format.test.ts create mode 100644 packages/ts-format/src/html.ts create mode 100644 packages/ts-format/src/index.ts create mode 100644 packages/ts-format/src/markup.ts create mode 100644 packages/ts-format/src/mdx.ts create mode 100644 packages/ts-format/src/source.ts create mode 100644 packages/ts-format/src/templates.ts create mode 100644 packages/ts-format/tsconfig.json create mode 100644 packages/ts-ssg-vscode/src/formatting.ts create mode 100644 scripts/format-markup.ts diff --git a/package.json b/package.json index e47703d8..90051d1d 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,9 @@ "verify-toolchain": "tsx scripts/verify-toolchain.ts", "pack-vscode-ext": "yarn --cwd packages/ts-ssg-vscode build && yarn --cwd packages/ts-ssg-vscode package", "longest": "yarn tsx ./scripts/longest-functions.ts", - "format": "biome format --write", + "format": "biome format --write && yarn format:markup --write", + "format:markup": "tsx scripts/format-markup.ts", + "format:check": "biome format && yarn format:markup --check", "pre-commit": "yarn check-dependencies && yarn check-eol && yarn regor-components && yarn embed && yarn format && yarn lint && yarn build && yarn test run" } } diff --git a/packages/purestack/package.json b/packages/purestack/package.json index 4ba6c8ec..f50ec986 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@purestack/ts-components": "workspace:^", + "@purestack/ts-format": "workspace:^", "@purestack/ts-ssg": "workspace:^", "@purestack/ts-style": "workspace:^", "@purestack/ts-svg-icons": "workspace:^", diff --git a/packages/purestack/src/cli/application.ts b/packages/purestack/src/cli/application.ts index 95ddfa66..03aea5c4 100644 --- a/packages/purestack/src/cli/application.ts +++ b/packages/purestack/src/cli/application.ts @@ -16,6 +16,7 @@ const help = `PureStack purestack serve --content Preview with live reload purestack build --content Build static files purestack publish --content Prepare a local release artifact + purestack format [paths] --write Format PureStack markup and templates purestack skills update Refresh this site's AI agent skill purestack --version Print the installed version @@ -31,9 +32,17 @@ export async function runApplication( return 0 } if (!args.length || ['--help', '-h', 'help'].includes(args[0])) { - console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help) + if (args[0] === 'help' && args[1] === 'format') { + const { formatHelp } = await import('./format') + console.log(formatHelp) + } else + console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help) return 0 } + if (args[0] === 'format') { + const { runFormatCli } = await import('./format') + return await runFormatCli(args.slice(1), environment.cwd) + } if (args[0] === 'skills') { if (args[1] !== 'update' || args.length > 2) throw new Error('Usage: purestack skills update') diff --git a/packages/purestack/src/cli/format.test.ts b/packages/purestack/src/cli/format.test.ts new file mode 100644 index 00000000..193c6d2a --- /dev/null +++ b/packages/purestack/src/cli/format.test.ts @@ -0,0 +1,29 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runFormatCli } from './format' + +afterEach(() => vi.restoreAllMocks()) + +describe('purestack format', () => { + it('checks by default, writes on request, and returns correct exit statuses', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-format-cli-')) + try { + const file = path.join(cwd, 'page.mdx') + const source = '' + await writeFile(file, source) + expect(await runFormatCli([], cwd)).toBe(1) + expect(await readFile(file, 'utf8')).toBe(source) + expect(await runFormatCli(['page.mdx', '--write'], cwd)).toBe(0) + expect(await readFile(file, 'utf8')).toBe('') + expect(await runFormatCli(['--check'], cwd)).toBe(0) + await expect(runFormatCli(['--write', '--check'], cwd)).rejects.toThrow( + 'Choose either', + ) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/purestack/src/cli/format.ts b/packages/purestack/src/cli/format.ts new file mode 100644 index 00000000..5d09ef22 --- /dev/null +++ b/packages/purestack/src/cli/format.ts @@ -0,0 +1,65 @@ +import path from 'node:path' +import { parseArgs } from 'node:util' +import { + type FormatFilesOptions, + formatFiles, +} from '@purestack/ts-format/files' + +export const formatHelp = `Usage: purestack format [files or directories] [options] + +Format PureStack component markup in .mdx/.rmdx and html/svg templates in TypeScript. +Markdown prose, frontmatter, and TypeScript outside those templates stay untouched. +Defaults to checking the current folder. Git-ignored and generated directories are skipped. + + --write Save formatted files + --check Check without writing (default); exit with 1 if changes are needed + -h, --help Show help + +Examples: + purestack format content --write + purestack format content --check` + +export async function runFormatCli( + args: string[], + cwd: string, + options: FormatFilesOptions = {}, +) { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + write: { type: 'boolean', default: false }, + check: { type: 'boolean', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + }) + if (values.help) { + console.log(formatHelp) + return 0 + } + if (values.write && values.check) + throw new Error('Choose either --write or --check.') + const result = await formatFiles(positionals.length ? positionals : ['.'], { + ...options, + cwd, + write: values.write, + }) + for (const file of result.changed) { + console.log( + `${values.write ? 'Formatted' : 'Needs formatting:'} ${path.relative(cwd, file)}`, + ) + } + console.log( + `Checked ${result.checked} files; ${result.changed.length} ${values.write ? 'formatted' : 'need formatting'}.`, + ) + if (result.errors.length) { + console.error( + `\nFormatting failed for ${new Set(result.errors.map((error) => error.filePath)).size} files:`, + ) + for (const error of result.errors) + console.error(`${path.relative(cwd, error.filePath)}: ${error.message}`) + } + return result.errors.length || (!values.write && result.changed.length) + ? 1 + : 0 +} diff --git a/packages/ts-format/LICENSE b/packages/ts-format/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/packages/ts-format/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Yasin Koculu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ts-format/README.md b/packages/ts-format/README.md new file mode 100644 index 00000000..c0292eda --- /dev/null +++ b/packages/ts-format/README.md @@ -0,0 +1,42 @@ +# @purestack/ts-format + +The shared PureStack formatter used by PureStack CLI and the VS Code extension. +Formats component markup in MDX/RMDX and `html`/`svg` tagged templates in TypeScript. +Markdown prose, frontmatter, code samples, expressions, and surrounding TypeScript +are preserved. Biome handles the rest of the project's formatting. + +```ts +import { formatSource } from '@purestack/ts-format' + +const result = await formatSource(source, { filePath: 'content/page.mdx' }) +// result.code, result.changed, result.edits, result.diagnostics +``` + +Options include `printWidth` (80), `tabSize` (2), `useTabs` (false), and an optional +`onlyWithinRange` with UTF-16 source offsets. Edits refer to the original source. +Line endings are preserved. Diagnostics identify markup that could not be formatted. + +For files and directories: + +```ts +import { formatFiles } from '@purestack/ts-format/files' + +const result = await formatFiles(['content'], { cwd: process.cwd(), write: true }) +// result.checked, result.changed, result.errors +``` + +File operations default to checking without writing, honor Git ignores for directory +scans, skip symlinks and generated directories, and collect failures across files. +A file with formatting errors is never written. `exclude` accepts additional file +or directory paths relative to `cwd`. Without Git, directory scans still work and +skip known output, dependency, and cache directories. + +From a PureStack project: + +```sh +purestack format content --write +purestack format content --check +``` + +In this repository, `yarn format:markup --write` formats PureStack markup only. +`yarn format` runs Biome followed by this formatter; `yarn format:check` checks both. diff --git a/packages/ts-format/package.json b/packages/ts-format/package.json new file mode 100644 index 00000000..4edf53ea --- /dev/null +++ b/packages/ts-format/package.json @@ -0,0 +1,49 @@ +{ + "name": "@purestack/ts-format", + "version": "1.1.23", + "packageManager": "yarn@4.18.1", + "module": "./dist/ts-format.mjs", + "types": "./dist/ts-format.d.mts", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "packages/ts-format" + }, + "homepage": "https://purestack.studio", + "bugs": { + "url": "https://github.com/PureStackStudio/PureStack/issues" + }, + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/ts-format.d.mts", + "import": "./dist/ts-format.mjs" + }, + "./files": { + "types": "./dist/files.d.mts", + "import": "./dist/files.mjs" + } + }, + "license": "MIT", + "files": [ + "dist/*.mjs", + "dist/*.mjs.map", + "dist/*.d.mts", + "dist/*.d.mts.map", + "README.md", + "LICENSE" + ], + "dependencies": { + "prettier": "^3.8.1", + "typescript-parser": "npm:typescript@6.0.3" + }, + "scripts": { + "build": "tsc", + "lint": "biome check ./src --formatter-enabled=false --write", + "test": "vitest" + } +} diff --git a/packages/ts-format/src/expressions.ts b/packages/ts-format/src/expressions.ts new file mode 100644 index 00000000..df3786d4 --- /dev/null +++ b/packages/ts-format/src/expressions.ts @@ -0,0 +1,73 @@ +import { findMdxExpressionEnd } from './markup' + +export interface RegorExpressionPlaceholder { + placeholder: string + source: string +} + +/** + * Protects Regor {{ ... }} expressions while the surrounding markup is + * formatted as HTML. The expression source is restored byte-for-byte. + */ +export function maskRegorExpressions(source: string) { + const expressions: RegorExpressionPlaceholder[] = [] + let placeholderContent = '' + let cursor = 0 + + while (cursor < source.length) { + const start = source.indexOf('{{', cursor) + if (start === -1) { + placeholderContent += source.slice(cursor) + break + } + + placeholderContent += source.slice(cursor, start) + + const end = findRegorExpressionEnd(source, start) + if (end === -1) { + // Keep incomplete input untouched rather than guessing its boundary. + placeholderContent += source.slice(start) + break + } + + const placeholder = createPlaceholder(source, expressions.length) + expressions.push({ placeholder, source: source.slice(start, end) }) + placeholderContent += placeholder + cursor = end + } + + return { expressions, placeholderContent } +} + +export function restoreRegorExpressions( + formatted: string, + expressions: RegorExpressionPlaceholder[], +) { + let restored = formatted + + for (const expression of expressions) { + restored = restored.split(expression.placeholder).join(expression.source) + } + + return restored +} + +function findRegorExpressionEnd(source: string, start: number) { + // Reuse the expression scanner already used by MDX. Starting at the first + // brace makes {{ ... }} two outer brace levels, so nested object literals, + // quoted braces and template-literal expressions remain balanced correctly. + const end = findMdxExpressionEnd(source, start) + if (end === -1) return -1 + + return source.slice(end - 2, end) === '}}' ? end : -1 +} + +function createPlaceholder(source: string, index: number) { + let placeholder = `__PURESTACK_REGOR_EXPR_${index}__` + + while (source.includes(placeholder)) { + placeholder = `_${placeholder}_` + } + + return placeholder +} diff --git a/packages/ts-format/src/fences.ts b/packages/ts-format/src/fences.ts new file mode 100644 index 00000000..8242f91f --- /dev/null +++ b/packages/ts-format/src/fences.ts @@ -0,0 +1,438 @@ +import { scanTagTokens } from './markup' +import { + intersects, + type SourceEdit, + type SourceRange, + type SourceText, +} from './source' + +export interface MdxFencePlaceholder { + placeholder: string + source: string +} + +export interface MdxFence { + length: number + marker: '`' | '~' +} + +export interface MdxFenceDelimiter extends MdxFence { + suffix: string +} + +export interface MdxFenceDelimiterEditOptions { + onlyWithinRange?: SourceRange +} + +export function buildMdxFenceDelimiterEdits( + document: SourceText, + excludedRanges: SourceRange[], + options: MdxFenceDelimiterEditOptions = {}, +) { + const edits: SourceEdit[] = [] + let activeFence: MdxFence | undefined + let frontmatterHandled = false + let inFrontmatter = false + + for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { + const line = document.lineAt(lineIndex) + const lineText = line.text + const trimmed = lineText.trim() + + if (!frontmatterHandled && lineIndex === 0 && trimmed === '---') { + frontmatterHandled = true + inFrontmatter = true + continue + } + + if (inFrontmatter) { + if (trimmed === '---') inFrontmatter = false + continue + } + + if (isLineInsideAnyRange(line.range, excludedRanges)) { + continue + } + + if ( + options.onlyWithinRange && + !intersects(line.range, options.onlyWithinRange) + ) { + continue + } + + if (!activeFence) { + const splitLine = splitLineBeforeInlineMdxFenceOpener(lineText) + if (splitLine) { + const replacement = [ + ...splitAdjacentMarkupTags(splitLine.before), + splitLine.fenceLine, + ].join('\n') + pushLineReplacement(edits, line.range, lineText, replacement) + + const delimiter = parseMdxFenceDelimiter(splitLine.fenceLine.trim()) + activeFence = delimiter + ? { length: delimiter.length, marker: delimiter.marker } + : undefined + continue + } + + const delimiter = parseMdxFenceDelimiter(trimmed) + if (!delimiter) continue + + pushLineReplacement( + edits, + line.range, + lineText, + trimLineStartIndent(lineText), + ) + activeFence = { length: delimiter.length, marker: delimiter.marker } + continue + } + + const splitClosingPrefix = splitLineAfterInlineMdxFenceCloser( + lineText, + activeFence, + ) + if (splitClosingPrefix) { + const replacement = [ + trimLineStartIndent(splitClosingPrefix.fenceLine), + splitClosingPrefix.after, + ].join('\n') + pushLineReplacement(edits, line.range, lineText, replacement) + activeFence = undefined + continue + } + + const splitClosingSuffix = splitLineBeforeInlineMdxFenceCloser( + lineText, + activeFence, + ) + if (splitClosingSuffix) { + const replacement = [ + splitClosingSuffix.before, + trimLineStartIndent(splitClosingSuffix.fenceLine), + ].join('\n') + pushLineReplacement(edits, line.range, lineText, replacement) + activeFence = undefined + continue + } + + const delimiter = parseMdxFenceDelimiter(trimmed) + if (!delimiter || !isMdxFenceClosingDelimiter(delimiter, activeFence)) { + continue + } + + pushLineReplacement( + edits, + line.range, + lineText, + trimLineStartIndent(lineText), + ) + activeFence = undefined + } + + return edits.map((edit) => ({ + ...edit, + newText: document.preserveEol(edit.newText), + })) +} + +export function maskMdxFencedRegions(source: string) { + const fences: MdxFencePlaceholder[] = [] + const lines = splitInlineMdxFenceDelimiters(source).split('\n') + const placeholderLines: string[] = [] + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const openingDelimiter = parseMdxFenceDelimiter(lines[lineIndex].trim()) + if (!openingDelimiter) { + placeholderLines.push(lines[lineIndex]) + continue + } + + let closingLineIndex = -1 + for ( + let candidateLineIndex = lineIndex + 1; + candidateLineIndex < lines.length; + candidateLineIndex++ + ) { + const closingDelimiter = parseMdxFenceDelimiter( + lines[candidateLineIndex].trim(), + ) + if ( + closingDelimiter && + isMdxFenceClosingDelimiter(closingDelimiter, openingDelimiter) + ) { + closingLineIndex = candidateLineIndex + break + } + } + + if (closingLineIndex === -1) { + placeholderLines.push(lines[lineIndex]) + continue + } + + const placeholder = `` + fences.push({ + placeholder, + source: normalizeMdxFenceDelimiterIndent( + lines.slice(lineIndex, closingLineIndex + 1), + ), + }) + placeholderLines.push(placeholder) + lineIndex = closingLineIndex + } + + return { fences, placeholderContent: placeholderLines.join('\n') } +} + +export function restoreMdxFencedRegions( + formatted: string, + fences: MdxFencePlaceholder[], +) { + let restored = formatted + + for (const fence of fences) { + const standalonePlaceholderPattern = new RegExp( + `^[\\t ]*${escapeRegExp(fence.placeholder)}$`, + 'gm', + ) + if (standalonePlaceholderPattern.test(restored)) { + restored = restored.replace(standalonePlaceholderPattern, fence.source) + continue + } + + restored = restored.split(fence.placeholder).join(fence.source) + } + + return restored +} + +export function parseMdxFenceDelimiter( + trimmed: string, +): MdxFenceDelimiter | undefined { + const match = /^(`{3,}|~{3,})(.*)$/.exec(trimmed) + if (!match) return undefined + + return { + length: match[1].length, + marker: match[1].startsWith('`') ? '`' : '~', + suffix: match[2], + } +} + +export function parseInlineMdxFenceOpener(trimmed: string) { + const splitLine = splitLineBeforeInlineMdxFenceOpener(trimmed) + if (!splitLine) return undefined + + return parseMdxFenceDelimiter(splitLine.fenceLine.trim()) +} + +export function parseInlineMdxFenceCloser( + trimmed: string, + activeFence: MdxFence, +) { + const splitLine = splitLineAfterInlineMdxFenceCloser(trimmed, activeFence) + if (splitLine) return parseMdxFenceDelimiter(splitLine.fenceLine.trim()) + + const trailingSplitLine = splitLineBeforeInlineMdxFenceCloser( + trimmed, + activeFence, + ) + if (!trailingSplitLine) return undefined + + return parseMdxFenceDelimiter(trailingSplitLine.fenceLine.trim()) +} + +export function isMdxFenceClosingDelimiter( + delimiter: MdxFenceDelimiter, + activeFence: MdxFence, +) { + return ( + delimiter.marker === activeFence.marker && + delimiter.length >= activeFence.length && + delimiter.suffix.trim().length === 0 + ) +} + +function splitInlineMdxFenceDelimiters(source: string) { + const lines = source.split('\n') + const splitLines: string[] = [] + let activeFence: MdxFence | undefined + + for (const line of lines) { + if (!activeFence) { + const splitLine = splitLineBeforeInlineMdxFenceOpener(line) + if (splitLine) { + splitLines.push( + ...splitAdjacentMarkupTags(splitLine.before), + splitLine.fenceLine, + ) + + const delimiter = parseMdxFenceDelimiter(splitLine.fenceLine.trim()) + activeFence = delimiter + ? { length: delimiter.length, marker: delimiter.marker } + : undefined + continue + } + + splitLines.push(line) + + const delimiter = parseMdxFenceDelimiter(line.trim()) + if (!delimiter) continue + activeFence = { length: delimiter.length, marker: delimiter.marker } + continue + } + + const splitClosingLine = splitLineAfterInlineMdxFenceCloser( + line, + activeFence, + ) + if (splitClosingLine) { + splitLines.push(splitClosingLine.fenceLine, splitClosingLine.after) + activeFence = undefined + continue + } + + const splitTrailingClosingLine = splitLineBeforeInlineMdxFenceCloser( + line, + activeFence, + ) + if (splitTrailingClosingLine) { + splitLines.push( + splitTrailingClosingLine.before, + splitTrailingClosingLine.fenceLine, + ) + activeFence = undefined + continue + } + + splitLines.push(line) + + const delimiter = parseMdxFenceDelimiter(line.trim()) + if (!delimiter) continue + + if (isMdxFenceClosingDelimiter(delimiter, activeFence)) { + activeFence = undefined + } + } + + return splitLines.join('\n') +} + +function splitLineBeforeInlineMdxFenceOpener(line: string) { + if (parseMdxFenceDelimiter(line.trim())) return undefined + + const match = /^(.+?)[\t ]*((?:`{3,}|~{3,}).*)$/.exec(line) + if (!match) return undefined + + const before = match[1].trimEnd() + if (before.trim().length === 0) return undefined + + return { + before, + fenceLine: match[2], + } +} + +function splitAdjacentMarkupTags(line: string) { + const tokens = scanTagTokens(line) + if (tokens.length < 2) return [line] + + let cursor = 0 + const lines: string[] = [] + const indent = /^\s*/.exec(line)?.[0] ?? '' + + for (const token of tokens) { + if (line.slice(cursor, token.start).trim().length > 0) return [line] + + const tag = line.slice(token.start, token.end) + lines.push(lines.length === 0 ? `${indent}${tag}` : tag) + cursor = token.end + } + + if (line.slice(cursor).trim().length > 0) return [line] + + return lines +} + +function splitLineBeforeInlineMdxFenceCloser( + line: string, + activeFence: MdxFence, +) { + const pattern = new RegExp( + `^(.*?)[\\t ]*(${escapeRegExp(activeFence.marker.repeat(activeFence.length))}${escapeRegExp(activeFence.marker)}*)[\\t ]*$`, + ) + const match = pattern.exec(line) + if (!match || match[1].trim().length === 0) return undefined + + const closingDelimiter = parseMdxFenceDelimiter(match[2].trim()) + if (!closingDelimiter) return undefined + if (!isMdxFenceClosingDelimiter(closingDelimiter, activeFence)) { + return undefined + } + + return { + before: match[1].trimEnd(), + fenceLine: match[2], + } +} + +function splitLineAfterInlineMdxFenceCloser( + line: string, + activeFence: MdxFence, +) { + const pattern = new RegExp( + `^([\\t ]*${escapeRegExp(activeFence.marker.repeat(activeFence.length))}${escapeRegExp(activeFence.marker)}*)(.*)$`, + ) + const match = pattern.exec(line) + if (!match || match[2].trim().length === 0) return undefined + + const closingDelimiter = parseMdxFenceDelimiter(match[1].trim()) + if (!closingDelimiter) return undefined + if (!isMdxFenceClosingDelimiter(closingDelimiter, activeFence)) { + return undefined + } + + return { + fenceLine: match[1], + after: match[2], + } +} + +function normalizeMdxFenceDelimiterIndent(lines: string[]) { + if (lines.length < 2) return lines.join('\n') + + const normalizedLines = [...lines] + normalizedLines[0] = trimLineStartIndent(normalizedLines[0]) + normalizedLines[normalizedLines.length - 1] = trimLineStartIndent( + normalizedLines[normalizedLines.length - 1], + ) + return normalizedLines.join('\n') +} + +function trimLineStartIndent(line: string) { + return line.replace(/^[\t ]+/, '') +} + +function pushLineReplacement( + edits: SourceEdit[], + range: SourceRange, + oldText: string, + newText: string, +) { + if (newText === oldText) return + + edits.push({ ...range, newText }) +} + +function isLineInsideAnyRange(line: SourceRange, ranges: SourceRange[]) { + return ranges.some( + (range) => line.start >= range.start && line.end <= range.end, + ) +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/packages/ts-format/src/files.test.ts b/packages/ts-format/src/files.test.ts new file mode 100644 index 00000000..654ba257 --- /dev/null +++ b/packages/ts-format/src/files.test.ts @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatFiles } from './files' + +describe('formatFiles', () => { + it('checks without writing, saves once, and reports a clean second pass', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-format-')) + try { + const source = + 'const keep = 1;\nconst view = html``' + await writeFile(path.join(cwd, 'app.ts'), source) + await mkdir(path.join(cwd, 'node_modules')) + await writeFile(path.join(cwd, 'node_modules', 'ignored.ts'), source) + const check = await formatFiles(['.'], { cwd }) + expect(check.errors).toEqual([]) + expect(check.checked).toBe(1) + expect(check.changed).toEqual([path.join(cwd, 'app.ts')]) + expect(await readFile(path.join(cwd, 'app.ts'), 'utf8')).toBe(source) + expect( + (await formatFiles(['.'], { cwd, write: true })).changed, + ).toHaveLength(1) + expect(await readFile(path.join(cwd, 'app.ts'), 'utf8')).toContain( + 'const keep = 1;', + ) + expect((await formatFiles(['.'], { cwd })).changed).toEqual([]) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('honors Git ignores and exclusions while collecting errors and formatting other files', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-format-')) + try { + execFileSync('git', ['init', cwd], { stdio: 'ignore' }) + await writeFile(path.join(cwd, '.gitignore'), 'ignored.mdx\n') + await writeFile(path.join(cwd, 'ignored.mdx'), '') + await writeFile( + path.join(cwd, 'excluded.mdx'), + '', + ) + await writeFile(path.join(cwd, 'good.mdx'), '') + await writeFile( + path.join(cwd, 'bad.mdx'), + '', + ) + expect(await readFile(path.join(cwd, 'ignored.mdx'), 'utf8')).toBe( + '', + ) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ts-format/src/files.ts b/packages/ts-format/src/files.ts new file mode 100644 index 00000000..63f8500a --- /dev/null +++ b/packages/ts-format/src/files.ts @@ -0,0 +1,158 @@ +import { execFile } from 'node:child_process' +import { lstat, readdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { promisify } from 'node:util' +import { formatSource } from './index' +import type { FormatOptions } from './source' + +const exec = promisify(execFile) +const extensions = /\.(?:[cm]?ts|mdx|rmdx)$/i +const ignoredDirectories = new Set([ + '.git', + '.yarn', + '.tmp', + '.ts-ssg', + '.cache', + 'node_modules', + 'dist', + 'out', + 'coverage', + 'artifacts', +]) + +export interface FormatFilesOptions + extends Omit { + cwd?: string + write?: boolean + /** Files or directories to omit, relative to cwd. */ + exclude?: readonly string[] +} + +export interface FormatFileError { + filePath: string + message: string +} + +/** Discover source files, honor Git ignores in repositories, and collect all failures. */ +export async function formatFiles( + inputs: readonly string[] = ['.'], + options: FormatFilesOptions = {}, +) { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const excluded = (options.exclude ?? []).map((entry) => + path.resolve(cwd, entry), + ) + const files = new Set() + const errors: FormatFileError[] = [] + const changed: string[] = [] + + function isExcluded(file: string) { + return ( + path + .relative(cwd, file) + .split(path.sep) + .some((part) => ignoredDirectories.has(part)) || + excluded.some( + (entry) => file === entry || file.startsWith(`${entry}${path.sep}`), + ) + ) + } + + function addFile(file: string) { + if (extensions.test(file) && !isExcluded(file)) files.add(file) + } + + async function walk(directory: string) { + if (isExcluded(directory)) return + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name) + if (entry.isSymbolicLink()) continue + if (entry.isDirectory()) await walk(file) + else if (entry.isFile()) addFile(file) + } + } + + for (const input of inputs) { + const target = path.resolve(cwd, input) + try { + const entry = await lstat(target) + if (entry.isSymbolicLink()) continue + if (entry.isFile()) { + addFile(target) + continue + } + if (!entry.isDirectory() || isExcluded(target)) continue + let gitFiles: string[] | undefined + try { + const { stdout: root } = await exec('git', [ + '-C', + target, + 'rev-parse', + '--show-toplevel', + ]) + const repository = root.trim() + const relative = + path.relative(repository, target).split(path.sep).join('/') || '.' + const { stdout } = await exec( + 'git', + [ + '-C', + repository, + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '-z', + '--', + relative, + ], + { maxBuffer: 32 * 1024 * 1024 }, + ) + gitFiles = stdout + .split('\0') + .filter(Boolean) + .map((file) => path.resolve(repository, file)) + } catch { + // Git is optional; a standalone project still has a formatter. + } + if (gitFiles) { + for (const file of gitFiles) addFile(file) + } else await walk(target) + } catch (error) { + errors.push({ + filePath: target, + message: error instanceof Error ? error.message : String(error), + }) + } + } + + for (const filePath of [...files].sort()) { + try { + // Git may still list deleted files and symlinks. Never follow those into another directory. + const entry = await lstat(filePath).catch(() => undefined) + if (!entry?.isFile() || entry.isSymbolicLink()) continue + const text = await readFile(filePath, 'utf8') + const result = await formatSource(text, { ...options, filePath }) + if (result.diagnostics.length) { + for (const diagnostic of result.diagnostics) + errors.push({ filePath, message: diagnostic.message }) + continue + } + if (!result.changed) continue + if (options.write) { + if ((await readFile(filePath, 'utf8')) !== text) + throw new Error( + 'File changed during formatting; skipped to preserve your edits.', + ) + await writeFile(filePath, result.code, 'utf8') + } + changed.push(filePath) + } catch (error) { + errors.push({ + filePath, + message: error instanceof Error ? error.message : String(error), + }) + } + } + return { checked: files.size, changed, errors } +} diff --git a/packages/ts-format/src/format.test.ts b/packages/ts-format/src/format.test.ts new file mode 100644 index 00000000..e57589d2 --- /dev/null +++ b/packages/ts-format/src/format.test.ts @@ -0,0 +1,66 @@ +/* biome-ignore-all lint/suspicious/noTemplateCurlyInString: Fixtures contain literal TypeScript interpolations. */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { applySourceEdits, formatSource } from './index' + +describe('formatSource', () => { + it('formats component markup while preserving Markdown, frontmatter and fenced samples', async () => { + const source = readFileSync( + new URL( + '../../../templates/starter/template/content/guide/index.mdx', + import.meta.url, + ), + 'utf8', + ) + const result = await formatSource(source, { filePath: 'guide/index.mdx' }) + expect(result.diagnostics).toEqual([]) + const outsideMarkup = (text: string) => + text.slice(0, text.indexOf(' { + const source = + 'const untouched = 1;\nconst view = html`${value} {{ count + 1 }}`;\nconst icon = svg``;\nconst other = sql`select * from users`;' + const result = await formatSource(source, { filePath: 'view.ts' }) + expect(result.diagnostics).toEqual([]) + expect(result.code).toContain('const untouched = 1;') + expect(result.code).toContain('${value}') + expect(result.code).toContain('{{ count + 1 }}') + expect(result.code).toContain('@click="save"') + expect(result.code).toContain('') + expect(result.code).toContain('sql`select * from users`') + expect( + (await formatSource(result.code, { filePath: 'view.ts' })).changed, + ).toBe(false) + }) + + it('preserves CRLF and untouched text when applying multiple edits', async () => { + const source = + '---\r\ntitle: Test\r\n---\r\n\r\n\r\n\r\nKeep my prose.\r\n\r\n\r\n' + const result = await formatSource(source) + expect(result.code).toContain('Keep my prose.\r\n') + expect(result.code.replaceAll('\r\n', '')).not.toContain('\n') + expect((await formatSource(result.code)).changed).toBe(false) + }) + + it('does not apply overlapping replacements for nested templates', async () => { + const source = 'const view = html`${html``}`' + const result = await formatSource(source, { filePath: 'nested.ts' }) + expect(result.diagnostics).toEqual([]) + expect(result.code).toContain('${html``}') + expect(() => applySourceEdits(source, result.edits)).not.toThrow() + }) + + it('reports malformed markup without changing it', async () => { + const source = '') return index + } + + return -1 +} + +function skipWhitespace(markup: string, startIndex: number) { + let index = startIndex + while (index < markup.length && /\s/.test(markup[index])) index++ + return index +} + +function readTagNameEnd(markup: string, startIndex: number) { + let index = startIndex + while (index < markup.length && /[A-Za-z0-9._:$-]/.test(markup[index])) { + index++ + } + return index +} diff --git a/packages/ts-format/src/mdx.ts b/packages/ts-format/src/mdx.ts new file mode 100644 index 00000000..44f05984 --- /dev/null +++ b/packages/ts-format/src/mdx.ts @@ -0,0 +1,547 @@ +import { maskRegorExpressions, restoreRegorExpressions } from './expressions' +import { + buildMdxFenceDelimiterEdits, + isMdxFenceClosingDelimiter, + maskMdxFencedRegions, + parseInlineMdxFenceCloser, + parseInlineMdxFenceOpener, + parseMdxFenceDelimiter, + restoreMdxFencedRegions, +} from './fences' +import { formatHtmlFragment, normalizeSelfClosingTagSpacing } from './html' +import { + findMdxExpressionEnd, + findTagEnd, + isMdxExpressionStart, + scanTagTokens, + VOID_HTML_TAG_NAMES, +} from './markup' +import { + intersects, + type SourceEdit, + type SourceRange, + type SourceText, +} from './source' + +export interface MdxMarkupBlock { + content: string + range: SourceRange +} + +interface MdxExpressionPlaceholder { + placeholder: string + source: string +} + +interface MdxInlineCodePlaceholder { + placeholder: string + source: string +} + +interface MdxFormattingRequest { + onlyWithinRange?: SourceRange +} + +interface MdxMarkupBlockOptions { + includeIncomplete?: boolean +} + +interface MdxBlockScanState { + activeEndLine?: number + activeLines: string[] + activeStartLine?: number + fenceLength?: number + fenceMarker?: '`' | '~' + frontmatterHandled: boolean + inFence: boolean + inFrontmatter: boolean +} + +export async function buildMdxFormattingEdits( + document: SourceText, + request: MdxFormattingRequest = {}, +) { + const blocks = getMdxMarkupBlocks(document, { + includeIncomplete: true, + }).filter((block) => + request.onlyWithinRange + ? intersects(block.range, request.onlyWithinRange) + : true, + ) + + const edits: SourceEdit[] = [] + const blockRanges = blocks.map((block) => block.range) + + for (const block of blocks) { + const formatted = await formatMdxMarkupBlock(document, block) + if (!formatted || formatted === block.content) continue + + edits.push({ ...block.range, newText: document.preserveEol(formatted) }) + } + + edits.push( + ...buildMdxFenceDelimiterEdits(document, blockRanges, { + onlyWithinRange: request.onlyWithinRange, + }), + ) + + return edits +} + +export function getMdxMarkupBlocks( + document: SourceText, + options: MdxMarkupBlockOptions = {}, +) { + const blocks: MdxMarkupBlock[] = [] + const state: MdxBlockScanState = { + activeEndLine: undefined, + activeLines: [], + frontmatterHandled: false, + inFence: false, + inFrontmatter: false, + } + + for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { + const lineText = document.lineAt(lineIndex).text + const trimmed = lineText.trim() + + if (startMdxFrontmatter(lineIndex, trimmed, state)) { + continue + } + + if (closeMdxFrontmatter(trimmed, state)) { + continue + } + + collectActiveMarkupBlock( + blocks, + document, + lineIndex, + lineText, + trimmed, + state, + ) + } + + if ( + options.includeIncomplete && + state.activeStartLine !== undefined && + state.activeEndLine !== undefined + ) { + pushActiveBlock( + blocks, + document, + state.activeStartLine, + state.activeEndLine, + state.activeLines, + ) + } + + return blocks +} + +function pushActiveBlock( + blocks: MdxMarkupBlock[], + document: SourceText, + startLine: number, + endLine: number, + lines: string[], +) { + const start = document.lineAt(startLine).range.start + const end = document.lineAt(endLine).range.end + blocks.push({ + content: lines.join('\n'), + range: { start, end }, + }) +} + +function looksLikeMarkupBlockStart(trimmedLine: string) { + return /^<[A-Za-z][A-Za-z0-9._:$-]*/.test(trimmedLine) +} + +function isCompleteMarkupBlock(markup: string) { + const { placeholderContent } = maskMdxFencedRegions(markup) + if (!placeholderContent.includes('<')) return false + if (hasUnterminatedTag(placeholderContent)) return false + + return getUnclosedTagNames(placeholderContent).length === 0 +} + +async function formatMdxMarkupBlock( + document: SourceText, + block: MdxMarkupBlock, +) { + const formattingOptions = document.options + const { fences, placeholderContent: fencePlaceholderContent } = + maskMdxFencedRegions(block.content.trim()) + const { inlineCodeSpans, placeholderContent: inlineCodePlaceholderContent } = + maskMdxInlineCodeSpans(fencePlaceholderContent) + const { + expressions: regorExpressions, + placeholderContent: regorPlaceholderContent, + } = maskRegorExpressions(inlineCodePlaceholderContent) + const { expressions, placeholderContent } = maskMdxExpressions( + regorPlaceholderContent, + ) + if (!placeholderContent.includes('<')) return undefined + + let formatted: string + try { + formatted = await formatHtmlFragment(placeholderContent, formattingOptions) + } catch (error) { + document.report(block.range, error) + return undefined + } + + const restoredExpressions = restoreMdxExpressions( + normalizeSelfClosingTagSpacing(formatted.trim()), + expressions, + ) + const restoredRegorExpressions = restoreRegorExpressions( + restoredExpressions, + regorExpressions, + ) + const restoredInlineCode = restoreMdxInlineCodeSpans( + restoredRegorExpressions, + inlineCodeSpans, + ) + + return restoreMdxFencedRegions(restoredInlineCode, fences) +} + +function maskMdxInlineCodeSpans(source: string) { + const inlineCodeSpans: MdxInlineCodePlaceholder[] = [] + const lines = source.split('\n') + const placeholderLines = lines.map((line) => + maskMdxInlineCodeSpansInLine(line, inlineCodeSpans), + ) + + return { + inlineCodeSpans, + placeholderContent: placeholderLines.join('\n'), + } +} + +function maskMdxInlineCodeSpansInLine( + line: string, + inlineCodeSpans: MdxInlineCodePlaceholder[], +) { + let placeholderLine = '' + + for (let index = 0; index < line.length; ) { + const start = line.indexOf('`', index) + if (start === -1) { + placeholderLine += line.slice(index) + break + } + + const tickCount = countRepeatedChar(line, start, '`') + if (tickCount !== 1) { + placeholderLine += line.slice(index, start + tickCount) + index = start + tickCount + continue + } + + const end = findMdxInlineCodeEnd(line, start + 1) + if (end === -1) { + placeholderLine += line.slice(index) + break + } + + const source = line.slice(start, end + 1) + const placeholder = createMdxInlineCodePlaceholder( + inlineCodeSpans.length, + line, + start, + end + 1, + ) + inlineCodeSpans.push({ placeholder, source }) + placeholderLine += line.slice(index, start) + placeholderLine += placeholder + index = end + 1 + } + + return placeholderLine +} + +function createMdxInlineCodePlaceholder( + index: number, + line: string, + start: number, + end: number, +) { + const lineBefore = line.slice(0, start) + const lineAfter = line.slice(end) + if (lineBefore.trim().length === 0 && lineAfter.trim().length === 0) { + return `` + } + + return `PURESTACK_MDX_INLINE_CODE_${index}` +} + +function restoreMdxInlineCodeSpans( + formatted: string, + inlineCodeSpans: MdxInlineCodePlaceholder[], +) { + let restored = formatted + + for (const inlineCodeSpan of sortLongestPlaceholderFirst(inlineCodeSpans)) { + restored = restored + .split(inlineCodeSpan.placeholder) + .join(inlineCodeSpan.source) + } + + return restored +} + +export function maskMdxExpressions(source: string) { + const expressions: MdxExpressionPlaceholder[] = [] + let placeholderContent = '' + + for (let index = 0; index < source.length; ) { + const rawTextEnd = findRawTextElementEnd(source, index) + if (rawTextEnd !== undefined) { + placeholderContent += source.slice(index, rawTextEnd) + index = rawTextEnd + continue + } + + if (!isMdxExpressionStart(source, index)) { + placeholderContent += source[index] + index++ + continue + } + + const expressionEnd = findMdxExpressionEnd(source, index) + if (expressionEnd === -1) { + placeholderContent += source[index] + index++ + continue + } + + const placeholder = `PURESTACK_MDX_EXPR_${expressions.length}` + expressions.push({ + placeholder, + source: source.slice(index, expressionEnd), + }) + placeholderContent += placeholder + index = expressionEnd + } + + return { expressions, placeholderContent } +} + +function restoreMdxExpressions( + formatted: string, + expressions: MdxExpressionPlaceholder[], +) { + let restored = formatted + + for (const expression of sortLongestPlaceholderFirst(expressions)) { + restored = restored.split(expression.placeholder).join(expression.source) + } + + return restored +} + +function hasUnterminatedTag(markup: string) { + for (let index = 0; index < markup.length; index++) { + if (markup[index] !== '<') continue + + if (markup.startsWith('', index + 4) + if (commentEnd === -1) return true + index = commentEnd + 2 + continue + } + + const nextTagEnd = findTagEnd(markup, index + 1) + if (nextTagEnd === -1) return true + index = nextTagEnd + } + + return false +} + +function startMdxFrontmatter( + lineIndex: number, + trimmed: string, + state: MdxBlockScanState, +) { + if (state.frontmatterHandled || lineIndex !== 0 || trimmed !== '---') { + return false + } + + state.inFrontmatter = true + state.frontmatterHandled = true + return true +} + +function closeMdxFrontmatter(trimmed: string, state: MdxBlockScanState) { + if (!state.inFrontmatter) return false + + if (trimmed === '---') { + state.inFrontmatter = false + } + + return true +} + +function updateMdxFenceState(trimmed: string, state: MdxBlockScanState) { + const activeFence = + state.fenceMarker && state.fenceLength + ? { length: state.fenceLength, marker: state.fenceMarker } + : undefined + const delimiter = + state.inFence && activeFence + ? (parseInlineMdxFenceCloser(trimmed, activeFence) ?? + parseMdxFenceDelimiter(trimmed)) + : (parseMdxFenceDelimiter(trimmed) ?? parseInlineMdxFenceOpener(trimmed)) + if (!delimiter) return state.inFence + + if (!state.inFence) { + state.inFence = true + state.fenceLength = delimiter.length + state.fenceMarker = delimiter.marker + return true + } + + if ( + state.fenceMarker && + state.fenceLength && + isMdxFenceClosingDelimiter(delimiter, { + length: state.fenceLength, + marker: state.fenceMarker, + }) + ) { + state.inFence = false + state.fenceLength = undefined + state.fenceMarker = undefined + } + + return true +} + +function collectActiveMarkupBlock( + blocks: MdxMarkupBlock[], + document: SourceText, + lineIndex: number, + lineText: string, + trimmed: string, + state: MdxBlockScanState, +) { + if (state.activeStartLine === undefined) { + if (state.inFence) { + updateMdxFenceState(trimmed, state) + return + } + + if (!looksLikeMarkupBlockStart(trimmed)) { + if (updateMdxFenceState(trimmed, state)) return + return + } + + state.activeStartLine = lineIndex + state.activeEndLine = lineIndex + state.activeLines = [lineText] + } else { + state.activeEndLine = lineIndex + state.activeLines.push(lineText) + } + + updateMdxFenceState(trimmed, state) + if (state.inFence) return + if (!isCompleteMarkupBlock(state.activeLines.join('\n'))) return + + pushActiveBlock( + blocks, + document, + state.activeStartLine, + state.activeEndLine ?? lineIndex, + state.activeLines, + ) + state.activeEndLine = undefined + state.activeStartLine = undefined + state.activeLines = [] +} + +function getUnclosedTagNames(markup: string) { + const stack: string[] = [] + + for (const token of scanTagTokens(markup)) { + if (token.kind === 'opening') { + if (isSelfContainedTag(token.name, token.selfClosing)) continue + + stack.push(token.name) + continue + } + + closeMatchingTag(stack, token.name) + } + + return stack +} + +function isSelfContainedTag(tagName: string, selfClosing: boolean) { + return selfClosing || VOID_HTML_TAG_NAMES.has(tagName.toLowerCase()) +} + +function closeMatchingTag(stack: string[], tagName: string) { + for (let index = stack.length - 1; index >= 0; index--) { + if (stack[index] !== tagName) continue + stack.splice(index) + return + } +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function sortLongestPlaceholderFirst( + placeholders: T[], +) { + return [...placeholders].sort( + (left, right) => right.placeholder.length - left.placeholder.length, + ) +} + +function findMdxInlineCodeEnd(line: string, startIndex: number) { + for (let index = startIndex; index < line.length; index++) { + if (line[index] === '`') return index + } + + return -1 +} + +function countRepeatedChar(line: string, startIndex: number, char: string) { + let count = 0 + + while (line[startIndex + count] === char) { + count++ + } + + return count +} + +function findRawTextElementEnd(source: string, startIndex: number) { + if (source[startIndex] !== '<' || source[startIndex + 1] === '/') { + return undefined + } + + const tagEnd = findTagEnd(source, startIndex + 1) + if (tagEnd === -1) return undefined + + const openingTag = source.slice(startIndex, tagEnd + 1) + const tagName = /^<\s*(script|style)\b/i.exec(openingTag)?.[1] + if (!tagName) return undefined + + const closingTagPattern = new RegExp( + `<\\/\\s*${escapeRegExp(tagName)}\\s*>`, + 'i', + ) + const closingTagMatch = closingTagPattern.exec(source.slice(tagEnd + 1)) + if (!closingTagMatch) return undefined + + return tagEnd + 1 + closingTagMatch.index + closingTagMatch[0].length +} diff --git a/packages/ts-format/src/source.ts b/packages/ts-format/src/source.ts new file mode 100644 index 00000000..ac419e62 --- /dev/null +++ b/packages/ts-format/src/source.ts @@ -0,0 +1,122 @@ +import type { HtmlFormattingOptions } from './html' + +export interface SourceRange { + start: number + end: number +} + +export interface SourceEdit extends SourceRange { + newText: string +} + +export interface FormatDiagnostic extends SourceRange { + message: string +} + +export interface FormatOptions extends Partial { + filePath?: string + language?: 'mdx' | 'typescript' + onlyWithinRange?: SourceRange +} + +export function intersects(left: SourceRange, right: SourceRange) { + return left.start <= right.end && right.start <= left.end +} + +/** Immutable source snapshot. All edit offsets refer to this text. */ +export class SourceText { + readonly filePath: string + readonly options: HtmlFormattingOptions + readonly diagnostics: FormatDiagnostic[] = [] + private readonly lines: Array<{ text: string; range: SourceRange }> = [] + private readonly eol: '\n' | '\r\n' + + constructor( + readonly text: string, + options: FormatOptions = {}, + ) { + this.filePath = options.filePath ?? 'source.mdx' + this.options = { + printWidth: options.printWidth ?? 80, + tabSize: options.tabSize ?? 2, + useTabs: options.useTabs ?? false, + } + for (const value of [this.options.printWidth, this.options.tabSize]) { + if (!Number.isInteger(value) || value < 1) + throw new Error('Print width and tab size must be positive integers.') + } + this.eol = /\r\n/.test(text) ? '\r\n' : '\n' + let start = 0 + for (const line of text.split('\n')) { + const content = line.endsWith('\r') ? line.slice(0, -1) : line + this.lines.push({ + text: content, + range: { start, end: start + content.length }, + }) + start += line.length + 1 + } + } + + get lineCount() { + return this.lines.length + } + + lineAt(line: number) { + return this.lines[line] + } + + positionAt(offset: number) { + const bounded = Math.max(0, Math.min(offset, this.text.length)) + let low = 0 + let high = this.lines.length - 1 + while (low < high) { + const middle = Math.ceil((low + high) / 2) + if (this.lines[middle].range.start > bounded) high = middle - 1 + else low = middle + } + const line = this.lines[low] + return { + line: low, + character: Math.min(bounded, line.range.end) - line.range.start, + } + } + + offsetAt(position: { line: number; character: number }) { + const line = + this.lines[Math.max(0, Math.min(position.line, this.lines.length - 1))] + return Math.min( + line.range.end, + line.range.start + Math.max(0, position.character), + ) + } + + preserveEol(text: string) { + return text.replace(/\r?\n/g, this.eol) + } + + report(range: SourceRange, error: unknown) { + this.diagnostics.push({ + ...range, + message: error instanceof Error ? error.message : String(error), + }) + } +} + +/** Apply non-overlapping edits against their original snapshot. */ +export function applySourceEdits(source: string, edits: readonly SourceEdit[]) { + const ordered = [...edits].sort((a, b) => a.start - b.start || a.end - b.end) + let cursor = 0 + const parts: string[] = [] + for (const edit of ordered) { + if ( + edit.start < cursor || + edit.end < edit.start || + edit.end > source.length + ) + throw new Error('Formatter returned overlapping or invalid source edits.') + parts.push(source.slice(cursor, edit.start), edit.newText) + cursor = edit.end + } + parts.push(source.slice(cursor)) + return parts.join('') +} diff --git a/packages/ts-format/src/templates.ts b/packages/ts-format/src/templates.ts new file mode 100644 index 00000000..6e64a80d --- /dev/null +++ b/packages/ts-format/src/templates.ts @@ -0,0 +1,272 @@ +import type * as TypeScript from 'typescript-parser' +import ts from 'typescript-parser' +import { maskRegorExpressions, restoreRegorExpressions } from './expressions' + +import { formatHtmlFragment, normalizeSelfClosingTagSpacing } from './html' +import { + intersects, + type SourceEdit, + type SourceRange, + type SourceText, +} from './source' + +export type SupportedTemplateTagName = 'html' | 'svg' + +export interface SupportedTaggedTemplate { + content: string + contentRange: SourceRange + expressions: TemplateExpressionPlaceholder[] + interpolationRanges: Array<{ start: number; end: number }> + placeholderContent: string + tagName: SupportedTemplateTagName +} + +interface TemplateExpressionPlaceholder { + placeholder: string + source: string +} + +const SUPPORTED_TEMPLATE_TAG_NAMES: SupportedTemplateTagName[] = ['html', 'svg'] + +interface TemplateFormattingRequest { + onlyWithinRange?: SourceRange +} + +export async function buildTemplateFormattingEdits( + document: SourceText, + request: TemplateFormattingRequest = {}, +) { + const templates = getSupportedTaggedTemplates(document).filter((template) => + request.onlyWithinRange + ? intersects(template.contentRange, request.onlyWithinRange) + : true, + ) + if (templates.length === 0) return [] + + const edits: SourceEdit[] = [] + + for (const template of templates) { + const formatted = await formatTaggedTemplate(document, template) + if (!formatted || formatted === template.content) continue + + edits.push({ + ...template.contentRange, + newText: document.preserveEol(formatted), + }) + } + + return edits +} + +export function getSupportedTaggedTemplates( + document: SourceText, +): SupportedTaggedTemplate[] { + const sourceText = document.text + const sourceFile = ts.createSourceFile( + document.filePath, + sourceText, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ) + const templates: SupportedTaggedTemplate[] = [] + + visitNode(sourceFile) + return templates + + function visitNode(node: TypeScript.Node) { + if (ts.isTaggedTemplateExpression(node)) { + const template = createSupportedTaggedTemplate(node, sourceFile) + if (template) { + templates.push(template) + // The outer template owns its interpolations; never emit overlapping edits. + return + } + } + + ts.forEachChild(node, visitNode) + } +} + +export function isOffsetInsideSupportedTaggedTemplate( + sourceFile: TypeScript.SourceFile, + offset: number, +) { + let isInsideTemplate = false + + visitNode(sourceFile) + return isInsideTemplate + + function visitNode(node: TypeScript.Node) { + if (isInsideTemplate) return + if (offset < node.getStart(sourceFile) || offset >= node.getEnd()) return + + if (ts.isTaggedTemplateExpression(node)) { + const templateTagName = getSupportedTemplateTagName( + node.tag.getText(sourceFile), + ) + if (!templateTagName) { + ts.forEachChild(node, visitNode) + return + } + + const template = node.template + if ( + offset >= template.getStart(sourceFile) && + offset < template.getEnd() + ) { + isInsideTemplate = true + return + } + } + + ts.forEachChild(node, visitNode) + } +} + +function createSupportedTaggedTemplate( + node: TypeScript.TaggedTemplateExpression, + sourceFile: TypeScript.SourceFile, +) { + const tagName = getSupportedTemplateTagName(node.tag.getText(sourceFile)) + if (!tagName) return undefined + + const template = node.template + if (isLikelyPrematureRawBacktickClose(sourceFile.text, template)) { + return undefined + } + + const innerStart = template.getStart(sourceFile) + 1 + const innerEnd = template.getEnd() - 1 + if (innerEnd < innerStart) return undefined + + const contentRange = { start: innerStart, end: innerEnd } + + if (ts.isNoSubstitutionTemplateLiteral(template)) { + const content = sourceFile.text.slice(innerStart, innerEnd) + return { + content, + contentRange, + expressions: [], + interpolationRanges: [], + placeholderContent: content, + tagName, + } + } + + const expressions: TemplateExpressionPlaceholder[] = [] + const interpolationRanges: Array<{ start: number; end: number }> = [] + let placeholderContent = sliceTemplateHeadContent(template.head, sourceFile) + let currentContentOffset = placeholderContent.length + + template.templateSpans.forEach((span, index) => { + const placeholder = `PURESTACK_EXPR_${index}_${tagName.toUpperCase()}` + const interpolationEnd = span.literal.getStart(sourceFile) - innerStart + 1 + expressions.push({ + placeholder, + source: sourceFile.text.slice( + span.expression.getStart(sourceFile), + span.expression.getEnd(), + ), + }) + interpolationRanges.push({ + start: currentContentOffset, + end: interpolationEnd, + }) + placeholderContent += placeholder + const literalContent = sliceTemplateSpanLiteralContent( + span.literal, + sourceFile, + ) + placeholderContent += literalContent + currentContentOffset = interpolationEnd + literalContent.length + }) + + return { + content: sourceFile.text.slice(innerStart, innerEnd), + contentRange, + expressions, + interpolationRanges, + placeholderContent, + tagName, + } +} + +function getSupportedTemplateTagName(tagText: string) { + const normalizedTag = tagText.trim() + + for (const tagName of SUPPORTED_TEMPLATE_TAG_NAMES) { + if (normalizedTag === tagName || normalizedTag.endsWith(`.${tagName}`)) { + return tagName + } + } + + return undefined +} + +function isLikelyPrematureRawBacktickClose( + sourceText: string, + template: TypeScript.TemplateLiteral, +) { + return sourceText.startsWith('``', template.getEnd()) +} + +function sliceTemplateHeadContent( + head: TypeScript.TemplateHead, + sourceFile: TypeScript.SourceFile, +) { + return sourceFile.text.slice(head.getStart(sourceFile) + 1, head.getEnd() - 2) +} + +function sliceTemplateSpanLiteralContent( + literal: TypeScript.TemplateMiddle | TypeScript.TemplateTail, + sourceFile: TypeScript.SourceFile, +) { + const endOffset = ts.isTemplateTail(literal) ? 1 : 2 + return sourceFile.text.slice( + literal.getStart(sourceFile) + 1, + literal.getEnd() - endOffset, + ) +} + +async function formatTaggedTemplate( + document: SourceText, + template: SupportedTaggedTemplate, +) { + const formattingOptions = document.options + const source = template.placeholderContent.trim() + const { expressions: regorExpressions, placeholderContent } = + maskRegorExpressions(source) + + if (!placeholderContent.includes('<')) return undefined + + let formatted: string + try { + formatted = await formatHtmlFragment(placeholderContent, formattingOptions) + } catch (error) { + document.report(template.contentRange, error) + return undefined + } + + const restoredTemplateExpressions = restoreTemplateExpressions( + normalizeSelfClosingTagSpacing(formatted.trim()), + template.expressions, + ) + + return restoreRegorExpressions(restoredTemplateExpressions, regorExpressions) +} + +function restoreTemplateExpressions( + formatted: string, + expressions: TemplateExpressionPlaceholder[], +) { + let restored = formatted + + for (const expression of expressions) { + restored = restored + .split(expression.placeholder) + .join(`\${${expression.source}}`) + } + + return restored +} diff --git a/packages/ts-format/tsconfig.json b/packages/ts-format/tsconfig.json new file mode 100644 index 00000000..29b50ec0 --- /dev/null +++ b/packages/ts-format/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "noImplicitOverride": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "paths": { + "@purestack/*": ["../../packages/*/src"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["src/test/**", "src/**/*.test.ts", "src/**/*.spec.ts"] +} diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 571feb43..f91d11f4 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -39,7 +39,8 @@ "typescript": "^6.0.3" }, "dependencies": { - "prettier": "^3.8.1" + "@purestack/ts-format": "workspace:^", + "typescript-parser": "npm:typescript@6.0.3" }, "contributes": { "commands": [ diff --git a/packages/ts-ssg-vscode/src/formatting.ts b/packages/ts-ssg-vscode/src/formatting.ts new file mode 100644 index 00000000..7bc4b075 --- /dev/null +++ b/packages/ts-ssg-vscode/src/formatting.ts @@ -0,0 +1,41 @@ +import { + type SourceEdit, + type SourceRange, + SourceText, +} from '@purestack/ts-format' +import * as vscode from 'vscode' +import { getHtmlFormattingOptions } from './htmlFormatting' + +export function createFormattingSource(document: vscode.TextDocument) { + return new SourceText(document.getText(), { + ...getHtmlFormattingOptions(document), + filePath: document.uri.fsPath, + }) +} + +export function toSourceRange(source: SourceText, range: vscode.Range) { + return { + start: source.offsetAt(range.start), + end: source.offsetAt(range.end), + } +} + +export function toEditorRange(source: SourceText, range: SourceRange) { + const start = source.positionAt(range.start) + const end = source.positionAt(range.end) + return new vscode.Range( + new vscode.Position(start.line, start.character), + new vscode.Position(end.line, end.character), + ) +} + +export function toEditorEdits(source: SourceText, edits: SourceEdit[]) { + for (const diagnostic of source.diagnostics) { + console.warn( + `[PureStack] Skipping markup formatting for ${source.filePath}: ${diagnostic.message}`, + ) + } + return edits.map((edit) => + vscode.TextEdit.replace(toEditorRange(source, edit), edit.newText), + ) +} diff --git a/packages/ts-ssg-vscode/src/htmlFormatting.ts b/packages/ts-ssg-vscode/src/htmlFormatting.ts index 1d334f2c..33933dfb 100644 --- a/packages/ts-ssg-vscode/src/htmlFormatting.ts +++ b/packages/ts-ssg-vscode/src/htmlFormatting.ts @@ -1,31 +1,15 @@ -import htmlPlugin from 'prettier/plugins/html' -import postcssPlugin from 'prettier/plugins/postcss' -import prettier from 'prettier/standalone' import * as vscode from 'vscode' +export type { HtmlFormattingOptions } from '@purestack/ts-format' +export { + formatHtmlFragment, + getLineIndent, + normalizeSelfClosingTagSpacing, +} from '@purestack/ts-format' + const DEFAULT_PRINT_WIDTH = 80 const DEFAULT_TAB_SIZE = 2 -export interface HtmlFormattingOptions { - printWidth: number - tabSize: number - useTabs: boolean -} - -export async function formatHtmlFragment( - source: string, - options: HtmlFormattingOptions, -) { - return prettier.format(source, { - htmlWhitespaceSensitivity: 'css', - parser: 'html', - plugins: [htmlPlugin, postcssPlugin], - printWidth: options.printWidth, - tabWidth: options.tabSize, - useTabs: options.useTabs, - }) -} - export function getHtmlFormattingOptions(document: vscode.TextDocument) { const editorConfig = vscode.workspace.getConfiguration('editor', document.uri) const configuredWordWrap = Number( @@ -51,15 +35,6 @@ export function getHtmlFormattingOptions(document: vscode.TextDocument) { } } -export function getLineIndent(line: string) { - const match = /^\s*/.exec(line) - return match?.[0] ?? '' -} - -export function normalizeSelfClosingTagSpacing(formatted: string) { - return formatted.replace(/\s+\/>/g, '/>') -} - export function shouldFormatOnSave(document: vscode.TextDocument) { const editorConfig = vscode.workspace.getConfiguration('editor', document.uri) return editorConfig.get('formatOnSave', false) diff --git a/packages/ts-ssg-vscode/src/markupSupport.ts b/packages/ts-ssg-vscode/src/markupSupport.ts index 0e440315..2d8db861 100644 --- a/packages/ts-ssg-vscode/src/markupSupport.ts +++ b/packages/ts-ssg-vscode/src/markupSupport.ts @@ -1,3 +1,7 @@ +import { + findMdxExpressionEnd, + isMdxExpressionStart, +} from '@purestack/ts-format' import type * as TypeScript from 'typescript' import * as vscode from 'vscode' import runtimeTs from './typescriptRuntime' @@ -6,22 +10,14 @@ const ts = runtimeTs const SUPPORTED_TEMPLATE_TAG_NAMES = new Set(['html', 'svg']) -export const VOID_HTML_TAG_NAMES = new Set([ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]) +export { + findMdxExpressionEnd, + findTagEnd, + isMdxExpressionStart, + scanTagTokens, + type TagToken, + VOID_HTML_TAG_NAMES, +} from '@purestack/ts-format' export interface MarkupContext { contentEndOffset: number @@ -29,16 +25,6 @@ export interface MarkupContext { ignoredRanges: Array<{ end: number; start: number }> } -export interface TagToken { - end: number - kind: 'closing' | 'opening' - name: string - nameEnd: number - nameStart: number - selfClosing: boolean - start: number -} - interface MdxIgnoreScanState { fenceMarker?: string fenceStartOffset?: number @@ -96,44 +82,6 @@ export function sanitizeMarkup( return chars.join('') } -export function scanTagTokens(markup: string) { - const tokens: TagToken[] = [] - - for (let index = 0; index < markup.length; index++) { - if (markup[index] !== '<') continue - - if (markup.startsWith('', index + 4) - if (commentEnd === -1) break - index = commentEnd + 2 - continue - } - - const isClosing = markup[index + 1] === '/' - const nameStart = skipWhitespace(markup, index + (isClosing ? 2 : 1)) - const nameEnd = readTagNameEnd(markup, nameStart) - if (nameEnd === nameStart) continue - - const tagEnd = findTagEnd(markup, nameEnd) - if (tagEnd === -1) break - - const name = markup.slice(nameStart, nameEnd) - const trailing = markup.slice(nameEnd, tagEnd) - tokens.push({ - start: index, - end: tagEnd + 1, - kind: isClosing ? 'closing' : 'opening', - name, - nameStart, - nameEnd, - selfClosing: !isClosing && /\/\s*$/.test(trailing), - }) - index = tagEnd - } - - return tokens -} - export function getPendingOpeningTagAtEnd(markup: string) { for ( let index = markup.lastIndexOf('<'); @@ -407,57 +355,6 @@ export function getMdxExpressionRanges(document: vscode.TextDocument) { return ranges } -export function isMdxExpressionStart(source: string, index: number) { - return ( - source[index] === '{' && - source[index - 1] !== '{' && - source[index + 1] !== '{' - ) -} - -export function findMdxExpressionEnd(source: string, startIndex: number) { - let braceDepth = 0 - let quote: '"' | "'" | '`' | undefined - - for (let index = startIndex; index < source.length; index++) { - const current = source[index] - const previous = source[index - 1] - - if (quote) { - if (current === quote && previous !== '\\') { - quote = undefined - } else if ( - quote === '`' && - current === '$' && - source[index + 1] === '{' - ) { - const templateExpressionEnd = findMdxExpressionEnd(source, index + 1) - if (templateExpressionEnd === -1) return -1 - index = templateExpressionEnd - 1 - } - continue - } - - if (current === '"' || current === "'" || current === '`') { - quote = current - continue - } - - if (current === '{') { - braceDepth++ - continue - } - - if (current !== '}') continue - - braceDepth-- - if (braceDepth === 0) return index + 1 - if (braceDepth < 0) return -1 - } - - return -1 -} - function endsWithSelfClosingSlash(tagTail: string) { return /\/\s*$/.test(tagTail) } @@ -480,27 +377,6 @@ function isTagTailBalanced(tagTail: string) { return !quote } -export function findTagEnd(markup: string, startIndex: number) { - let quote: '"' | "'" | undefined - - for (let index = startIndex; index < markup.length; index++) { - const current = markup[index] - if (quote) { - if (current === quote && markup[index - 1] !== '\\') quote = undefined - continue - } - - if (current === '"' || current === "'") { - quote = current - continue - } - - if (current === '>') return index - } - - return -1 -} - function skipWhitespace(markup: string, startIndex: number) { let index = startIndex while (index < markup.length && /\s/.test(markup[index])) index++ diff --git a/packages/ts-ssg-vscode/src/mdxFenceFormatting.ts b/packages/ts-ssg-vscode/src/mdxFenceFormatting.ts index b09338bd..1db834a5 100644 --- a/packages/ts-ssg-vscode/src/mdxFenceFormatting.ts +++ b/packages/ts-ssg-vscode/src/mdxFenceFormatting.ts @@ -1,428 +1,43 @@ -import * as vscode from 'vscode' -import { scanTagTokens } from './markupSupport' - -export interface MdxFencePlaceholder { - placeholder: string - source: string -} - -export interface MdxFence { - length: number - marker: '`' | '~' -} - -export interface MdxFenceDelimiter extends MdxFence { - suffix: string -} - -export interface MdxFenceDelimiterEditOptions { - onlyWithinRange?: vscode.Range -} +import { + buildMdxFenceDelimiterEdits as buildSourceEdits, + SourceText, +} from '@purestack/ts-format' +import type * as vscode from 'vscode' +import { toEditorEdits, toSourceRange } from './formatting' + +export { + isMdxFenceClosingDelimiter, + type MdxFence, + type MdxFenceDelimiter, + type MdxFencePlaceholder, + maskMdxFencedRegions, + parseInlineMdxFenceCloser, + parseInlineMdxFenceOpener, + parseMdxFenceDelimiter, + restoreMdxFencedRegions, +} from '@purestack/ts-format' export function buildMdxFenceDelimiterEdits( - document: vscode.TextDocument, + document: Pick, excludedRanges: vscode.Range[], - options: MdxFenceDelimiterEditOptions = {}, -) { - const edits: vscode.TextEdit[] = [] - let activeFence: MdxFence | undefined - let frontmatterHandled = false - let inFrontmatter = false - - for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { - const line = document.lineAt(lineIndex) - const lineText = line.text - const trimmed = lineText.trim() - - if (!frontmatterHandled && lineIndex === 0 && trimmed === '---') { - frontmatterHandled = true - inFrontmatter = true - continue - } - - if (inFrontmatter) { - if (trimmed === '---') inFrontmatter = false - continue - } - - if (isLineInsideAnyRange(lineIndex, excludedRanges)) { - continue - } - - if ( - options.onlyWithinRange && - !line.range.intersection(options.onlyWithinRange) - ) { - continue - } - - if (!activeFence) { - const splitLine = splitLineBeforeInlineMdxFenceOpener(lineText) - if (splitLine) { - const replacement = [ - ...splitAdjacentMarkupTags(splitLine.before), - splitLine.fenceLine, - ].join('\n') - pushLineReplacement(edits, line.range, lineText, replacement) - - const delimiter = parseMdxFenceDelimiter(splitLine.fenceLine.trim()) - activeFence = delimiter - ? { length: delimiter.length, marker: delimiter.marker } - : undefined - continue - } - - const delimiter = parseMdxFenceDelimiter(trimmed) - if (!delimiter) continue - - pushLineReplacement( - edits, - line.range, - lineText, - trimLineStartIndent(lineText), - ) - activeFence = { length: delimiter.length, marker: delimiter.marker } - continue - } - - const splitClosingPrefix = splitLineAfterInlineMdxFenceCloser( - lineText, - activeFence, - ) - if (splitClosingPrefix) { - const replacement = [ - trimLineStartIndent(splitClosingPrefix.fenceLine), - splitClosingPrefix.after, - ].join('\n') - pushLineReplacement(edits, line.range, lineText, replacement) - activeFence = undefined - continue - } - - const splitClosingSuffix = splitLineBeforeInlineMdxFenceCloser( - lineText, - activeFence, - ) - if (splitClosingSuffix) { - const replacement = [ - splitClosingSuffix.before, - trimLineStartIndent(splitClosingSuffix.fenceLine), - ].join('\n') - pushLineReplacement(edits, line.range, lineText, replacement) - activeFence = undefined - continue - } - - const delimiter = parseMdxFenceDelimiter(trimmed) - if (!delimiter || !isMdxFenceClosingDelimiter(delimiter, activeFence)) { - continue - } - - pushLineReplacement( - edits, - line.range, - lineText, - trimLineStartIndent(lineText), - ) - activeFence = undefined - } - - return edits -} - -export function maskMdxFencedRegions(source: string) { - const fences: MdxFencePlaceholder[] = [] - const lines = splitInlineMdxFenceDelimiters(source).split('\n') - const placeholderLines: string[] = [] - - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const openingDelimiter = parseMdxFenceDelimiter(lines[lineIndex].trim()) - if (!openingDelimiter) { - placeholderLines.push(lines[lineIndex]) - continue - } - - let closingLineIndex = -1 - for ( - let candidateLineIndex = lineIndex + 1; - candidateLineIndex < lines.length; - candidateLineIndex++ - ) { - const closingDelimiter = parseMdxFenceDelimiter( - lines[candidateLineIndex].trim(), - ) - if ( - closingDelimiter && - isMdxFenceClosingDelimiter(closingDelimiter, openingDelimiter) - ) { - closingLineIndex = candidateLineIndex - break - } - } - - if (closingLineIndex === -1) { - placeholderLines.push(lines[lineIndex]) - continue - } - - const placeholder = `` - fences.push({ - placeholder, - source: normalizeMdxFenceDelimiterIndent( - lines.slice(lineIndex, closingLineIndex + 1), - ), - }) - placeholderLines.push(placeholder) - lineIndex = closingLineIndex - } - - return { fences, placeholderContent: placeholderLines.join('\n') } -} - -export function restoreMdxFencedRegions( - formatted: string, - fences: MdxFencePlaceholder[], -) { - let restored = formatted - - for (const fence of fences) { - const standalonePlaceholderPattern = new RegExp( - `^[\\t ]*${escapeRegExp(fence.placeholder)}$`, - 'gm', - ) - if (standalonePlaceholderPattern.test(restored)) { - restored = restored.replace(standalonePlaceholderPattern, fence.source) - continue - } - - restored = restored.split(fence.placeholder).join(fence.source) - } - - return restored -} - -export function parseMdxFenceDelimiter(trimmed: string) { - const match = /^(`{3,}|~{3,})(.*)$/.exec(trimmed) - if (!match) return undefined - - return { - length: match[1].length, - marker: match[1][0] as '`' | '~', - suffix: match[2], - } -} - -export function parseInlineMdxFenceOpener(trimmed: string) { - const splitLine = splitLineBeforeInlineMdxFenceOpener(trimmed) - if (!splitLine) return undefined - - return parseMdxFenceDelimiter(splitLine.fenceLine.trim()) -} - -export function parseInlineMdxFenceCloser( - trimmed: string, - activeFence: MdxFence, -) { - const splitLine = splitLineAfterInlineMdxFenceCloser(trimmed, activeFence) - if (splitLine) return parseMdxFenceDelimiter(splitLine.fenceLine.trim()) - - const trailingSplitLine = splitLineBeforeInlineMdxFenceCloser( - trimmed, - activeFence, - ) - if (!trailingSplitLine) return undefined - - return parseMdxFenceDelimiter(trailingSplitLine.fenceLine.trim()) -} - -export function isMdxFenceClosingDelimiter( - delimiter: MdxFenceDelimiter, - activeFence: MdxFence, + options: { onlyWithinRange?: vscode.Range } = {}, ) { - return ( - delimiter.marker === activeFence.marker && - delimiter.length >= activeFence.length && - delimiter.suffix.trim().length === 0 + const source = new SourceText( + Array.from( + { length: document.lineCount }, + (_, line) => document.lineAt(line).text, + ).join('\n'), ) -} - -function splitInlineMdxFenceDelimiters(source: string) { - const lines = source.split('\n') - const splitLines: string[] = [] - let activeFence: MdxFence | undefined - - for (const line of lines) { - if (!activeFence) { - const splitLine = splitLineBeforeInlineMdxFenceOpener(line) - if (splitLine) { - splitLines.push( - ...splitAdjacentMarkupTags(splitLine.before), - splitLine.fenceLine, - ) - - const delimiter = parseMdxFenceDelimiter(splitLine.fenceLine.trim()) - activeFence = delimiter - ? { length: delimiter.length, marker: delimiter.marker } - : undefined - continue - } - - splitLines.push(line) - - const delimiter = parseMdxFenceDelimiter(line.trim()) - if (!delimiter) continue - activeFence = { length: delimiter.length, marker: delimiter.marker } - continue - } - - const splitClosingLine = splitLineAfterInlineMdxFenceCloser( - line, - activeFence, - ) - if (splitClosingLine) { - splitLines.push(splitClosingLine.fenceLine, splitClosingLine.after) - activeFence = undefined - continue - } - - const splitTrailingClosingLine = splitLineBeforeInlineMdxFenceCloser( - line, - activeFence, - ) - if (splitTrailingClosingLine) { - splitLines.push( - splitTrailingClosingLine.before, - splitTrailingClosingLine.fenceLine, - ) - activeFence = undefined - continue - } - - splitLines.push(line) - - const delimiter = parseMdxFenceDelimiter(line.trim()) - if (!delimiter) continue - - if (isMdxFenceClosingDelimiter(delimiter, activeFence)) { - activeFence = undefined - } - } - - return splitLines.join('\n') -} - -function splitLineBeforeInlineMdxFenceOpener(line: string) { - if (parseMdxFenceDelimiter(line.trim())) return undefined - - const match = /^(.+?)[\t ]*((?:`{3,}|~{3,}).*)$/.exec(line) - if (!match) return undefined - - const before = match[1].trimEnd() - if (before.trim().length === 0) return undefined - - return { - before, - fenceLine: match[2], - } -} - -function splitAdjacentMarkupTags(line: string) { - const tokens = scanTagTokens(line) - if (tokens.length < 2) return [line] - - let cursor = 0 - const lines: string[] = [] - const indent = /^\s*/.exec(line)?.[0] ?? '' - - for (const token of tokens) { - if (line.slice(cursor, token.start).trim().length > 0) return [line] - - const tag = line.slice(token.start, token.end) - lines.push(lines.length === 0 ? `${indent}${tag}` : tag) - cursor = token.end - } - - if (line.slice(cursor).trim().length > 0) return [line] - - return lines -} - -function splitLineBeforeInlineMdxFenceCloser( - line: string, - activeFence: MdxFence, -) { - const pattern = new RegExp( - `^(.*?)[\\t ]*(${escapeRegExp(activeFence.marker.repeat(activeFence.length))}${escapeRegExp(activeFence.marker)}*)[\\t ]*$`, + return toEditorEdits( + source, + buildSourceEdits( + source, + excludedRanges.map((range) => toSourceRange(source, range)), + { + onlyWithinRange: options.onlyWithinRange + ? toSourceRange(source, options.onlyWithinRange) + : undefined, + }, + ), ) - const match = pattern.exec(line) - if (!match || match[1].trim().length === 0) return undefined - - const closingDelimiter = parseMdxFenceDelimiter(match[2].trim()) - if (!closingDelimiter) return undefined - if (!isMdxFenceClosingDelimiter(closingDelimiter, activeFence)) { - return undefined - } - - return { - before: match[1].trimEnd(), - fenceLine: match[2], - } -} - -function splitLineAfterInlineMdxFenceCloser( - line: string, - activeFence: MdxFence, -) { - const pattern = new RegExp( - `^([\\t ]*${escapeRegExp(activeFence.marker.repeat(activeFence.length))}${escapeRegExp(activeFence.marker)}*)(.*)$`, - ) - const match = pattern.exec(line) - if (!match || match[2].trim().length === 0) return undefined - - const closingDelimiter = parseMdxFenceDelimiter(match[1].trim()) - if (!closingDelimiter) return undefined - if (!isMdxFenceClosingDelimiter(closingDelimiter, activeFence)) { - return undefined - } - - return { - fenceLine: match[1], - after: match[2], - } -} - -function normalizeMdxFenceDelimiterIndent(lines: string[]) { - if (lines.length < 2) return lines.join('\n') - - const normalizedLines = [...lines] - normalizedLines[0] = trimLineStartIndent(normalizedLines[0]) - normalizedLines[normalizedLines.length - 1] = trimLineStartIndent( - normalizedLines[normalizedLines.length - 1], - ) - return normalizedLines.join('\n') -} - -function trimLineStartIndent(line: string) { - return line.replace(/^[\t ]+/, '') -} - -function pushLineReplacement( - edits: vscode.TextEdit[], - range: vscode.Range, - oldText: string, - newText: string, -) { - if (newText === oldText) return - - edits.push(vscode.TextEdit.replace(range, newText)) -} - -function isLineInsideAnyRange(lineIndex: number, ranges: vscode.Range[]) { - return ranges.some( - (range) => lineIndex >= range.start.line && lineIndex <= range.end.line, - ) -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } diff --git a/packages/ts-ssg-vscode/src/mdxFormatting.ts b/packages/ts-ssg-vscode/src/mdxFormatting.ts index f4272dc0..91f3f028 100644 --- a/packages/ts-ssg-vscode/src/mdxFormatting.ts +++ b/packages/ts-ssg-vscode/src/mdxFormatting.ts @@ -1,575 +1,64 @@ -import * as vscode from 'vscode' import { - formatHtmlFragment, - getHtmlFormattingOptions, - normalizeSelfClosingTagSpacing, - shouldFormatOnSave, -} from './htmlFormatting' + buildMdxFormattingEdits as buildSourceEdits, + getMdxMarkupBlocks as getSourceBlocks, +} from '@purestack/ts-format' +import type * as vscode from 'vscode' import { - findMdxExpressionEnd, - findTagEnd, - isMdxExpressionStart, - scanTagTokens, - VOID_HTML_TAG_NAMES, -} from './markupSupport' -import { - buildMdxFenceDelimiterEdits, - isMdxFenceClosingDelimiter, - maskMdxFencedRegions, - parseInlineMdxFenceCloser, - parseInlineMdxFenceOpener, - parseMdxFenceDelimiter, - restoreMdxFencedRegions, -} from './mdxFenceFormatting' -import { - maskRegorExpressions, - restoreRegorExpressions, -} from './regorExpressions' + createFormattingSource, + toEditorEdits, + toEditorRange, + toSourceRange, +} from './formatting' +import { shouldFormatOnSave } from './htmlFormatting' + +export { maskMdxExpressions } from '@purestack/ts-format' export interface MdxMarkupBlock { content: string range: vscode.Range } -interface MdxExpressionPlaceholder { - placeholder: string - source: string -} - -interface MdxInlineCodePlaceholder { - placeholder: string - source: string -} - interface MdxFormattingRequest { onlyWithinRange?: vscode.Range requireFormatOnSave?: boolean } -interface MdxMarkupBlockOptions { - includeIncomplete?: boolean -} - -interface MdxBlockScanState { - activeEndLine?: number - activeLines: string[] - activeStartLine?: number - fenceLength?: number - fenceMarker?: '`' | '~' - frontmatterHandled: boolean - inFence: boolean - inFrontmatter: boolean -} - export async function buildMdxFormattingEdits( document: vscode.TextDocument, request: MdxFormattingRequest = {}, ) { if (document.languageId !== 'mdx') return [] - if (request.requireFormatOnSave && !shouldFormatMarkupOnSave(document)) { - return [] - } - - const blocks = getMdxMarkupBlocks(document).filter((block) => - request.onlyWithinRange - ? block.range.intersection(request.onlyWithinRange) - : true, - ) - - const edits: vscode.TextEdit[] = [] - const blockRanges = blocks.map((block) => block.range) - - for (const block of blocks) { - const formatted = await formatMdxMarkupBlock(document, block) - if (!formatted || formatted === block.content) continue - - edits.push(vscode.TextEdit.replace(block.range, formatted)) - } - - edits.push( - ...buildMdxFenceDelimiterEdits(document, blockRanges, { - onlyWithinRange: request.onlyWithinRange, + if (request.requireFormatOnSave && !shouldFormatOnSave(document)) return [] + const source = createFormattingSource(document) + return toEditorEdits( + source, + await buildSourceEdits(source, { + onlyWithinRange: request.onlyWithinRange + ? toSourceRange(source, request.onlyWithinRange) + : undefined, }), ) +} - return edits +export function getMdxMarkupBlocks( + document: vscode.TextDocument, + options: { includeIncomplete?: boolean } = {}, +): MdxMarkupBlock[] { + const source = createFormattingSource(document) + return getSourceBlocks(source, options).map((block) => ({ + content: block.content, + range: toEditorRange(source, block.range), + })) } export async function formatActiveEditorMdxMarkup( editor: vscode.TextEditor, request: MdxFormattingRequest = {}, ) { + const version = editor.document.version const edits = await buildMdxFormattingEdits(editor.document, request) - if (edits.length === 0) return false - - return editor.edit((editBuilder) => { - for (const edit of edits) { - editBuilder.replace(edit.range, edit.newText) - } - }) -} - -export function getMdxMarkupBlocks( - document: vscode.TextDocument, - options: MdxMarkupBlockOptions = {}, -) { - const blocks: MdxMarkupBlock[] = [] - const state: MdxBlockScanState = { - activeEndLine: undefined, - activeLines: [], - frontmatterHandled: false, - inFence: false, - inFrontmatter: false, - } - - for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { - const lineText = document.lineAt(lineIndex).text - const trimmed = lineText.trim() - - if (startMdxFrontmatter(lineIndex, trimmed, state)) { - continue - } - - if (closeMdxFrontmatter(trimmed, state)) { - continue - } - - collectActiveMarkupBlock( - blocks, - document, - lineIndex, - lineText, - trimmed, - state, - ) - } - - if ( - options.includeIncomplete && - state.activeStartLine !== undefined && - state.activeEndLine !== undefined - ) { - pushActiveBlock( - blocks, - document, - state.activeStartLine, - state.activeEndLine, - state.activeLines, - ) - } - - return blocks -} - -function pushActiveBlock( - blocks: MdxMarkupBlock[], - document: vscode.TextDocument, - startLine: number, - endLine: number, - lines: string[], -) { - const start = new vscode.Position(startLine, 0) - const end = document.lineAt(endLine).range.end - blocks.push({ - content: lines.join('\n'), - range: new vscode.Range(start, end), + if (!edits.length || editor.document.version !== version) return false + return editor.edit((builder) => { + for (const edit of edits) builder.replace(edit.range, edit.newText) }) } - -function looksLikeMarkupBlockStart(trimmedLine: string) { - return /^<[A-Za-z][A-Za-z0-9._:$-]*/.test(trimmedLine) -} - -function isCompleteMarkupBlock(markup: string) { - const { placeholderContent } = maskMdxFencedRegions(markup) - if (!placeholderContent.includes('<')) return false - if (hasUnterminatedTag(placeholderContent)) return false - - return getUnclosedTagNames(placeholderContent).length === 0 -} - -async function formatMdxMarkupBlock( - document: vscode.TextDocument, - block: MdxMarkupBlock, -) { - const formattingOptions = getHtmlFormattingOptions(document) - const { fences, placeholderContent: fencePlaceholderContent } = - maskMdxFencedRegions(block.content.trim()) - const { inlineCodeSpans, placeholderContent: inlineCodePlaceholderContent } = - maskMdxInlineCodeSpans(fencePlaceholderContent) - const { - expressions: regorExpressions, - placeholderContent: regorPlaceholderContent, - } = maskRegorExpressions(inlineCodePlaceholderContent) - const { expressions, placeholderContent } = maskMdxExpressions( - regorPlaceholderContent, - ) - if (!placeholderContent.includes('<')) return undefined - - let formatted: string - try { - formatted = await formatHtmlFragment(placeholderContent, formattingOptions) - } catch (error) { - console.warn( - `[PureStack] Skipping MDX markup formatting for ${document.uri.fsPath}:`, - error, - ) - return undefined - } - - const restoredExpressions = restoreMdxExpressions( - normalizeSelfClosingTagSpacing(formatted.trim()), - expressions, - ) - const restoredRegorExpressions = restoreRegorExpressions( - restoredExpressions, - regorExpressions, - ) - const restoredInlineCode = restoreMdxInlineCodeSpans( - restoredRegorExpressions, - inlineCodeSpans, - ) - - return restoreMdxFencedRegions(restoredInlineCode, fences) -} - -function maskMdxInlineCodeSpans(source: string) { - const inlineCodeSpans: MdxInlineCodePlaceholder[] = [] - const lines = source.split('\n') - const placeholderLines = lines.map((line) => - maskMdxInlineCodeSpansInLine(line, inlineCodeSpans), - ) - - return { - inlineCodeSpans, - placeholderContent: placeholderLines.join('\n'), - } -} - -function maskMdxInlineCodeSpansInLine( - line: string, - inlineCodeSpans: MdxInlineCodePlaceholder[], -) { - let placeholderLine = '' - - for (let index = 0; index < line.length; ) { - const start = line.indexOf('`', index) - if (start === -1) { - placeholderLine += line.slice(index) - break - } - - const tickCount = countRepeatedChar(line, start, '`') - if (tickCount !== 1) { - placeholderLine += line.slice(index, start + tickCount) - index = start + tickCount - continue - } - - const end = findMdxInlineCodeEnd(line, start + 1) - if (end === -1) { - placeholderLine += line.slice(index) - break - } - - const source = line.slice(start, end + 1) - const placeholder = createMdxInlineCodePlaceholder( - inlineCodeSpans.length, - line, - start, - end + 1, - ) - inlineCodeSpans.push({ placeholder, source }) - placeholderLine += line.slice(index, start) - placeholderLine += placeholder - index = end + 1 - } - - return placeholderLine -} - -function createMdxInlineCodePlaceholder( - index: number, - line: string, - start: number, - end: number, -) { - const lineBefore = line.slice(0, start) - const lineAfter = line.slice(end) - if (lineBefore.trim().length === 0 && lineAfter.trim().length === 0) { - return `` - } - - return `PURESTACK_MDX_INLINE_CODE_${index}` -} - -function restoreMdxInlineCodeSpans( - formatted: string, - inlineCodeSpans: MdxInlineCodePlaceholder[], -) { - let restored = formatted - - for (const inlineCodeSpan of sortLongestPlaceholderFirst(inlineCodeSpans)) { - restored = restored - .split(inlineCodeSpan.placeholder) - .join(inlineCodeSpan.source) - } - - return restored -} - -export function maskMdxExpressions(source: string) { - const expressions: MdxExpressionPlaceholder[] = [] - let placeholderContent = '' - - for (let index = 0; index < source.length; ) { - const rawTextEnd = findRawTextElementEnd(source, index) - if (rawTextEnd !== undefined) { - placeholderContent += source.slice(index, rawTextEnd) - index = rawTextEnd - continue - } - - if (!isMdxExpressionStart(source, index)) { - placeholderContent += source[index] - index++ - continue - } - - const expressionEnd = findMdxExpressionEnd(source, index) - if (expressionEnd === -1) { - placeholderContent += source[index] - index++ - continue - } - - const placeholder = `PURESTACK_MDX_EXPR_${expressions.length}` - expressions.push({ - placeholder, - source: source.slice(index, expressionEnd), - }) - placeholderContent += placeholder - index = expressionEnd - } - - return { expressions, placeholderContent } -} - -function restoreMdxExpressions( - formatted: string, - expressions: MdxExpressionPlaceholder[], -) { - let restored = formatted - - for (const expression of sortLongestPlaceholderFirst(expressions)) { - restored = restored.split(expression.placeholder).join(expression.source) - } - - return restored -} - -function hasUnterminatedTag(markup: string) { - for (let index = 0; index < markup.length; index++) { - if (markup[index] !== '<') continue - - if (markup.startsWith('', index + 4) - if (commentEnd === -1) return true - index = commentEnd + 2 - continue - } - - const nextTagEnd = findTagEnd(markup, index + 1) - if (nextTagEnd === -1) return true - index = nextTagEnd - } - - return false -} - -function shouldFormatMarkupOnSave(document: vscode.TextDocument) { - return shouldFormatOnSave(document) -} - -function startMdxFrontmatter( - lineIndex: number, - trimmed: string, - state: MdxBlockScanState, -) { - if (state.frontmatterHandled || lineIndex !== 0 || trimmed !== '---') { - return false - } - - state.inFrontmatter = true - state.frontmatterHandled = true - return true -} - -function closeMdxFrontmatter(trimmed: string, state: MdxBlockScanState) { - if (!state.inFrontmatter) return false - - if (trimmed === '---') { - state.inFrontmatter = false - } - - return true -} - -function updateMdxFenceState(trimmed: string, state: MdxBlockScanState) { - const activeFence = - state.fenceMarker && state.fenceLength - ? { length: state.fenceLength, marker: state.fenceMarker } - : undefined - const delimiter = - state.inFence && activeFence - ? (parseInlineMdxFenceCloser(trimmed, activeFence) ?? - parseMdxFenceDelimiter(trimmed)) - : (parseMdxFenceDelimiter(trimmed) ?? parseInlineMdxFenceOpener(trimmed)) - if (!delimiter) return state.inFence - - if (!state.inFence) { - state.inFence = true - state.fenceLength = delimiter.length - state.fenceMarker = delimiter.marker - return true - } - - if ( - state.fenceMarker && - state.fenceLength && - isMdxFenceClosingDelimiter(delimiter, { - length: state.fenceLength, - marker: state.fenceMarker, - }) - ) { - state.inFence = false - state.fenceLength = undefined - state.fenceMarker = undefined - } - - return true -} - -function collectActiveMarkupBlock( - blocks: MdxMarkupBlock[], - document: vscode.TextDocument, - lineIndex: number, - lineText: string, - trimmed: string, - state: MdxBlockScanState, -) { - if (state.activeStartLine === undefined) { - if (state.inFence) { - updateMdxFenceState(trimmed, state) - return - } - - if (!looksLikeMarkupBlockStart(trimmed)) { - if (updateMdxFenceState(trimmed, state)) return - return - } - - state.activeStartLine = lineIndex - state.activeEndLine = lineIndex - state.activeLines = [lineText] - } else { - state.activeEndLine = lineIndex - state.activeLines.push(lineText) - } - - updateMdxFenceState(trimmed, state) - if (state.inFence) return - if (!isCompleteMarkupBlock(state.activeLines.join('\n'))) return - - pushActiveBlock( - blocks, - document, - state.activeStartLine, - state.activeEndLine ?? lineIndex, - state.activeLines, - ) - state.activeEndLine = undefined - state.activeStartLine = undefined - state.activeLines = [] -} - -function getUnclosedTagNames(markup: string) { - const stack: string[] = [] - - for (const token of scanTagTokens(markup)) { - if (token.kind === 'opening') { - if (isSelfContainedTag(token.name, token.selfClosing)) continue - - stack.push(token.name) - continue - } - - closeMatchingTag(stack, token.name) - } - - return stack -} - -function isSelfContainedTag(tagName: string, selfClosing: boolean) { - return selfClosing || VOID_HTML_TAG_NAMES.has(tagName.toLowerCase()) -} - -function closeMatchingTag(stack: string[], tagName: string) { - for (let index = stack.length - 1; index >= 0; index--) { - if (stack[index] !== tagName) continue - stack.splice(index) - return - } -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -} - -function sortLongestPlaceholderFirst( - placeholders: T[], -) { - return [...placeholders].sort( - (left, right) => right.placeholder.length - left.placeholder.length, - ) -} - -function findMdxInlineCodeEnd(line: string, startIndex: number) { - for (let index = startIndex; index < line.length; index++) { - if (line[index] === '`') return index - } - - return -1 -} - -function countRepeatedChar(line: string, startIndex: number, char: string) { - let count = 0 - - while (line[startIndex + count] === char) { - count++ - } - - return count -} - -function findRawTextElementEnd(source: string, startIndex: number) { - if (source[startIndex] !== '<' || source[startIndex + 1] === '/') { - return undefined - } - - const tagEnd = findTagEnd(source, startIndex + 1) - if (tagEnd === -1) return undefined - - const openingTag = source.slice(startIndex, tagEnd + 1) - const tagName = /^<\s*(script|style)\b/i.exec(openingTag)?.[1] - if (!tagName) return undefined - - const closingTagPattern = new RegExp( - `<\\/\\s*${escapeRegExp(tagName)}\\s*>`, - 'i', - ) - const closingTagMatch = closingTagPattern.exec(source.slice(tagEnd + 1)) - if (!closingTagMatch) return undefined - - return tagEnd + 1 + closingTagMatch.index + closingTagMatch[0].length -} diff --git a/packages/ts-ssg-vscode/src/regorExpressions.ts b/packages/ts-ssg-vscode/src/regorExpressions.ts index 7aa4df24..7a0ae1e3 100644 --- a/packages/ts-ssg-vscode/src/regorExpressions.ts +++ b/packages/ts-ssg-vscode/src/regorExpressions.ts @@ -1,73 +1,5 @@ -import { findMdxExpressionEnd } from './markupSupport' - -export interface RegorExpressionPlaceholder { - placeholder: string - source: string -} - -/** - * Protects Regor {{ ... }} expressions while the surrounding markup is - * formatted as HTML. The expression source is restored byte-for-byte. - */ -export function maskRegorExpressions(source: string) { - const expressions: RegorExpressionPlaceholder[] = [] - let placeholderContent = '' - let cursor = 0 - - while (cursor < source.length) { - const start = source.indexOf('{{', cursor) - if (start === -1) { - placeholderContent += source.slice(cursor) - break - } - - placeholderContent += source.slice(cursor, start) - - const end = findRegorExpressionEnd(source, start) - if (end === -1) { - // Keep incomplete input untouched rather than guessing its boundary. - placeholderContent += source.slice(start) - break - } - - const placeholder = createPlaceholder(source, expressions.length) - expressions.push({ placeholder, source: source.slice(start, end) }) - placeholderContent += placeholder - cursor = end - } - - return { expressions, placeholderContent } -} - -export function restoreRegorExpressions( - formatted: string, - expressions: RegorExpressionPlaceholder[], -) { - let restored = formatted - - for (const expression of expressions) { - restored = restored.split(expression.placeholder).join(expression.source) - } - - return restored -} - -function findRegorExpressionEnd(source: string, start: number) { - // Reuse the expression scanner already used by MDX. Starting at the first - // brace makes {{ ... }} two outer brace levels, so nested object literals, - // quoted braces and template-literal expressions remain balanced correctly. - const end = findMdxExpressionEnd(source, start) - if (end === -1) return -1 - - return source.slice(end - 2, end) === '}}' ? end : -1 -} - -function createPlaceholder(source: string, index: number) { - let placeholder = `__PURESTACK_REGOR_EXPR_${index}__` - - while (source.includes(placeholder)) { - placeholder = `_${placeholder}_` - } - - return placeholder -} +export { + maskRegorExpressions, + type RegorExpressionPlaceholder, + restoreRegorExpressions, +} from '@purestack/ts-format' diff --git a/packages/ts-ssg-vscode/src/templateFormatting.ts b/packages/ts-ssg-vscode/src/templateFormatting.ts index 7c33cd50..7e362ba3 100644 --- a/packages/ts-ssg-vscode/src/templateFormatting.ts +++ b/packages/ts-ssg-vscode/src/templateFormatting.ts @@ -1,38 +1,27 @@ -import type * as TypeScript from 'typescript' -import * as vscode from 'vscode' -import runtimeTs from './typescriptRuntime' - -const ts = runtimeTs - import { - formatHtmlFragment, - getHtmlFormattingOptions, - normalizeSelfClosingTagSpacing, - shouldFormatOnSave, -} from './htmlFormatting' + buildTemplateFormattingEdits as buildSourceEdits, + getSupportedTaggedTemplates as getSourceTemplates, + type SupportedTaggedTemplate as SourceTemplate, +} from '@purestack/ts-format' +import type * as vscode from 'vscode' import { - maskRegorExpressions, - restoreRegorExpressions, -} from './regorExpressions' - -export type SupportedTemplateTagName = 'html' | 'svg' - -export interface SupportedTaggedTemplate { - content: string + createFormattingSource, + toEditorEdits, + toEditorRange, + toSourceRange, +} from './formatting' +import { shouldFormatOnSave } from './htmlFormatting' + +export { + isOffsetInsideSupportedTaggedTemplate, + type SupportedTemplateTagName, +} from '@purestack/ts-format' + +export interface SupportedTaggedTemplate + extends Omit { contentRange: vscode.Range - expressions: TemplateExpressionPlaceholder[] - interpolationRanges: Array<{ start: number; end: number }> - placeholderContent: string - tagName: SupportedTemplateTagName } -interface TemplateExpressionPlaceholder { - placeholder: string - source: string -} - -const SUPPORTED_TEMPLATE_TAG_NAMES: SupportedTemplateTagName[] = ['html', 'svg'] - interface TemplateFormattingRequest { onlyWithinRange?: vscode.Range requireFormatOnSave?: boolean @@ -43,261 +32,36 @@ export async function buildTemplateFormattingEdits( request: TemplateFormattingRequest = {}, ) { if (document.languageId !== 'typescript') return [] - if (request.requireFormatOnSave && !shouldFormatTemplatesOnSave(document)) { - return [] - } - - const templates = getSupportedTaggedTemplates(document).filter((template) => - request.onlyWithinRange - ? template.contentRange.intersection(request.onlyWithinRange) - : true, + if (request.requireFormatOnSave && !shouldFormatOnSave(document)) return [] + const source = createFormattingSource(document) + return toEditorEdits( + source, + await buildSourceEdits(source, { + onlyWithinRange: request.onlyWithinRange + ? toSourceRange(source, request.onlyWithinRange) + : undefined, + }), ) - if (templates.length === 0) return [] - - const edits: vscode.TextEdit[] = [] - - for (const template of templates) { - const formatted = await formatTaggedTemplate(document, template) - if (!formatted || formatted === template.content) continue - - edits.push(vscode.TextEdit.replace(template.contentRange, formatted)) - } - - return edits } export function getSupportedTaggedTemplates( document: vscode.TextDocument, ): SupportedTaggedTemplate[] { - const sourceText = document.getText() - const sourceFile = ts.createSourceFile( - document.uri.fsPath, - sourceText, - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TS, - ) - const templates: SupportedTaggedTemplate[] = [] - - visitNode(sourceFile) - return templates - - function visitNode(node: TypeScript.Node) { - if (ts.isTaggedTemplateExpression(node)) { - const template = createSupportedTaggedTemplate(node, sourceFile, document) - if (template) templates.push(template) - } - - ts.forEachChild(node, visitNode) - } -} - -export function isOffsetInsideSupportedTaggedTemplate( - sourceFile: TypeScript.SourceFile, - offset: number, -) { - let isInsideTemplate = false - - visitNode(sourceFile) - return isInsideTemplate - - function visitNode(node: TypeScript.Node) { - if (isInsideTemplate) return - if (offset < node.getStart(sourceFile) || offset >= node.getEnd()) return - - if (ts.isTaggedTemplateExpression(node)) { - const templateTagName = getSupportedTemplateTagName( - node.tag.getText(sourceFile), - ) - if (!templateTagName) { - ts.forEachChild(node, visitNode) - return - } - - const template = node.template - if ( - offset >= template.getStart(sourceFile) && - offset < template.getEnd() - ) { - isInsideTemplate = true - return - } - } - - ts.forEachChild(node, visitNode) - } -} - -function createSupportedTaggedTemplate( - node: TypeScript.TaggedTemplateExpression, - sourceFile: TypeScript.SourceFile, - document: vscode.TextDocument, -) { - const tagName = getSupportedTemplateTagName(node.tag.getText(sourceFile)) - if (!tagName) return undefined - - const template = node.template - if (isLikelyPrematureRawBacktickClose(sourceFile.text, template)) { - return undefined - } - - const innerStart = template.getStart(sourceFile) + 1 - const innerEnd = template.getEnd() - 1 - if (innerEnd < innerStart) return undefined - - const contentRange = new vscode.Range( - document.positionAt(innerStart), - document.positionAt(innerEnd), - ) - - if (ts.isNoSubstitutionTemplateLiteral(template)) { - const content = sourceFile.text.slice(innerStart, innerEnd) - return { - content, - contentRange, - expressions: [], - interpolationRanges: [], - placeholderContent: content, - tagName, - } - } - - const expressions: TemplateExpressionPlaceholder[] = [] - const interpolationRanges: Array<{ start: number; end: number }> = [] - let placeholderContent = sliceTemplateHeadContent(template.head, sourceFile) - let currentContentOffset = placeholderContent.length - - template.templateSpans.forEach((span, index) => { - const placeholder = `PURESTACK_EXPR_${index}_${tagName.toUpperCase()}` - const interpolationEnd = span.literal.getStart(sourceFile) - innerStart + 1 - expressions.push({ - placeholder, - source: sourceFile.text.slice( - span.expression.getStart(sourceFile), - span.expression.getEnd(), - ), - }) - interpolationRanges.push({ - start: currentContentOffset, - end: interpolationEnd, - }) - placeholderContent += placeholder - const literalContent = sliceTemplateSpanLiteralContent( - span.literal, - sourceFile, - ) - placeholderContent += literalContent - currentContentOffset = interpolationEnd + literalContent.length - }) - - return { - content: sourceFile.text.slice(innerStart, innerEnd), - contentRange, - expressions, - interpolationRanges, - placeholderContent, - tagName, - } -} - -function getSupportedTemplateTagName(tagText: string) { - const normalizedTag = tagText.trim() - - for (const tagName of SUPPORTED_TEMPLATE_TAG_NAMES) { - if (normalizedTag === tagName || normalizedTag.endsWith(`.${tagName}`)) { - return tagName - } - } - - return undefined -} - -function isLikelyPrematureRawBacktickClose( - sourceText: string, - template: TypeScript.TemplateLiteral, -) { - return sourceText.startsWith('``', template.getEnd()) -} - -function sliceTemplateHeadContent( - head: TypeScript.TemplateHead, - sourceFile: TypeScript.SourceFile, -) { - return sourceFile.text.slice(head.getStart(sourceFile) + 1, head.getEnd() - 2) -} - -function sliceTemplateSpanLiteralContent( - literal: TypeScript.TemplateMiddle | TypeScript.TemplateTail, - sourceFile: TypeScript.SourceFile, -) { - const endOffset = ts.isTemplateTail(literal) ? 1 : 2 - return sourceFile.text.slice( - literal.getStart(sourceFile) + 1, - literal.getEnd() - endOffset, - ) -} - -async function formatTaggedTemplate( - document: vscode.TextDocument, - template: SupportedTaggedTemplate, -) { - const formattingOptions = getHtmlFormattingOptions(document) - const source = template.placeholderContent.trim() - const { expressions: regorExpressions, placeholderContent } = - maskRegorExpressions(source) - - if (!placeholderContent.includes('<')) return undefined - - let formatted: string - try { - formatted = await formatHtmlFragment(placeholderContent, formattingOptions) - } catch (error) { - console.warn( - `[PureStack] Skipping template formatting for ${document.uri.fsPath}:`, - error, - ) - return undefined - } - - const restoredTemplateExpressions = restoreTemplateExpressions( - normalizeSelfClosingTagSpacing(formatted.trim()), - template.expressions, - ) - - return restoreRegorExpressions(restoredTemplateExpressions, regorExpressions) -} - -function restoreTemplateExpressions( - formatted: string, - expressions: TemplateExpressionPlaceholder[], -) { - let restored = formatted - - for (const expression of expressions) { - restored = restored - .split(expression.placeholder) - .join(`\${${expression.source}}`) - } - - return restored -} - -function shouldFormatTemplatesOnSave(document: vscode.TextDocument) { - return shouldFormatOnSave(document) + const source = createFormattingSource(document) + return getSourceTemplates(source).map((template) => ({ + ...template, + contentRange: toEditorRange(source, template.contentRange), + })) } export async function formatActiveEditorTemplates( editor: vscode.TextEditor, request: TemplateFormattingRequest = {}, ) { - const edits = await buildTemplateFormattingEdits(editor.document, { - ...request, - }) - if (edits.length === 0) return false - - return editor.edit((editBuilder) => { - for (const edit of edits) { - editBuilder.replace(edit.range, edit.newText) - } + const version = editor.document.version + const edits = await buildTemplateFormattingEdits(editor.document, request) + if (!edits.length || editor.document.version !== version) return false + return editor.edit((builder) => { + for (const edit of edits) builder.replace(edit.range, edit.newText) }) } diff --git a/packages/ts-ssg-vscode/src/typescriptRuntime.ts b/packages/ts-ssg-vscode/src/typescriptRuntime.ts index 4d2e017c..ef9c4d4e 100644 --- a/packages/ts-ssg-vscode/src/typescriptRuntime.ts +++ b/packages/ts-ssg-vscode/src/typescriptRuntime.ts @@ -1,7 +1,7 @@ import { createRequire } from 'node:module' import * as path from 'node:path' -import bundledTs from 'typescript' import type * as TypeScript from 'typescript' +import bundledTs from 'typescript-parser' type TypeScriptRuntime = typeof TypeScript diff --git a/packages/ts-ssg-vscode/syntaxes/source.mdx.tmLanguage b/packages/ts-ssg-vscode/syntaxes/source.mdx.tmLanguage index 03bf34a2..b5edbfbb 100644 --- a/packages/ts-ssg-vscode/syntaxes/source.mdx.tmLanguage +++ b/packages/ts-ssg-vscode/syntaxes/source.mdx.tmLanguage @@ -1994,8 +1994,10 @@ #markdown-text - while - (?:^|\G)(?:[ ]{4}|\t) + + end + $ commonmark-thematic-break diff --git a/packages/ts-ssg-vscode/tsconfig.json b/packages/ts-ssg-vscode/tsconfig.json index d0fc43fc..ddeb5ecc 100644 --- a/packages/ts-ssg-vscode/tsconfig.json +++ b/packages/ts-ssg-vscode/tsconfig.json @@ -1,9 +1,13 @@ { "compilerOptions": { "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "rootDir": "src", + "module": "ESNext", + "moduleResolution": "bundler", + "rootDir": "..", + "noEmit": true, + "paths": { + "@purestack/ts-format": ["../ts-format/src/index.ts"] + }, "outDir": "dist", "strict": true, "esModuleInterop": true, diff --git a/scripts/format-markup.ts b/scripts/format-markup.ts new file mode 100644 index 00000000..56fc350e --- /dev/null +++ b/scripts/format-markup.ts @@ -0,0 +1,13 @@ +import { runFormatCli } from '../packages/purestack/src/cli/format' + +try { + process.exitCode = await runFormatCli(process.argv.slice(2), process.cwd(), { + exclude: [ + 'packages/ts-page-scripts/src/embed', + 'packages/ts-svg-icons/src', + ], + }) +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +} diff --git a/tsconfig.json b/tsconfig.json index 6ba03b95..f540ac53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "types": ["node"], "lib": ["ESNext", "DOM", "DOM.Iterable"], "paths": { + "@purestack/ts-format/files": ["./packages/ts-format/src/files.ts"], "@purestack/*": ["./packages/*/src"] } }, diff --git a/yarn.lock b/yarn.lock index d3afd6b8..cc9a7a88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1064,6 +1064,15 @@ __metadata: languageName: unknown linkType: soft +"@purestack/ts-format@workspace:^, @purestack/ts-format@workspace:packages/ts-format": + version: 0.0.0-use.local + resolution: "@purestack/ts-format@workspace:packages/ts-format" + dependencies: + prettier: "npm:^3.8.1" + typescript-parser: "npm:typescript@6.0.3" + languageName: unknown + linkType: soft + "@purestack/ts-highlight@workspace:^, @purestack/ts-highlight@workspace:packages/ts-highlight": version: 0.0.0-use.local resolution: "@purestack/ts-highlight@workspace:packages/ts-highlight" @@ -4774,11 +4783,12 @@ __metadata: version: 0.0.0-use.local resolution: "purestack-component-navigation@workspace:packages/ts-ssg-vscode" dependencies: + "@purestack/ts-format": "workspace:^" "@types/vscode": "npm:^1.138.0" "@vscode/vsce": "npm:^4.0.0" esbuild: "npm:^0.28.0" - prettier: "npm:^3.8.1" typescript: "npm:^6.0.3" + typescript-parser: "npm:typescript@6.0.3" languageName: unknown linkType: soft @@ -4803,6 +4813,7 @@ __metadata: resolution: "purestack@workspace:packages/purestack" dependencies: "@purestack/ts-components": "workspace:^" + "@purestack/ts-format": "workspace:^" "@purestack/ts-minidom": "workspace:^" "@purestack/ts-ssg": "workspace:^" "@purestack/ts-style": "workspace:^" From 55a5d695c2dd9da6fafa264a957fe995d2553093 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Fri, 23 Oct 2026 04:31:08 +0200 Subject: [PATCH 2/5] Update prettier and post-process to fix single character closing tags. --- packages/ts-css/package.json | 2 +- packages/ts-format/package.json | 2 +- packages/ts-format/src/html.test.ts | 48 +++++++++++++++++++++++++++++ packages/ts-format/src/html.ts | 30 +++++++++++++++++- packages/ts-html/package.json | 2 +- yarn.lock | 14 ++++----- 6 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 packages/ts-format/src/html.test.ts diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index 5ae8c422..7ee560bd 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -40,6 +40,6 @@ }, "dependencies": { "@purestack/ts-util": "workspace:^", - "prettier": "^3.8.1" + "prettier": "^3.9.10" } } diff --git a/packages/ts-format/package.json b/packages/ts-format/package.json index 4edf53ea..c5b0c07e 100644 --- a/packages/ts-format/package.json +++ b/packages/ts-format/package.json @@ -38,7 +38,7 @@ "LICENSE" ], "dependencies": { - "prettier": "^3.8.1", + "prettier": "^3.9.10", "typescript-parser": "npm:typescript@6.0.3" }, "scripts": { diff --git a/packages/ts-format/src/html.test.ts b/packages/ts-format/src/html.test.ts new file mode 100644 index 00000000..a6acea7a --- /dev/null +++ b/packages/ts-format/src/html.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { normalizeClosingTagSpacing } from './html' +import { formatSource } from './index' + +describe('closing tag formatting', () => { + it.each(['page.mdx', 'view.ts'])( + 'keeps closing tags together in %s and remains stable on repeated formatting', + async (filePath) => { + const markup = + 'Read the documentationStart building' + const source = filePath.endsWith('.ts') + ? `const view = html\`${markup}\`` + : markup + const options = { filePath, printWidth: 30 } + const result = await formatSource(source, options) + expect(result.diagnostics).toEqual([]) + expect(result.code).toContain('') + expect(result.code).toContain('') + expect(result.code).not.toMatch(/<\/[\w:.-]+\s+>/) + expect((await formatSource(result.code, options)).code).toBe(result.code) + }, + ) + + it('preserves whitespace between inline elements', async () => { + const source = + 'BeforeLink After' + const result = await formatSource(source, { printWidth: 25 }) + expect(result.diagnostics).toEqual([]) + expect(result.code).toContain('Before\s+ { + const source = + 'Link' + expect(normalizeClosingTagSpacing(source)).toBe( + source.replace('Link', 'Link'), + ) + }) + + it('joins closing tags inside PascalCase components named like raw-text tags', () => { + const source = + '<a>Link</a\n ></Title\n><Style><em>Note</em\n></Style><title></a\n>' + expect(normalizeClosingTagSpacing(source)).toBe( + '<a>Link</a></a\n>', + ) + }) +}) diff --git a/packages/ts-format/src/html.ts b/packages/ts-format/src/html.ts index 3e1d88de..1accffc5 100644 --- a/packages/ts-format/src/html.ts +++ b/packages/ts-format/src/html.ts @@ -2,6 +2,24 @@ import htmlPlugin from 'prettier/plugins/html' import postcssPlugin from 'prettier/plugins/postcss' import prettier from 'prettier/standalone' +const RAW_TEXT_TAG = + 'script|style|textarea|title|xmp|iframe|noembed|noframes|plaintext' +const TAG_BODY = String.raw`(?:"[^"]*"|'[^']*'|[^"'>])*>` + +/** + * Skips comments, raw text and opening tags; captures closing tags broken before `>`. + * Case-sensitive so PascalCase components such as `` are not raw text. + */ +const BROKEN_CLOSING_TAG = new RegExp( + [ + String.raw`<!--[\s\S]*?-->`, + String.raw`<(${RAW_TEXT_TAG})\b${TAG_BODY}[\s\S]*?</\1\s*>`, + String.raw`<[A-Za-z]${TAG_BODY}`, + String.raw`(</[^\s>]+)\s*\n\s*>`, + ].join('|'), + 'g', +) + export interface HtmlFormattingOptions { printWidth: number tabSize: number @@ -12,7 +30,7 @@ export async function formatHtmlFragment( source: string, options: HtmlFormattingOptions, ) { - return prettier.format(source, { + const formatted = await prettier.format(source, { htmlWhitespaceSensitivity: 'css', parser: 'html', plugins: [htmlPlugin, postcssPlugin], @@ -20,6 +38,16 @@ export async function formatHtmlFragment( tabWidth: options.tabSize, useTabs: options.useTabs, }) + return normalizeClosingTagSpacing(formatted) +} + +/** Keep closing tags readable without inserting whitespace into inline content. */ +export function normalizeClosingTagSpacing(source: string) { + return source.replace( + BROKEN_CLOSING_TAG, + (match, _rawTextTag: string, closingTag?: string) => + closingTag ? `${closingTag}>` : match, + ) } export function getLineIndent(line: string) { diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index ed1c655c..a926e6a3 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -40,6 +40,6 @@ }, "dependencies": { "@purestack/ts-util": "workspace:^", - "prettier": "^3.8.1" + "prettier": "^3.9.10" } } diff --git a/yarn.lock b/yarn.lock index cc9a7a88..19341f3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1060,7 +1060,7 @@ __metadata: resolution: "@purestack/ts-css@workspace:packages/ts-css" dependencies: "@purestack/ts-util": "workspace:^" - prettier: "npm:^3.8.1" + prettier: "npm:^3.9.10" languageName: unknown linkType: soft @@ -1068,7 +1068,7 @@ __metadata: version: 0.0.0-use.local resolution: "@purestack/ts-format@workspace:packages/ts-format" dependencies: - prettier: "npm:^3.8.1" + prettier: "npm:^3.9.10" typescript-parser: "npm:typescript@6.0.3" languageName: unknown linkType: soft @@ -1089,7 +1089,7 @@ __metadata: resolution: "@purestack/ts-html@workspace:packages/ts-html" dependencies: "@purestack/ts-util": "workspace:^" - prettier: "npm:^3.8.1" + prettier: "npm:^3.9.10" languageName: unknown linkType: soft @@ -4735,12 +4735,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.8.1": - version: 3.8.1 - resolution: "prettier@npm:3.8.1" +"prettier@npm:^3.9.10": + version: 3.9.10 + resolution: "prettier@npm:3.9.10" bin: prettier: bin/prettier.cjs - checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 + checksum: 10c0/0b3531593aa6fb36ac6baf629cb058bda472c59a6938774f931fe55283a07c62e6f56e621b043a82d8a3e21fc284c08c34f9e539fc70072635f79b7bae1a1067 languageName: node linkType: hard From 56cb7e8aef26996dfee08903254d43acccd88b4d Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Fri, 23 Oct 2026 04:45:33 +0200 Subject: [PATCH 3/5] Format entire repo with ts-format. --- .../components/landing/feature-showcase.ts | 3 +- .../components/landing/regor-relationship.ts | 26 +- .../components/landing/source-workbench.ts | 9 +- frontend/components/shared/copy-button.ts | 3 +- frontend/components/shell/palette-picker.ts | 27 +- frontend/designs/social-preview.svg | 1 + frontend/docs/apiProperty.ts | 3 +- frontend/docs/componentGuide.ts | 34 +- frontend/docs/dataGuide.ts | 72 +- frontend/docs/formAppearance.ts | 107 +- frontend/docs/landingGuide.ts | 194 ++- frontend/docs/modal.ts | 6 +- frontend/docs/semanticToneGuide.ts | 69 +- frontend/docs/sitePreviewComponents.ts | 7 +- frontend/docs/sitePreviews.ts | 8 +- .../actions/alert-box/alert-box.mdx | 398 ++++-- .../actions/badge/badge-playground.ts | 12 +- .../components/actions/badge/badge.mdx | 224 +-- .../actions/badge/release-checklist.ts | 21 +- .../btn-group-drop-down.mdx | 991 ++++++++++--- .../dropdown-playground.ts | 88 +- .../btn-group-drop-down/publish-actions.ts | 35 +- .../actions/btn-group/btn-group.mdx | 289 ++-- .../actions/btn-group/formatting-controls.ts | 14 +- .../actions/btn-group/group-playground.ts | 28 +- .../actions/btn-group/selection-toolbar.ts | 48 +- .../components/actions/btn-link/btn-link.mdx | 444 ++++-- .../actions/btn-link/link-playground.ts | 61 +- .../components/actions/buttons/buttons.mdx | 198 +-- .../components/actions/buttons/collection.ts | 10 +- .../components/actions/index.mdx | 121 +- .../actions/toast-host/playground.ts | 22 +- .../actions/toast-host/toast-host.mdx | 144 +- .../components/data/bar-chart/bar-chart.mdx | 427 ++++-- .../components/data/bar-chart/playground.ts | 92 +- .../data/doughnut-chart/doughnut-chart.mdx | 706 ++++++--- .../data/doughnut-chart/playground.ts | 160 ++- .../components/data/index.mdx | 206 ++- .../components/data/line-chart/line-chart.mdx | 585 +++++--- .../components/data/line-chart/playground.ts | 122 +- .../data/variable-virtual-list/basic-list.ts | 22 +- .../data/variable-virtual-list/playground.ts | 15 +- .../variable-virtual-list.mdx | 64 +- .../variable-virtual-table/basic-table.ts | 43 +- .../data/variable-virtual-table/playground.ts | 20 +- .../variable-virtual-table.mdx | 221 ++- .../data/virtual-list/basic-list.ts | 20 +- .../data/virtual-list/persistent-state.ts | 52 +- .../data/virtual-list/playground.ts | 103 +- .../data/virtual-list/virtual-list.mdx | 194 ++- .../data/virtual-table/basic-table.ts | 51 +- .../data/virtual-table/playground.ts | 27 +- .../data/virtual-table/virtual-table.mdx | 232 ++- .../components/forms/app-form/app-form.mdx | 161 ++- .../components/forms/app-form/playground.ts | 7 +- .../auto-complete-input.mdx | 335 ++++- .../forms/auto-complete-input/custom-row.ts | 5 +- .../forms/auto-complete-input/playground.ts | 28 +- .../auto-complete-option-row.mdx | 153 +- .../forms/auto-complete-option-row/states.ts | 5 +- .../components/forms/composer/composer.mdx | 87 +- .../forms/composer/draft-workflow.ts | 36 +- .../forms/composer/inline-images.ts | 61 +- .../components/forms/composer/playground.ts | 72 +- .../forms/contact-form/contact-form.mdx | 216 ++- .../forms/contact-form/playground.ts | 8 +- .../forms/drop-files/drop-files.mdx | 255 +++- .../components/forms/drop-files/playground.ts | 36 +- .../form-assist-link/form-assist-link.mdx | 158 +- .../forms/form-check/form-check.mdx | 243 +++- .../components/forms/form-check/playground.ts | 14 +- .../forms/form-divider/form-divider.mdx | 125 +- .../form-input-field/form-input-field.mdx | 178 ++- .../forms/form-input-field/playground.ts | 10 +- .../components/forms/form-meta/form-meta.mdx | 106 +- .../form-select-field/form-select-field.mdx | 351 +++-- .../forms/form-select-field/playground.ts | 7 +- .../forms/form-status/form-status.mdx | 165 ++- .../forms/form-status/playground.ts | 2 +- .../forms/form-submit/form-submit.mdx | 148 +- .../forms/form-submit/playground.ts | 7 +- .../components/forms/index.mdx | 283 +++- .../multi-auto-complete-input.mdx | 413 +++++- .../multi-auto-complete-input/playground.ts | 61 +- .../forms/multi-auto-complete-input/states.ts | 5 +- .../multi-auto-complete-option-row.mdx | 160 ++- .../multi-auto-complete-option-row/states.ts | 5 +- .../purestack.studio/components/index.mdx | 287 +--- .../landing/code-showcase/code-showcase.mdx | 225 ++- .../comparison-column/comparison-column.mdx | 219 ++- .../comparison-feature/comparison-feature.mdx | 154 +- .../comparison-table/comparison-table.mdx | 276 +++- .../landing/cta-section/cta-section.mdx | 273 +++- .../landing/feature-card/feature-card.mdx | 242 +++- .../components/landing/index.mdx | 394 ++++- .../landing/landing-band/landing-band.mdx | 565 ++++++-- .../landing-section/landing-section.mdx | 325 ++++- .../landing/metric-item/metric-item.mdx | 175 ++- .../landing/metric-strip/metric-strip.mdx | 236 ++- .../pricing-feature/pricing-feature.mdx | 169 ++- .../landing/pricing-plan/pricing-plan.mdx | 297 +++- .../landing/pricing-table/pricing-table.mdx | 393 +++-- .../expandable-panel/expandable-panel.mdx | 686 ++++++--- .../components/layout/flex/flex.mdx | 686 ++++++--- .../components/layout/flex/playground.ts | 256 +++- .../components/layout/grid/grid.mdx | 475 ++++-- .../components/layout/grid/playground.ts | 202 ++- .../layout/icon-frame/icon-frame.mdx | 484 +++++-- .../components/layout/icon/icon.mdx | 199 ++- .../components/layout/index.mdx | 222 ++- .../layout/modal-trigger/modal-trigger.mdx | 448 ++++-- .../components/layout/modal/modal.mdx | 945 +++++++++--- .../components/layout/panel/panel.mdx | 531 ++++--- .../layout/section-header/section-header.mdx | 325 +++-- .../components/layout/tab-pane/tab-pane.mdx | 1271 ++++++++++++++--- .../components/layout/tabs/composition.ts | 66 +- .../components/layout/tabs/overflow.ts | 38 +- .../components/layout/tabs/playground.ts | 137 +- .../layout/tabs/release-workspace.ts | 61 +- .../components/layout/tabs/tabs.mdx | 1107 +++++++++++--- .../components/runtime/index.mdx | 114 +- .../runtime/page-script/page-script.mdx | 532 ++++++- .../runtime/regor-app/playground.ts | 48 +- .../runtime/regor-app/regor-app.mdx | 133 +- .../site/classic-logo/classic-logo.mdx | 185 ++- .../site/classic-logo/playground.ts | 42 +- .../components/site/consent/consent.mdx | 147 +- .../components/site/consent/preview.mdx | 8 +- .../components/site/index.mdx | 192 ++- .../components/site/nav-list/nav-list.mdx | 117 +- .../site/nav-menu/nav-menu-playground.ts | 49 +- .../components/site/nav-menu/nav-menu.mdx | 187 ++- .../components/site/page-links/page-links.mdx | 106 +- .../components/site/page-toc/page-toc.mdx | 135 +- .../components/site/page-toc/preview.mdx | 2 +- .../components/site/search-box/search-box.mdx | 135 +- .../components/site/sign-in/preview.mdx | 10 +- .../components/site/sign-in/sign-in.mdx | 180 ++- .../site/site-footer/site-footer.mdx | 214 ++- .../components/site/site-logo/appearances.ts | 71 +- .../components/site/site-logo/playground.ts | 85 +- .../components/site/site-logo/site-logo.mdx | 581 ++++++-- .../site/theme-toggle/theme-toggle.mdx | 98 +- .../components/site/top-bar/preview.mdx | 6 +- .../components/site/top-bar/top-bar.mdx | 77 +- frontend/purestack.studio/footer.mdx | 39 +- .../extending/regor/_regor-frontmatter.mdx | 12 +- .../extending/regor/regor-launch-checklist.ts | 16 +- .../guides/extending/regor/regor.mdx | 12 +- .../guides/getting-started/purestack-cli.mdx | 110 +- frontend/purestack.studio/guides/index.mdx | 28 +- .../purestack.studio/guides/styling/icons.mdx | 95 +- .../guides/styling/semantic-tones.mdx | 21 +- .../styling/semantic-tones/playground.ts | 58 +- .../guides/styling/typography.mdx | 20 +- frontend/purestack.studio/header.mdx | 6 +- frontend/purestack.studio/index.mdx | 173 +-- packages/purestack/forge/footer.mdx | 6 +- packages/purestack/forge/header.mdx | 2 +- packages/purestack/forge/index.mdx | 4 +- .../modules/components/creation-panel.ts | 12 +- .../modules/components/creation-progress.ts | 17 +- .../modules/components/creation-result.ts | 29 +- .../forge/modules/components/dashboard.ts | 10 +- .../forge/modules/components/navigation.ts | 34 +- .../forge/modules/components/next-steps.ts | 12 +- .../forge/modules/components/option-field.ts | 24 +- .../forge/modules/components/options-step.ts | 30 +- .../forge/modules/components/review-step.ts | 23 +- .../forge/modules/components/setup-step.ts | 34 +- .../forge/modules/components/site-step.ts | 31 +- .../forge/modules/components/step-actions.ts | 10 +- .../forge/modules/components/step.ts | 6 +- .../forge/modules/components/template-card.ts | 23 +- .../modules/components/templates-step.ts | 36 +- .../src/standard/alertBox/alertBox.ts | 3 +- .../src/standard/composer/composer.ts | 3 +- .../src/standard/contactForm/contactForm.ts | 10 +- .../src/standard/dropFiles/dropFiles.ts | 4 +- .../expandablePanel/expandablePanel.ts | 3 +- .../src/standard/form/autoCompleteInput.ts | 14 +- .../ts-components/src/standard/form/form.ts | 3 +- .../src/standard/form/formCheck.ts | 3 +- .../src/standard/form/formSelectField.ts | 8 +- .../standard/form/multiAutoCompleteInput.ts | 11 +- .../src/standard/landing/codeShowcase.ts | 64 +- .../src/standard/landing/comparisonColumn.ts | 28 +- .../src/standard/landing/comparisonFeature.ts | 14 +- .../src/standard/landing/comparisonTable.ts | 36 +- .../src/standard/landing/ctaSection.ts | 68 +- .../src/standard/landing/featureCard.ts | 40 +- .../src/standard/landing/landingBand.ts | 4 +- .../src/standard/landing/landingSection.ts | 38 +- .../src/standard/landing/metricItem.ts | 28 +- .../src/standard/landing/metricStrip.ts | 16 +- .../ts-components/src/standard/logo/logo.ts | 10 +- .../src/standard/modal/modalStore.ts | 8 +- .../src/standard/toast/toastHost.ts | 12 +- .../src/standard/virtualList/virtualTable.ts | 2 +- packages/ts-format/README.md | 5 +- packages/ts-format/src/html.ts | 4 +- packages/ts-ssg-vscode/assets/file-icon.svg | 1 + .../ts-ssg-vscode/src/frontmatterSupport.ts | 2 +- .../ts-ssg-vscode/src/templateAutoClose.ts | 2 +- .../ts-ssg-vscode/src/templateDiagnostics.ts | 2 +- packages/ts-ssg/sample-content/about.mdx | 21 +- packages/ts-ssg/sample-content/contact.mdx | 61 +- packages/ts-ssg/sample-content/footer.mdx | 5 +- .../sample-content/guide/components.mdx | 39 +- packages/ts-ssg/sample-content/guide/data.mdx | 19 +- .../ts-ssg/sample-content/guide/forms.mdx | 9 +- .../ts-ssg/sample-content/guide/index.mdx | 68 +- .../sample-content/guide/interactions.mdx | 45 +- .../sample-content/guide/launch-checklist.mdx | 3 +- packages/ts-ssg/sample-content/index.mdx | 408 +++++- packages/ts-ssg/sample-content/plans.mdx | 6 +- packages/ts-ssg/sample-content/platform.mdx | 128 +- packages/ts-ssg/sample-content/workspace.mdx | 88 +- templates/starter/template/content/about.mdx | 45 +- templates/starter/template/content/footer.mdx | 7 +- .../guide/checklist/checklist-component.ts | 31 +- .../content/guide/checklist/checklist.mdx | 32 +- .../starter/template/content/guide/index.mdx | 161 ++- templates/starter/template/content/header.mdx | 14 +- .../landing/foundation/feature-card.ts | 17 +- .../landing/foundation/foundation.ts | 11 +- .../modules/components/landing/hero/canvas.ts | 11 +- .../modules/components/landing/hero/hero.ts | 10 +- .../modules/components/landing/hero/intro.ts | 24 +- .../components/landing/hero/message.ts | 5 +- .../modules/components/landing/next-step.ts | 21 +- .../modules/components/shell/footer/brand.ts | 3 +- .../modules/components/shell/footer/footer.ts | 8 +- .../components/shell/footer/navigation.ts | 10 +- .../components/shell/footer/start-card.ts | 9 +- 235 files changed, 22516 insertions(+), 6990 deletions(-) diff --git a/frontend/components/landing/feature-showcase.ts b/frontend/components/landing/feature-showcase.ts index a5be8899..d933083f 100644 --- a/frontend/components/landing/feature-showcase.ts +++ b/frontend/components/landing/feature-showcase.ts @@ -38,8 +38,7 @@ const featureShowcaseTemplate = html`<Panel variant="link" icon="tabler:arrow-up-right" iconPosition="end" - >{{ linkLabel }}</BtnLink - > + >{{ linkLabel }}</BtnLink> </Panel>` export function defineFeatureShowcaseComponent() { diff --git a/frontend/components/landing/regor-relationship.ts b/frontend/components/landing/regor-relationship.ts index adf83274..75a0f353 100644 --- a/frontend/components/landing/regor-relationship.ts +++ b/frontend/components/landing/regor-relationship.ts @@ -7,13 +7,24 @@ export interface RegorRelationship { const regorRelationshipTemplate = html`<figure class="regor-connection"> <div class="regor-loop"> - <svg class="regor-loop-path" viewBox="0 0 360 220" preserveAspectRatio="none" fill="none" aria-hidden="true"> - <path class="regor-loop-ui" d="M80 62 V60 Q80 35 105 35 H255 Q280 35 280 54"/> + <svg + class="regor-loop-path" + viewBox="0 0 360 220" + preserveAspectRatio="none" + fill="none" + aria-hidden="true" + > + <path + class="regor-loop-ui" + d="M80 62 V60 Q80 35 105 35 H255 Q280 35 280 54"/> <path class="regor-loop-ui" d="M275 48 L280 55 L285 48"/> - <path class="regor-loop-docs" d="M280 160 Q280 185 255 185 H105 Q80 185 80 166"/> + <path + class="regor-loop-docs" + d="M280 160 Q280 185 255 185 H105 Q80 185 80 166"/> <path class="regor-loop-docs" d="M75 172 L80 165 L85 172"/> </svg> - <span class="regor-loop-label regor-loop-label--ui">Reactive UI <Icon name="lucide:arrow-right"/></span> + <span class="regor-loop-label regor-loop-label--ui" + >Reactive UI <Icon name="lucide:arrow-right"/></span> <div class="regor-loop-node regor-loop-node--regor"> <span class="regor-loop-mark"><Icon name="lucide:orbit"/></span> <strong>Regor</strong> @@ -22,9 +33,12 @@ const regorRelationshipTemplate = html`<figure class="regor-connection"> <span class="regor-loop-mark"><Icon name="tabler:stack-2"/></span> <strong>PureStack</strong> </div> - <span class="regor-loop-label regor-loop-label--docs"><Icon name="lucide:arrow-left"/>Documentation</span> + <span class="regor-loop-label regor-loop-label--docs" + ><Icon name="lucide:arrow-left"/>Documentation</span> </div> - <figcaption>{{ uiCaption }}<br/>{{ docsCaption }}</figcaption> + <figcaption> + {{ uiCaption }}<br/>{{ docsCaption }} + </figcaption> </figure>` export function defineRegorRelationshipComponent() { diff --git a/frontend/components/landing/source-workbench.ts b/frontend/components/landing/source-workbench.ts index fa89e4f5..f6cb2d5e 100644 --- a/frontend/components/landing/source-workbench.ts +++ b/frontend/components/landing/source-workbench.ts @@ -23,14 +23,11 @@ const sourceWorkbenchTemplate = html`<Panel tabVariant="none" > <TabPane id="content" label="index.mdx" icon="tabler:file-code" - ><slot name="content"></slot - ></TabPane> + ><slot name="content"></slot></TabPane> <TabPane id="interactive" label="counter.ts" icon="tabler:braces" - ><slot name="interactive"></slot - ></TabPane> + ><slot name="interactive"></slot></TabPane> <TabPane id="style" label="styles.ts" icon="tabler:palette" - ><slot name="style"></slot - ></TabPane> + ><slot name="style"></slot></TabPane> </Tabs> <Flex class="workbench-status" align="center" justify="between"> <span><Icon name="tabler:check"/>Same source. Connected workflow.</span> diff --git a/frontend/components/shared/copy-button.ts b/frontend/components/shared/copy-button.ts index a268e3cd..c2aa83a9 100644 --- a/frontend/components/shared/copy-button.ts +++ b/frontend/components/shared/copy-button.ts @@ -15,8 +15,7 @@ const copyButtonTemplate = html`<Btn tone="neutral" variant="subtle" icon="tabler:copy" - >Copy</Btn ->` + >Copy</Btn>` export function defineCopyButtonComponent() { return defineComponent<CopyButton>(copyButtonTemplate, { diff --git a/frontend/components/shell/palette-picker.ts b/frontend/components/shell/palette-picker.ts index 482aec11..3291cd5e 100644 --- a/frontend/components/shell/palette-picker.ts +++ b/frontend/components/shell/palette-picker.ts @@ -4,27 +4,34 @@ import { palettePresets } from '../../theme/presets' export interface PalettePicker {} const template = html`<details class="palette-picker"> - <summary class="palette-picker__trigger" aria-label="Site color" title="Choose site color"> + <summary + class="palette-picker__trigger" + aria-label="Site color" + title="Choose site color" + > <Icon name="tabler:palette" aria-hidden="true"/> </summary> <fieldset class="palette-picker__panel"> <legend>Site color</legend> <p>Choose your site’s color palette.</p> - <div class="palette-picker__options"> - ${palettePresets.map(renderColorOption).join('')} - </div> + <div class="palette-picker__options">${palettePresets.map(renderColorOption).join('')}</div> <span class="palette-picker__note">Saved on this device.</span> </fieldset> -</details><PageScript src="/modules/palette-picker.ts"/>` +</details> +<PageScript src="/modules/palette-picker.ts"/>` export function definePalettePickerComponent() { return defineComponent<PalettePicker>(template) } function renderColorOption(preset: (typeof palettePresets)[number]) { - return html`<label class="palette-picker__option" data-site-color-option="${preset.id}"> - <input type="radio" name="site-palette" value="${preset.id}"/> - <span class="palette-picker__swatch" aria-hidden="true"><Icon name="tabler:check"/></span> - <span>${preset.label}</span> - </label>` + return html`<label + class="palette-picker__option" + data-site-color-option="${preset.id}" +> + <input type="radio" name="site-palette" value="${preset.id}"/> + <span class="palette-picker__swatch" aria-hidden="true" + ><Icon name="tabler:check"/></span> + <span>${preset.label}</span> +</label>` } diff --git a/frontend/designs/social-preview.svg b/frontend/designs/social-preview.svg index bbac5421..3a49dff3 100644 --- a/frontend/designs/social-preview.svg +++ b/frontend/designs/social-preview.svg @@ -1,4 +1,5 @@ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"> + <title>PureStack: One language. A whole frontend. diff --git a/frontend/docs/apiProperty.ts b/frontend/docs/apiProperty.ts index f1f6ca44..1f1ecfe2 100644 --- a/frontend/docs/apiProperty.ts +++ b/frontend/docs/apiProperty.ts @@ -11,8 +11,7 @@ const apiPropertyTemplate = html`

{{ name }} + >{{ name }}

{{ valueType }}
diff --git a/frontend/docs/componentGuide.ts b/frontend/docs/componentGuide.ts index 09316054..3993b781 100644 --- a/frontend/docs/componentGuide.ts +++ b/frontend/docs/componentGuide.ts @@ -24,16 +24,20 @@ export function defineComponentGuideComponents() { return { ComponentHeader: defineComponent( html`
-
-
{{ name }}{{ kind }}
- -
-

{{ summary }}

- {{ section }} -
`, +
+
+ {{ name }}{{ kind }} +
+ +
+

{{ summary }}

+ {{ section }} +`, { props: ['name', 'summary', 'source', 'kind', 'section', 'sectionHref'], context: (head) => ({ @@ -45,10 +49,12 @@ export function defineComponentGuideComponents() { ), ComponentExample: defineComponent( html`
-
{{ label }}COMPOSITION
-
-

{{ caption }}

-
`, +
+ {{ label }}COMPOSITION +
+
+

{{ caption }}

+
`, { props: ['label', 'caption'] }, ), } diff --git a/frontend/docs/dataGuide.ts b/frontend/docs/dataGuide.ts index 022a2bed..662ec4d8 100644 --- a/frontend/docs/dataGuide.ts +++ b/frontend/docs/dataGuide.ts @@ -76,11 +76,29 @@ export interface ChartAppearanceControls { export function defineChartAppearanceControls() { return defineComponent( html` - - - - - `, + + + + +`, { props: ['tone', 'variant', 'variantMode', 'width', 'showWidth', 'prefix'], context: (head) => ({ @@ -104,13 +122,43 @@ export interface ChartAppearanceGallery { export function defineChartAppearanceGallery() { return defineComponent( html``, +
+ {{ sample.label }} + + + +
+`, { props: ['component', 'axis'], context: (head) => { diff --git a/frontend/docs/formAppearance.ts b/frontend/docs/formAppearance.ts index 0099424a..f66102ee 100644 --- a/frontend/docs/formAppearance.ts +++ b/frontend/docs/formAppearance.ts @@ -44,26 +44,103 @@ const inputTypes = [ 'week', ] -const template = html`
+const template = html`
{{ sample.label }} - - + + - {{ sample.receipt }} + {{ sample.receipt }} - - - -
- - {{ sample.receipt }} + + + + + + {{ sample.receipt }} - Your draft is ready for review. - - - - + Your draft is ready for review. + + + +
` diff --git a/frontend/docs/landingGuide.ts b/frontend/docs/landingGuide.ts index 18dc4212..9122c127 100644 --- a/frontend/docs/landingGuide.ts +++ b/frontend/docs/landingGuide.ts @@ -32,23 +32,183 @@ export interface LandingAppearanceGallery { export function defineLandingAppearanceGallery() { return defineComponent( html``, +
+ {{ sample.label }} +
+ A new chapter +
+
+ Ready to compose +
+
+ +
+
+ +
+
+ +
+
+ +
<Badge>Ready</Badge>
+
+
+
+
    + Reusable patterns +
+
+
+
    + Reusable patterns +
+
+
+
    + One shared theme
    Across every screen.
    +
+
+
+ +
+
+ Theme-aware components +
+
+ Theme-aware components +
+
+
    + Theme-aware components +
+
+
+
`, { props: ['component', 'axis'], context: (head) => { diff --git a/frontend/docs/modal.ts b/frontend/docs/modal.ts index 0176fa9d..1eb5af9f 100644 --- a/frontend/docs/modal.ts +++ b/frontend/docs/modal.ts @@ -26,16 +26,14 @@ const modalApiTemplate = html` >

This dialog was opened by a Regor event handler.

+ >Close with the API
` const modalStoreTemplate = html` Review a saved draft + >Review a saved draft

{{ result }}

` diff --git a/frontend/docs/semanticToneGuide.ts b/frontend/docs/semanticToneGuide.ts index 3980b6ab..06db7bf0 100644 --- a/frontend/docs/semanticToneGuide.ts +++ b/frontend/docs/semanticToneGuide.ts @@ -56,21 +56,60 @@ export interface SemanticToneGallery { export function defineSemanticToneGallery() { return defineComponent( html``, +
+ {{ sample.label }} +
+ {{ sample.description }} +

One palette. Several useful roles.

+
+ Alternate surface +
+ Button role +
+
+ {{ sample.description }} +
+ Inset accent +
+
+ Shared light +
+ Glass follows the same origin +
+
+
+ +
+

{{ sample.note }}

+
+`, { props: ['axis'], context: (head) => { diff --git a/frontend/docs/sitePreviewComponents.ts b/frontend/docs/sitePreviewComponents.ts index 3820883d..b0c2902d 100644 --- a/frontend/docs/sitePreviewComponents.ts +++ b/frontend/docs/sitePreviewComponents.ts @@ -32,7 +32,12 @@ export interface TopBarPreview { frameStyle: string } -const sitePreviewTemplate = html`` +const sitePreviewTemplate = html`` function resolveSitePreview(site: SiteConfig, slug: string, height: number) { return { diff --git a/frontend/docs/sitePreviews.ts b/frontend/docs/sitePreviews.ts index d3bcfe1b..2a0f96db 100644 --- a/frontend/docs/sitePreviews.ts +++ b/frontend/docs/sitePreviews.ts @@ -181,7 +181,13 @@ export interface SiteAppearanceGallery { export function defineSiteAppearanceGallery(site: SiteConfig) { return defineComponent( - html``, + html``, { props: ['component', 'axis'], context: (head) => ({ diff --git a/frontend/purestack.studio/components/actions/alert-box/alert-box.mdx b/frontend/purestack.studio/components/actions/alert-box/alert-box.mdx index d40cd231..809fa569 100644 --- a/frontend/purestack.studio/components/actions/alert-box/alert-box.mdx +++ b/frontend/purestack.studio/components/actions/alert-box/alert-box.mdx @@ -13,12 +13,37 @@ nav: # AlertBox - + ## In context - -

Explain what happened, why it matters, and what the reader can do next.

+ +
+

+ Explain what happened, why it matters, and what the reader can do next. +

+
+
```html @@ -34,26 +59,37 @@ nav: Make the next step part of the message. The actions slot keeps it alongside the context. - + -
- -

Use semantic tones to communicate intent across your interface.

- -
-
-
+
+ +

Use semantic tones to communicate intent across your interface.

+ +
+
+ ```html + -
- - - The preview is ready for review. - - - Check the content before publishing. - - - Correct the source error and build again. - - -
-
+
+ + + The preview is ready for review. + + + Check the content before publishing. + + + Correct the source error and build again. + + +
+
```html @@ -116,57 +159,166 @@ Keep the message explicit so color is never the only signal. All ten tones, with visible labels. Explain intent through the message as well as color. - -
-
neutralA clear message with supporting context.
-
accentA clear message with supporting context.
-
featureA clear message with supporting context.
-
secondaryA clear message with supporting context.
-
customA clear message with supporting context.
-
ghostA clear message with supporting context.
-
infoA clear message with supporting context.
-
successA clear message with supporting context.
-
warningA clear message with supporting context.
-
dangerA clear message with supporting context.
-
+ +
+
+ neutralA clear message with supporting context. +
+
+ accentA clear message with supporting context. +
+
+ featureA clear message with supporting context. +
+
+ secondaryA clear message with supporting context. +
+
+ customA clear message with supporting context. +
+
+ ghostA clear message with supporting context. +
+
+ infoA clear message with supporting context. +
+
+ successA clear message with supporting context. +
+
+ warningA clear message with supporting context. +
+
+ dangerA clear message with supporting context. +
+
## All visual variants All eighteen treatments in the info tone. Content, icons, and actions share the same API across treatments. - -
-
solidSupporting message.
-
surfaceSupporting message.
-
surfaceAltSupporting message.
-
spotlightSupporting message.
-
glassSupporting message.
-
flatSupporting message.
-
flatAltSupporting message.
-
flatSolidSupporting message.
-
outlineFillSupporting message.
-
outlineSupporting message.
-
subtleSupporting message.
-
subtleBtnSupporting message.
-
linkSupporting message.
-
sheenSupporting message.
-
underlineSupporting message.
-
railSupporting message.
-
bracketSupporting message.
-
noneSupporting message.
-
+ +
+
+ solidSupporting message. +
+
+ surfaceSupporting message. +
+
+ surfaceAltSupporting message. +
+
+ spotlightSupporting message. +
+
+ glassSupporting message. +
+
+ flatSupporting message. +
+
+ flatAltSupporting message. +
+
+ flatSolidSupporting message. +
+
+ outlineFillSupporting message. +
+
+ outlineSupporting message. +
+
+ subtleSupporting message. +
+
+ subtleBtnSupporting message. +
+
+ linkSupporting message. +
+
+ sheenSupporting message. +
+
+ underlineSupporting message. +
+
+ railSupporting message. +
+
+ bracketSupporting message. +
+
+ noneSupporting message. +
+
## Variant modes Stateless is the default. Stateful enables hover, focus, and active styling; it does not add action handlers. Hover or focus these notices to compare. - -
-
statelessCompare the treatment.
-
statefulCompare the treatment.
-
+ +
+
+ statelessCompare the treatment. +
+
+ statefulCompare the treatment. +
+
## Behavior and accessibility @@ -177,7 +329,11 @@ AlertBox is a visual content component and does not automatically announce messa

AlertBox contract

-

Props below are the public template API. RefOrValue accepts a literal or a reactive ref; bind refs with a colon-prefixed attribute. Native attributes and events can be passed through the component root.

+

+ Props below are the public template API. RefOrValue accepts a literal or a + reactive ref; bind refs with a colon-prefixed attribute. Native attributes + and events can be passed through the component root. +

- -

Visible heading. Choose a short title that identifies the content or task.

+ +

+ Visible heading. Choose a short title that identifies the content or task. +

- +

Short context above the heading, such as a category or release label.

- +

Optional compact label rendered with Badge.

- +

Secondary context such as an update time, limitation or reassurance.

- -

Semantic message tone. The default is info; use success, warning or danger only when that intent matches the text.

+ +

+ Semantic message tone. The default is info; use success, warning or danger + only when that intent matches the text. +

- -

Visual treatment. Accepts solid, surface, surfaceAlt, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, sheen, underline, rail, bracket or none.

+ +

+ Visual treatment. Accepts solid, surface, surfaceAlt, spotlight, glass, + flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, + sheen, underline, rail, bracket or none. +

- -

Use stateless for content surfaces; stateful enables the treatment’s hover, focus and active styles. It does not add interaction handlers.

+ +

+ Use stateless for content surfaces; stateful enables the treatment’s hover, + focus and active styles. It does not add interaction handlers. +

- -

Registered SVG icon name. In browser apps, include the icon in the resolver passed to defineIconComponents.

+ +

+ Registered SVG icon name. In browser apps, include the icon in the resolver + passed to defineIconComponents. +

### Composition
-

Default slot: message body. actions: contextual links or buttons. Register defineAlertComponents with Panel, Flex, IconFrame, Icon and Badge dependencies; action components need their own registrations.

+

+ Default slot: message body. + actions: contextual links or buttons. Register + defineAlertComponents with Panel, Flex, IconFrame, Icon and Badge + dependencies; action components need their own registrations. +

diff --git a/frontend/purestack.studio/components/actions/badge/badge-playground.ts b/frontend/purestack.studio/components/actions/badge/badge-playground.ts index 26864d1d..9046affd 100644 --- a/frontend/purestack.studio/components/actions/badge/badge-playground.ts +++ b/frontend/purestack.studio/components/actions/badge/badge-playground.ts @@ -28,8 +28,16 @@ const badgePlaygroundTemplate = html` - - + + Reset playground
` diff --git a/frontend/purestack.studio/components/actions/badge/badge.mdx b/frontend/purestack.studio/components/actions/badge/badge.mdx index 4c96c1d7..c921efbf 100644 --- a/frontend/purestack.studio/components/actions/badge/badge.mdx +++ b/frontend/purestack.studio/components/actions/badge/badge.mdx @@ -13,14 +13,28 @@ nav: # Badge - + Badge renders an inline `span` with two appearance props and a default slot. Its content describes something; use [Btn](/components/actions/buttons/) or [BtnLink](/components/actions/btn-link/) when the reader should take an action. ## In context - -

Design system

Stable

A small set of labels tells the release story at a glance.

v1.012 componentsTheme awareReview pending
+ +
+

Design system

+ Stable
+

A small set of labels tells the release story at a glance.

+ v1.012 componentsTheme awareReview pending +
```html @@ -50,17 +64,17 @@ Edit the label and explore every tone and variant. The default surface treatment tabVariant="underline" > -
- -
-
+
+ +
+
```html ``` - +
@@ -77,21 +91,21 @@ Use tone to reinforce the meaning already expressed by the label. “Passing”, tabVariant="underline" > -
- - neutral - accent - feature - secondary - custom - ghost - info - success - warning - danger - -
-
+
+ + neutral + accent + feature + secondary + custom + ghost + info + success + warning + danger + +
+ ```html @@ -123,29 +137,29 @@ Badge accepts every shared variant and defaults to `surface`. Its variants use s tabVariant="underline" > -
- - solid - surface - surfaceAlt - spotlight - glass - flat - flatAlt - flatSolid - outlineFill - outline - subtle - subtleBtn - link - sheen - underline - rail - bracket - none - -
-
+
+ + solid + surface + surfaceAlt + spotlight + glass + flat + flatAlt + flatSolid + outlineFill + outline + subtle + subtleBtn + link + sheen + underline + rail + bracket + none + +
+
```html @@ -185,22 +199,27 @@ The default slot accepts text and inline components. Put a badge beside a headin tabVariant="underline" > -
- - - Public API - Stable - v1.0 +
+ + + Public API + Stable + v1.0 + +

+ Open reviews 3 +

+ + + All checks passed +
-

Open reviews 3

- - - All checks passed - - -
- - +
+
+ ```html @@ -233,17 +252,17 @@ This example runs entirely in your browser. It models a release workflow without tabVariant="underline" > -
- -
-
+
+ +
+
```html ``` - +
@@ -259,8 +278,13 @@ This example runs entirely in your browser. It models a release workflow without ## API reference
- <Badge tone="success">Stable</Badge> -

A native <span> with two appearance props and one default slot. The default variant is surface. Static badges need no browser application.

+ <Badge tone="success">Stable</Badge> +

+ A native <span> with two appearance props and one default + slot. The default variant is surface. Static badges need no + browser application. +