mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 21:57:00 +04:00
192 lines
7.9 KiB
TypeScript
192 lines
7.9 KiB
TypeScript
#!/usr/bin/env node
|
|
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { sync } from 'glob'
|
|
import * as prettier from 'prettier'
|
|
import { compile } from '@mdx-js/mdx'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
|
const docs: string[] = sync(join(__dirname, '..', 'docs', 'content', '**', '*.mdx'))
|
|
|
|
// Finding nothing means the docs moved and this glob was not updated. Exiting 0
|
|
// with no output would look like "everything is already formatted".
|
|
if (docs.length === 0) {
|
|
console.error('No docs pages found — check the glob in this script.')
|
|
process.exit(1)
|
|
}
|
|
|
|
async function formatHTML(htmlString: string, options: prettier.Options = {}): Promise<string> {
|
|
try {
|
|
const formattedHtml = await prettier.format(htmlString, {
|
|
parser: 'html',
|
|
printWidth: 100,
|
|
...options,
|
|
})
|
|
return formattedHtml
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
console.error('Error formatting HTML:', errorMessage)
|
|
return htmlString // Return original in case of an error
|
|
}
|
|
}
|
|
|
|
async function replaceAsync(str: string, regex: RegExp, asyncFn: (...args: string[]) => Promise<string>): Promise<string> {
|
|
const matches = [...str.matchAll(regex)]
|
|
|
|
const replacements = await Promise.all(matches.map(async (match: RegExpMatchArray) => asyncFn(...(match as unknown as string[]))))
|
|
|
|
// Splice by match index. A string `.replace()` would hit the first occurrence
|
|
// every time, so two identical examples in one file would rewrite the same
|
|
// spot twice and leave the other untouched.
|
|
let result = ''
|
|
let last = 0
|
|
matches.forEach((match: RegExpMatchArray, i: number) => {
|
|
const start = match.index ?? 0
|
|
result += str.slice(last, start) + replacements[i]
|
|
last = start + match[0].length
|
|
})
|
|
|
|
return result + str.slice(last)
|
|
}
|
|
|
|
/**
|
|
* `<Example>` slots hold the demo markup, so they get the same treatment as an
|
|
* ```html fence.
|
|
*
|
|
* Slots containing `{` are skipped. In MDX every unescaped brace is a JSX
|
|
* expression — a component prop (`title={…}`), or the template literal that
|
|
* `<script>` and `<style>` demos are wrapped in so their JS/CSS braces survive.
|
|
* The html parser either throws on those ("Opening tag not terminated") or
|
|
* reformats the embedded code and breaks the expression. Embedded formatting is
|
|
* off as a second guard.
|
|
*
|
|
* Empty lines are stripped: a blank line inside the slot would make MDX start a
|
|
* new paragraph. Raw text is glued to the preceding markup line: markdown
|
|
* syntax is line-based, so a text line of its own can turn into a heading
|
|
* (`=`), a list (`+ 2`) or a blockquote (`>`); glued after a tag it stays
|
|
* phrasing text, and the docs pipeline unwraps the paragraph MDX wraps it in
|
|
* (docs/lib/satteri-unwrap-jsx-paragraphs.mjs).
|
|
*
|
|
* The real MDX compiler has the final word: an example whose formatted form
|
|
* it rejects keeps its original form.
|
|
*/
|
|
async function formatExamples(source: string): Promise<string> {
|
|
return replaceAsync(source, /(<Example\b[^>]*>\n)([\s\S]*?)(\n<\/Example>)/g, async (_m: string, open: string, inner: string, close: string) => {
|
|
if (inner.includes('{')) return _m
|
|
|
|
// Whitespace sensitivity follows css display: an inline element keeps its
|
|
// text exactly (`ignore` would pad `<span>@</span>JohnDoe` with spaces the
|
|
// reader can see), and a block element still gets one child per line. A
|
|
// long inline line wraps inside its tags (`</span\n><span`); the wide
|
|
// print width keeps that to the rare very long example.
|
|
const formatted = glueTextLines((await formatHTML(inner, { embeddedLanguageFormatting: 'off', htmlWhitespaceSensitivity: 'css', printWidth: 160 })).replace(/^\s*[\r\n]/gm, '').trim())
|
|
if (!formatted) return _m
|
|
|
|
if (!(await compilesAsMdx(open + formatted + close))) {
|
|
console.warn(`Left one example as-is (MDX cannot parse the formatted form)`)
|
|
return _m
|
|
}
|
|
|
|
return open + formatted + close
|
|
})
|
|
}
|
|
|
|
async function compilesAsMdx(source: string): Promise<boolean> {
|
|
try {
|
|
await compile(source)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// A complete tag; character classes also match newlines, so a tag spread over
|
|
// several lines (one attribute per line) matches too.
|
|
const tagPattern = /<\/?[a-zA-Z][\w.-]*(?:"[^"]*"|'[^']*'|[^<>"'])*?\/?>/g
|
|
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
|
|
|
|
/** opens minus closes over the tags of one line */
|
|
function lineBalance(line: string): number {
|
|
let depth = 0
|
|
for (const m of line.matchAll(/<(\/)?([a-zA-Z][\w.-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*?)(\/)?>/g)) {
|
|
if (m[1]) depth--
|
|
else if (!m[4] && !voidTags.has(m[2].toLowerCase())) depth++
|
|
}
|
|
return depth
|
|
}
|
|
|
|
const hasText = (line: string): boolean => line.replace(/<[^<>]*>/g, '').trim().length > 0
|
|
|
|
/** The tags of a fragment in order, for comparing markup before and after formatting. */
|
|
const tagSequence = (html: string): string => [...html.matchAll(/<(\/?)([a-zA-Z][\w.-]*)/g)].map((m) => `${m[1]}${m[2].toLowerCase()}`).join(',')
|
|
|
|
/**
|
|
* Merge raw-text lines back into their markup (see formatExamples). MDX parses
|
|
* a line that mixes text and tags as one markdown paragraph, and every tag in
|
|
* it must open and close within that paragraph — so a text line joins the
|
|
* preceding line, and the joined line keeps absorbing following lines until
|
|
* its tags balance. Tags spread over several lines are folded up first so the
|
|
* balance is countable per line; examples without raw text keep prettier's
|
|
* output untouched.
|
|
*/
|
|
function glueTextLines(formatted: string): string {
|
|
const lines = formatted.split('\n')
|
|
if (!lines.some((line) => hasText(line))) return formatted
|
|
|
|
const out: string[] = []
|
|
for (const line of formatted.replace(tagPattern, (tag) => tag.replace(/\s+/g, ' ').replace(/ >$/, '>')).split('\n')) {
|
|
const trimmed = line.trim()
|
|
const tail = out[out.length - 1]
|
|
const absorbing = tail !== undefined && hasText(tail) && lineBalance(tail) > 0
|
|
if (tail !== undefined && trimmed && (absorbing || !trimmed.startsWith('<'))) out[out.length - 1] = `${tail} ${trimmed}`
|
|
else out.push(line)
|
|
// A text line can also land after a closing tag whose opener sits on an
|
|
// earlier line (label after a multi-line svg) — pull those lines up too.
|
|
while (out.length > 1 && hasText(out[out.length - 1]) && lineBalance(out[out.length - 1]) < 0) {
|
|
const merged = `${out[out.length - 2]} ${out[out.length - 1].trim()}`
|
|
out.splice(out.length - 2, 2, merged)
|
|
}
|
|
}
|
|
return out.join('\n')
|
|
}
|
|
|
|
async function processFiles(): Promise<void> {
|
|
for (const file of docs) {
|
|
const oldContent = readFileSync(file, 'utf8')
|
|
|
|
// Code fences, anchored to column 0: an indented fence belongs to a list
|
|
// item, and reprinting it without that indentation breaks the list.
|
|
let content = await replaceAsync(oldContent, /^(```([a-z0-9]+).*?\n)([\s\S]*?)^(```)/gm, async (m: string, m1: string, m2: string, m3: string, m4: string) => {
|
|
if (m2 === 'html') {
|
|
let formattedHtml = await formatHTML(m3)
|
|
|
|
formattedHtml = formattedHtml.replace(/^\s*[\r\n]/gm, '')
|
|
|
|
// Fences hold illustrative snippets, not always whole documents, and
|
|
// js-beautify silently closes what it thinks is unclosed. Only take
|
|
// its output when it rewrapped the same tags it was given.
|
|
if (tagSequence(formattedHtml) !== tagSequence(m3)) return m
|
|
|
|
return m1 + formattedHtml.trim() + '\n' + m4
|
|
}
|
|
return m.trim()
|
|
})
|
|
|
|
content = await formatExamples(content)
|
|
|
|
if (content !== oldContent) {
|
|
writeFileSync(file, content, 'utf8')
|
|
console.log(`Reformatted ${file}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
processFiles().catch((error) => {
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
console.error('Error processing files:', errorMessage)
|
|
process.exit(1)
|
|
})
|