From 4b1015823122706aaa8d09b1ecf705c891af097d Mon Sep 17 00:00:00 2001 From: kanno <812137533@qq.com> Date: Fri, 14 Feb 2025 14:54:55 +0800 Subject: [PATCH] feat: using jsx write docs --- Makefile | 2 +- scripts/h.ts | 124 +++++++++++ scripts/jsx-namespace.d.ts | 21 ++ scripts/render.ts | 393 -------------------------------- scripts/render.tsx | 444 +++++++++++++++++++++++++++++++++++++ scripts/serve.ts | 2 +- tsconfig.json | 3 + 7 files changed, 594 insertions(+), 395 deletions(-) create mode 100644 scripts/h.ts create mode 100644 scripts/jsx-namespace.d.ts delete mode 100644 scripts/render.ts create mode 100644 scripts/render.tsx diff --git a/Makefile b/Makefile index e12cf66..e1a271d 100644 --- a/Makefile +++ b/Makefile @@ -39,4 +39,4 @@ dev-docs: ./node_modules/.bin/tsx scripts/serve.ts build-docs: - ./node_modules/.bin/tsx scripts/render.ts + ./node_modules/.bin/tsx scripts/render.tsx diff --git a/scripts/h.ts b/scripts/h.ts new file mode 100644 index 0000000..87e87de --- /dev/null +++ b/scripts/h.ts @@ -0,0 +1,124 @@ +/* eslint-disable no-use-before-define */ +// preact is fine, but i won't need it for the project. + +export type HTMLTag = keyof HTMLElementTagNameMap + +export type ProprsWithChildren

= P & { children?: Child | Child[] } + +export type Component

= (props: ProprsWithChildren

) => VNode + +export type Child = string | number | boolean | null | undefined | VNode + +export type DeepOptionalProps = { + [K in keyof T]?: T[K] extends object ? DeepOptionalProps : T[K] +} + +export type InferElement = HTMLElementTagNameMap[T] + +export interface VNode

{ + type: HTMLTag | Component

+ props: ProprsWithChildren

+ children: Child[] +} + +export type JSXElement = E extends HTMLTag ? VNode>> + : E extends Component ? VNode

+ : never + +export function h( + type: T, + props: T extends FragmentType ? null + : T extends HTMLTag ? (DeepOptionalProps> | null) + : T extends Component ? P + : never, + ...children: Child[] +): JSXElement { + return { + type, + props: props || null, + children: children.flat().filter(Boolean) + } as JSXElement +} + +export const Fragment = Symbol('Fragment') as unknown as Component +export type FragmentType = typeof Fragment + +function normalizeKey(key: string): string { + const specialCases: Record = { + className: 'class', + htmlFor: 'for' + } + + return specialCases[key] || key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) +} + +function renderProps(props: ProprsWithChildren>): string { + if (!props) { return '' } + return Object.entries(props) + .filter(([key]) => key !== 'children') + .map(([key, value]) => { + if (key === 'style' && typeof value === 'object') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const style = Object.entries(value) + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + .map(([k, v]) => `${normalizeKey(k)}:${v}`) + .join(';') + return `style="${style}"` + } + if (typeof value === 'boolean' && value) { + return normalizeKey(key) + } + if (typeof value === 'string' || typeof value === 'number') { + return `${normalizeKey(key)}="${value}"` + } + return '' + }) + .filter(Boolean) + .join(' ') +} + +export function renderToString(node: Child): string { + if (node == null || typeof node === 'boolean') { + return '' + } + + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + + const { type, props, children } = node as VNode + + if (type === Fragment) { + return children.map(renderToString).join('') + } + + if (typeof type === 'function') { + return renderToString(type(props)) + } + + const propsString = renderProps(props) + const childrenString = children.map(renderToString).join('') + + // Self-closing tags + const voidElements = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr' + ]) + if (voidElements.has(type)) { + return `<${type}${propsString ? ' ' + propsString : ''}/>` + } + + return `<${type}${propsString ? ' ' + propsString : ''}>${childrenString}` +} diff --git a/scripts/jsx-namespace.d.ts b/scripts/jsx-namespace.d.ts new file mode 100644 index 0000000..c68d113 --- /dev/null +++ b/scripts/jsx-namespace.d.ts @@ -0,0 +1,21 @@ +import { Child, DeepOptionalProps, InferElement, VNode } from './h' + +declare global { + namespace JSX { + type Element = VNode + type SVGTag = keyof SVGElementTagNameMap + export interface ElementChildrenAttribute { + children: Child | Child[] + } + export type IntrinsicElements = { + [K in ElementTag]: K extends keyof SVGElementTagNameMap ? DeepOptionalProps + : K extends 'meta' ? DeepOptionalProps + : DeepOptionalProps> | ElementChildrenAttribute + } + export type ElementChildrenAttribute = { + children: Any + } + } +} + +export {} diff --git a/scripts/render.ts b/scripts/render.ts deleted file mode 100644 index d350c6a..0000000 --- a/scripts/render.ts +++ /dev/null @@ -1,393 +0,0 @@ -import esbuild from 'esbuild' -import fs from 'fs' -import fsp from 'fs/promises' -import hljs from 'highlight.js' -import yaml from 'js-yaml' -import markdownit from 'markdown-it' -import path from 'path' -import type { Theme } from './theme' - -const md = markdownit({ html: true }) - -const docsDir = path.join(__dirname, '..', 'docs') - -const devDir = path.join(__dirname, '..', 'dev') - -const destDir = path.join(__dirname, '..', 'display') - -const scriptDir = __dirname - -const target = ['chrome58', 'safari11', 'firefox57', 'edge16'] - -type TagElement = 'p' | 'ul' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre' | `pre.${string}` - -type TagValue = T extends 'ul' ? string[] : string - -interface FormattedTag { - tag: T - value: TagValue -} - -type AnyFormattedTag = FormattedTag - -interface RenderMetadata { - title: string - body: AnyFormattedTag[] -} - -const data = yaml.load(fs.readFileSync(path.join(docsDir, 'index.yaml'), 'utf8')) as Record - -const pages = Object.entries(data) - -function createTag(tag: T, value: TagValue): FormattedTag { - return { tag, value } -} - -export interface Descriptor { - kind: 'script' | 'style' | 'title' - text: string - attrs?: string[] -} - -interface InjectHTMLTagOptions { - html: string - injectTo: 'body' | 'head' - descriptors: Descriptor | Descriptor[] -} - -// Refactor this function -export function injectHTMLTag(options: InjectHTMLTagOptions) { - const regExp = options.injectTo === 'head' ? /([ \t]*)<\/head>/i : /([ \t]*)<\/body>/i - options.descriptors = Array.isArray(options.descriptors) ? options.descriptors : [options.descriptors] - const descriptors = options.descriptors.map((d) => { - if (d.attrs && d.attrs.length > 0) { - return `<${d.kind} ${d.attrs.join(' ')}>${d.text}` - } - return `<${d.kind}>${d.text}` - }) - return options.html.replace(regExp, (match) => `${descriptors.join('\n')}${match}`) -} - -function minifyCSS(css: string) { - return esbuild.transformSync(css, { target, loader: 'css', minify: true }).code -} - -function minifyJS(js: string) { - return esbuild.transformSync(js, { target, loader: 'ts', minify: true }).code -} - -function buildAndMinifyJS(entry: string) { - const r = esbuild.buildSync({ - bundle: true, - format: 'iife', - loader: { - '.ts': 'ts' - }, - define: { - LIVE_RELOAD: 'false' - }, - minify: true, - write: false, - entryPoints: [entry] - }) - if (r.outputFiles.length) { - return r.outputFiles[0].text - } - throw new Error('No output files') -} - -const formatedPages = pages.reduce((acc, [page, pageData]) => { - if (typeof pageData === 'string') { - if (pageData.endsWith('.yaml')) { - pageData = yaml.load(fs.readFileSync(path.join(docsDir, pageData), 'utf8')) as RenderMetadata - } - } - if (typeof pageData === 'object') { - pageData.body = pageData.body.map((sec) => { - const tag = Object.keys(sec)[0] - // @ts-expect-error safe - return createTag(tag as TagElement, sec[tag] as TagValue) - }) - } - // @ts-expect-error safe - acc.push([page, pageData]) - return acc -}, [] as [string, RenderMetadata][]) - -const hljsPath = path.dirname(require.resolve('highlight.js/package.json', { paths: [process.cwd()] })) - -// We use github highlighting style - -function pipeOriginalCSSIntoThemeSystem(css: string, theme: Theme) { - let wrappered = '' - if (theme === 'dark') { - wrappered = `html[data-theme="dark"] { ${css} }\n` - } else { - wrappered = `html:not([data-theme="dark"]) { ${css} }\n` - } - - return minifyCSS(wrappered) -} - -const hljsGithubCSS = { - light: pipeOriginalCSSIntoThemeSystem(fs.readFileSync(path.join(hljsPath, 'styles/github.css'), 'utf-8'), 'light'), - dark: pipeOriginalCSSIntoThemeSystem(fs.readFileSync(path.join(hljsPath, 'styles/github-dark.css'), 'utf-8'), 'dark') -} - -const commonCSS = minifyCSS(fs.readFileSync(path.join(scriptDir, 'style.css'), 'utf8')) - -const coomonScript = minifyJS(fs.readFileSync(path.join(scriptDir, 'theme.ts'), 'utf8')) - -const assert = { - ul: (tag: FormattedTag): tag is FormattedTag<'ul'> => { - return tag.tag === 'ul' - }, - pre: (tag: FormattedTag): tag is FormattedTag<'pre' | `pre.${string}`> => { - if (tag.tag.startsWith('pre')) { return true } - return false - }, - base: (tag: FormattedTag): tag is FormattedTag> => { - if (tag.tag !== 'ul') { return true } - return false - } -} - -function toID(text: string) { - return text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]+/g, '') -} - -function renderMainSection(page: string, pageData: RenderMetadata): string { - const handler = (c: FormattedTag): string => { - if (assert.ul(c)) { - return `

` - } - if (assert.pre(c)) { - if (c.tag.startsWith('pre.')) { - const lang = c.tag.split('.')[1] - return `
${hljs.highlight(c.value.trim(), { language: lang }).value}
` - } - return `
${md.render(c.value.trim())}
` - } - if (assert.base(c)) { - // For heading metadata - if (/^h[2-6]$/.test(c.tag)) { - const slug = toID(c.value) - return `<${c.tag} id="${slug}">${ - md.renderInline(c.value.trim()) - }` - } - - return `<${c.tag}>${md.renderInline(c.value.trim())}` - } - throw new Error('Unreachable') - } - return pageData.body - .reduce((acc, cur) => (acc.push(handler(cur)), acc), []) - .join('\n') -} - -interface HeadingBase { - value: string - id: string -} - -interface HeadingMetadata extends HeadingBase { - h3s: HeadingBase[] -} - -interface HeadingStruct { - key: string - title: string - h2s: HeadingMetadata[] -} - -function renderMenu(): string { - const structure: HeadingStruct[] = [] - for (const [pageName, pageData] of formatedPages) { - if (pageName === 'index') { continue } - const h2s: HeadingMetadata[] = [] - const root = { key: pageName, title: pageData.title, h2s } - let h3s: HeadingBase[] = [] - - for (const c of pageData.body) { - if (assert.base(c)) { - if (c.tag === 'h2') { - h3s = [] - h2s.push({ value: c.value, id: toID(c.value), h3s }) - } else if (c.tag === 'h3') { - h3s.push({ value: c.value, id: toID(c.value) }) - } - } - } - structure.push(root) - } - - const navs: string[] = [] - - navs.push('
  • Home
  • ') - - for (const { key, title, h2s } of structure) { - navs.push(`
  • ${title}
  • `) - for (const h2 of h2s) { - navs.push(`
  • ${h2.value}
  • `) - if (h2.h3s.length > 0) { - navs.push('') - } - } - } - return navs.join('') -} - -const icons = { - moon: ` - - - -`.trim(), - sun: ` - - - -`.trim(), - github: ` - - - -`.trim(), - menu: ` - - `.trim() -} - -function widget() { - const html: string[] = [] - html.push('
    ') - html.push('') - html.push(icons.github) - html.push('') - html.push('') - html.push(icons.moon) - html.push(icons.sun) - html.push('') - html.push('
    ') - return html -} - -function buildExampleDisplay() { - let html = fs.readFileSync(path.join(devDir, 'index.html'), 'utf8') - html = html.replace(/)<[^<]*)*<\/script>/gi, '') - html = injectHTMLTag({ - html, - injectTo: 'body', - descriptors: { - kind: 'script', - text: buildAndMinifyJS(path.join(devDir, 'main.ts')) - } - }) - return html -} - -async function main() { - for (const [page, pageData] of formatedPages) { - const html: string[] = [] - html.push('') - html.push('') - - // Head - html.push('') - html.push('') - html.push('') - html.push('') - html.push(`squarified - ${pageData.title}`) - html.push('') - html.push('') - html.push('') - html.push('') - html.push(``) - html.push(``) - html.push(``) - html.push('') - - // Body - - html.push('') - - // Menubar - - html.push('') - - // shadow - - html.push('
    ') - - // Menu - - html.push('') - - // Article - html.push('
    ') - html.push(renderMainSection(page, pageData)) - html.push('
    ') - html.push('') - html.push(``) - html.push('') - html.push('\n') - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir) - } - await fsp.writeFile(path.join(destDir, `${page}.html`), html.join(''), 'utf8') - } - const example = buildExampleDisplay() - // cp data.json to display - await fsp.copyFile(path.join(devDir, 'data.json'), path.join(destDir, 'data.json')) - await fsp.writeFile(path.join(destDir, 'example.html'), example, 'utf8') -} - -main().catch(console.error) diff --git a/scripts/render.tsx b/scripts/render.tsx new file mode 100644 index 0000000..c930622 --- /dev/null +++ b/scripts/render.tsx @@ -0,0 +1,444 @@ +import esbuild from 'esbuild' +import fs from 'fs' +import fsp from 'fs/promises' +import hljs from 'highlight.js' +import yaml from 'js-yaml' +import markdownit from 'markdown-it' +import path from 'path' +import type { Component } from './h' +import { Fragment, h, renderToString } from './h' +import type { Theme } from './theme' +/// + +const md = markdownit({ html: true }) + +const Dirs = { + docs: path.resolve(__dirname, '../docs'), + src: path.resolve(__dirname, '../src'), + dest: path.resolve(__dirname, '../display'), + example: path.resolve(__dirname, '../dev'), + script: __dirname +} + +interface HeadProps { + title: string +} + +const Icons = { + Moon: () => ( + + + + ), + Sun: () => ( + + + + ), + GitHub: () => ( + + + + ), + Menu: () => ( + + + + ) +} + +type DocTagElement = 'p' | 'ul' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'pre' | `pre.${string}` + +type DocTagValue = T extends 'ul' ? string[] : string + +interface FormattedDocTag { + tag: T + value: DocTagValue +} + +type AnyFormattedDocTag = FormattedDocTag + +interface RenderMetadata { + title: string + body: AnyFormattedDocTag[] +} + +const target = ['chrome58', 'safari11', 'firefox57', 'edge16'] + +const data = yaml.load(fs.readFileSync(path.join(Dirs.docs, 'index.yaml'), 'utf8')) as Record + +const pages = Object.entries(data) + +const commonCSS = minifyCSS(fs.readFileSync(path.join(Dirs.script, 'style.css'), 'utf8')) +const coomonScript = minifyJS(fs.readFileSync(path.join(Dirs.script, 'theme.ts'), 'utf8')) + +function createTag(tag: T, value: DocTagValue): FormattedDocTag { + return { tag, value } +} + +const formatedPages = pages.reduce((acc, [page, pageData]) => { + if (typeof pageData === 'string') { + if (pageData.endsWith('.yaml')) { + pageData = yaml.load(fs.readFileSync(path.join(Dirs.docs, pageData), 'utf8')) as RenderMetadata + } + } + if (typeof pageData === 'object') { + pageData.body = pageData.body.map((sec) => { + const tag = Object.keys(sec)[0] + // @ts-expect-error safe + return createTag(tag as DocTagElement, sec[tag] as DocTagValue) + }) + } + // @ts-expect-error safe + acc.push([page, pageData]) + return acc +}, [] as [string, RenderMetadata][]) + +function minifyCSS(css: string) { + return esbuild.transformSync(css, { target, loader: 'css', minify: true }).code +} + +function minifyJS(js: string) { + return esbuild.transformSync(js, { target, loader: 'ts', minify: true }).code +} + +function buildAndMinifyJS(entry: string) { + const r = esbuild.buildSync({ + bundle: true, + format: 'iife', + loader: { + '.ts': 'ts' + }, + define: { + LIVE_RELOAD: 'false' + }, + minify: true, + write: false, + entryPoints: [entry] + }) + if (r.outputFiles.length) { + return r.outputFiles[0].text + } + throw new Error('No output files') +} + +// We use github highlighting style + +function pipeOriginalCSSIntoThemeSystem(css: string, theme: Theme) { + let wrappered = '' + if (theme === 'dark') { + wrappered = `html[data-theme="dark"] { ${css} }\n` + } else { + wrappered = `html:not([data-theme="dark"]) { ${css} }\n` + } + + return minifyCSS(wrappered) +} + +const hljsPath = path.dirname(require.resolve('highlight.js/package.json', { paths: [process.cwd()] })) + +const hljsGitHubCSS = { + light: pipeOriginalCSSIntoThemeSystem(fs.readFileSync(path.join(hljsPath, 'styles/github.css'), 'utf-8'), 'light'), + dark: pipeOriginalCSSIntoThemeSystem(fs.readFileSync(path.join(hljsPath, 'styles/github-dark.css'), 'utf-8'), 'dark') +} + +function Head(props: HeadProps) { + const { title } = props + return ( + + + + + squarified - {title} + + + + + + + + + ) +} + +function toID(text: string) { + return text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]+/g, '') +} + +interface HeadingBase { + value: string + id: string +} + +interface HeadingMetadata extends HeadingBase { + h3s: HeadingBase[] +} + +interface HeadingStruct { + key: string + title: string + h2s: HeadingMetadata[] +} + +const assert = { + ul: (tag: FormattedDocTag): tag is FormattedDocTag<'ul'> => { + return tag.tag === 'ul' + }, + pre: (tag: FormattedDocTag): tag is FormattedDocTag<'pre' | `pre.${string}`> => { + if (tag.tag.startsWith('pre')) { return true } + return false + }, + base: (tag: FormattedDocTag): tag is FormattedDocTag> => { + if (tag.tag !== 'ul') { return true } + return false + } +} + +function Menu() { + const structure: HeadingStruct[] = [] + + for (const [pageName, pageData] of formatedPages) { + if (pageName === 'index') { continue } + const h2s: HeadingMetadata[] = [] + const root = { key: pageName, title: pageData.title, h2s } + let h3s: HeadingBase[] = [] + + for (const c of pageData.body) { + if (assert.base(c)) { + if (c.tag === 'h2') { + h3s = [] + h2s.push({ value: c.value, id: toID(c.value), h3s }) + } else if (c.tag === 'h3') { + h3s.push({ value: c.value, id: toID(c.value) }) + } + } + } + structure.push(root) + } + + return ( + + ) +} + +interface ArticleProps { + data: RenderMetadata +} + +export function Article(props: ArticleProps) { + const { data } = props + return ( +
    + {data.body.map((c: FormattedDocTag) => { + if (assert.ul(c)) { + return ( +
      + {c.value.map((li) => ( +
    • + {
    • ${md.renderInline(li.trim())}
    • } + + ))} +
    + ) + } + if (assert.pre(c)) { + if (c.tag.startsWith('pre.')) { + const lang = c.tag.split('.')[1] + return ( +
    +                {hljs.highlight(c.value.trim(), { language: lang }).value}
    +              
    + ) + } + return
    ${md.render(c.value.trim())}
    + } + if (assert.base(c)) { + const Tag = c.tag as unknown as Component + if (/^h[2-6]$/.test(c.tag)) { + const slug = toID(c.value) + return ( + + + {md.renderInline(c.value.trim())} + + ) + } + return {md.renderInline(c.value.trim())} + } + throw new Error('Unreachable') + })} +
    + ) +} + +export function Layout(props: ArticleProps) { + return ( + + +
    + +
    + + ) +} + +export interface Descriptor { + kind: 'script' | 'style' | 'title' + text: string + attrs?: string[] +} + +interface InjectHTMLTagOptions { + html: string + injectTo: 'body' | 'head' + descriptors: Descriptor | Descriptor[] +} + +// Refactor this function +export function injectHTMLTag(options: InjectHTMLTagOptions) { + const regExp = options.injectTo === 'head' ? /([ \t]*)<\/head>/i : /([ \t]*)<\/body>/i + options.descriptors = Array.isArray(options.descriptors) ? options.descriptors : [options.descriptors] + const descriptors = options.descriptors.map((d) => { + if (d.attrs && d.attrs.length > 0) { + return `<${d.kind} ${d.attrs.join(' ')}>${d.text}` + } + return `<${d.kind}>${d.text}` + }) + return options.html.replace(regExp, (match) => `${descriptors.join('\n')}${match}`) +} + +function buildExampleDisplay() { + let html = fs.readFileSync(path.join(Dirs.example, 'index.html'), 'utf8') + html = html.replace(/)<[^<]*)*<\/script>/gi, '') + html = injectHTMLTag({ + html, + injectTo: 'body', + descriptors: { + kind: 'script', + text: buildAndMinifyJS(path.join(Dirs.example, 'main.ts')) + } + }) + return html +} + +async function main() { + for (const [page, pageData] of formatedPages) { + const html: string[] = [] + html.push('') + html.push(renderToString( + + + + + + + + )) + if (!fs.existsSync(Dirs.dest)) { + fs.mkdirSync(Dirs.dest) + } + + await fsp.writeFile(path.join(Dirs.dest, `${page}.html`), html.join(''), 'utf8') + } + const example = buildExampleDisplay() + await fsp.copyFile(path.join(Dirs.example, 'data.json'), path.join(Dirs.dest, 'data.json')) + await fsp.writeFile(path.join(Dirs.dest, 'example.html'), example, 'utf8') +} + +main().catch(console.error) diff --git a/scripts/serve.ts b/scripts/serve.ts index 8ea7149..a0955eb 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -78,7 +78,7 @@ function createStaticLivingServer() { } async function prepareDisplay() { - const r = await x('./node_modules/.bin/tsx', ['./scripts/render.ts'], { nodeOptions: { cwd: process.cwd() } }) + const r = await x('./node_modules/.bin/tsx', ['./scripts/render.tsx'], { nodeOptions: { cwd: process.cwd() } }) if (r.stderr) { throw new Error(r.stderr) } diff --git a/tsconfig.json b/tsconfig.json index 64644b5..8552380 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,9 @@ "resolveJsonModule": true, "module": "ESNext", "moduleResolution": "Bundler", + "jsx": "preserve", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", "strict": true } }