From 921daa87417df802f4e80b7d7a64946c7ccda116 Mon Sep 17 00:00:00 2001 From: liuly Date: Sun, 13 Sep 2026 02:45:53 +0800 Subject: [PATCH] perf: use lightweight markdown renderer for style docs --- src/components/Doc.tsx | 6 +- src/components/MiniMarkdown.test.ts | 83 ++++++ src/components/MiniMarkdown.tsx | 422 ++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 src/components/MiniMarkdown.test.ts create mode 100644 src/components/MiniMarkdown.tsx diff --git a/src/components/Doc.tsx b/src/components/Doc.tsx index aeced6d6..c1e9db61 100644 --- a/src/components/Doc.tsx +++ b/src/components/Doc.tsx @@ -1,5 +1,5 @@ import React from "react"; -import Markdown from "react-markdown"; +import { MiniMarkdown } from "./MiniMarkdown"; const headers = { js: "JS", @@ -50,9 +50,7 @@ export class Doc extends React.Component { {doc &&
- {children}, - }}>{doc} + {doc}
{renderValues &&
    diff --git a/src/components/MiniMarkdown.test.ts b/src/components/MiniMarkdown.test.ts new file mode 100644 index 00000000..fe7b6170 --- /dev/null +++ b/src/components/MiniMarkdown.test.ts @@ -0,0 +1,83 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; +import { MiniMarkdown, parseMarkdown } from "./MiniMarkdown"; + +function collectDocs(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + + const ownDoc = "doc" in value && typeof value.doc === "string" ? [value.doc] : []; + + return ownDoc.concat(Object.values(value).flatMap(collectDocs)); +} + +describe("parseMarkdown", () => { + it("renders the syntax used by style-spec documentation", () => { + const [paragraph, list, code] = parseMarkdown( + "A [`property`](https://example.com/MapLibre_style) with **details**.\n\n- [Example](assets/example.svg)\n\n```json\n{\"value\": true}\n```" + ); + + expect(paragraph).toMatchObject({ + type: "paragraph", + children: [ + { type: "text", value: "A " }, + { + type: "link", + href: "https://example.com/MapLibre_style", + children: [{ type: "code", value: "property" }] + }, + { type: "text", value: " with " }, + { type: "strong", children: [{ type: "text", value: "details" }] }, + { type: "text", value: "." } + ] + }); + expect(list).toMatchObject({ + type: "unordered-list", + items: [[{ + type: "link", + href: "assets/example.svg", + children: [{ type: "text", value: "Example" }] + }]] + }); + expect(code).toMatchObject({ + type: "code", + language: "json", + value: "{\"value\": true}" + }); + }); + + it("keeps unsafe links as plain text", () => { + const [paragraph] = parseMarkdown("[unsafe](javascript:alert(1))"); + expect(paragraph).toMatchObject({ + type: "paragraph", + children: [{ type: "text", value: "[unsafe](javascript:alert(1))" }] + }); + }); + + it("does not interpret underscores inside ordinary words", () => { + const [paragraph] = parseMarkdown("The `font_face` property and font_face value."); + expect(paragraph).toMatchObject({ + type: "paragraph", + children: [ + { type: "text", value: "The " }, + { type: "code", value: "font_face" }, + { type: "text", value: " property and font_face value." } + ] + }); + }); + + it("renders every current style-spec document", () => { + const docs = collectDocs(latest); + expect(docs).toHaveLength(620); + + const html = docs + .map(doc => renderToStaticMarkup(React.createElement(MiniMarkdown, { children: doc }))) + .join(""); + + expect(html).not.toContain("undefined"); + expect(html).toContain('href="https://en.wikipedia.org/wiki/Khmer_script"'); + expect(html).toContain("") + ? trimmed.slice(1, -1) + : trimmed.split(/\s+/, 1)[0]; + + return unwrapped && isSafeUrl(unwrapped) ? unwrapped : undefined; +} + +function parseLink(text: string, start: number) { + const image = text.startsWith("![", start); + const labelStart = start + (image ? 2 : 1); + const labelEnd = findClosingBracket(text, labelStart); + + if (labelEnd < 0 || text[labelEnd + 1] !== "(") return undefined; + + const destinationStart = labelEnd + 2; + const destinationEnd = findDestinationEnd(text, destinationStart); + if (destinationEnd < 0) return undefined; + + const destination = getDestination(text.slice(destinationStart, destinationEnd)); + if (!destination) return undefined; + + return { + image, + label: text.slice(labelStart, labelEnd), + destination, + end: destinationEnd + 1 + }; +} + +function canOpenEmphasis(text: string, index: number, marker: string) { + const before = text[index - 1] || ""; + const after = text[index + marker.length] || ""; + + if (!after || /\s/.test(after)) return false; + // An underscore inside a word is text, not an emphasis delimiter. + return marker !== "_" || !(/[\p{L}\p{N}]/u.test(before) && /[\p{L}\p{N}]/u.test(after)); +} + +function canCloseEmphasis(text: string, index: number, marker: string) { + const before = text[index - 1] || ""; + const after = text[index + marker.length] || ""; + + if (!before || /\s/.test(before)) return false; + return marker !== "_" || !(/[\p{L}\p{N}]/u.test(before) && /[\p{L}\p{N}]/u.test(after)); +} + +function parseInline(text: string): InlineNode[] { + const nodes: InlineNode[] = []; + let plainText = ""; + + const flushText = () => { + addText(nodes, plainText.replace(/\s*\n\s*/g, " ")); + plainText = ""; + }; + + for (let i = 0; i < text.length;) { + if (text[i] === "\\" && /[\\`*_[\]{}()#+\-.!]/.test(text[i + 1] || "")) { + plainText += text[i + 1]; + i += 2; + continue; + } + + if (text[i] === "`") { + let markerLength = 1; + while (text[i + markerLength] === "`") markerLength++; + const marker = "`".repeat(markerLength); + const end = text.indexOf(marker, i + markerLength); + + if (end >= 0) { + flushText(); + nodes.push({ + type: "code", + value: text.slice(i + markerLength, end).trim() + }); + i = end + markerLength; + continue; + } + } + + if (text.startsWith("![", i) || text[i] === "[") { + const link = parseLink(text, i); + if (link) { + flushText(); + if (link.image) { + nodes.push({ type: "image", src: link.destination, alt: link.label }); + } + else { + nodes.push({ + type: "link", + href: link.destination, + children: parseInline(link.label) + }); + } + i = link.end; + continue; + } + } + + const strongMarker = text.startsWith("**", i) + ? "**" + : text.startsWith("__", i) ? "__" : undefined; + if (strongMarker && canOpenEmphasis(text, i, strongMarker)) { + const end = text.indexOf(strongMarker, i + strongMarker.length); + if (end > i + strongMarker.length && canCloseEmphasis(text, end, strongMarker)) { + flushText(); + nodes.push({ + type: "strong", + children: parseInline(text.slice(i + strongMarker.length, end)) + }); + i = end + strongMarker.length; + continue; + } + } + + if ((text[i] === "*" || text[i] === "_") && canOpenEmphasis(text, i, text[i])) { + const marker = text[i]; + const end = text.indexOf(marker, i + 1); + if (end > i + 1 && canCloseEmphasis(text, end, marker)) { + flushText(); + nodes.push({ + type: "em", + children: parseInline(text.slice(i + 1, end)) + }); + i = end + 1; + continue; + } + } + + plainText += text[i]; + i++; + } + + flushText(); + return nodes; +} + +function isBlank(line: string) { + return /^\s*$/.test(line); +} + +function getFence(line: string) { + return line.match(/^\s{0,3}(`{3,}|~{3,})\s*([\w-]+)?\s*$/); +} + +function getListItem(line: string) { + const unordered = line.match(/^\s{0,3}[-+*]\s+(.+)$/); + if (unordered) return { ordered: false, value: unordered[1] }; + + const ordered = line.match(/^\s{0,3}\d+[.)]\s+(.+)$/); + if (ordered) return { ordered: true, value: ordered[1] }; + + return undefined; +} + +function isThematicBreak(line: string) { + return /^(?:\s{0,3}(?:\*[ \t]*){3,}|\s{0,3}(?:-[ \t]*){3,}|\s{0,3}(?:_[ \t]*){3,})$/.test(line); +} + +function isBlockStart(line: string) { + return !!getFence(line) || + /^\s{0,3}#{1,6}\s+/.test(line) || + !!getListItem(line) || + /^\s{0,3}>/.test(line) || + isThematicBreak(line); +} + +// This pure helper is exported for focused parser tests; the file also exports the React component below. +// eslint-disable-next-line react-refresh/only-export-components +export function parseMarkdown(markdown: string): MarkdownBlock[] { + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const blocks: MarkdownBlock[] = []; + let index = 0; + + while (index < lines.length) { + if (isBlank(lines[index])) { + index++; + continue; + } + + const fence = getFence(lines[index]); + if (fence) { + const fenceMarker = fence[1][0]; + const fenceLength = fence[1].length; + const codeLines: string[] = []; + index++; + while (index < lines.length && !new RegExp(`^\\s{0,3}${fenceMarker}{${fenceLength},}\\s*$`).test(lines[index])) { + codeLines.push(lines[index]); + index++; + } + if (index < lines.length) index++; + blocks.push({ + type: "code", + value: codeLines.join("\n"), + language: fence[2] + }); + continue; + } + + const heading = lines[index].match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/); + if (heading) { + blocks.push({ + type: "heading", + level: heading[1].length, + children: parseInline(heading[2]) + }); + index++; + continue; + } + + if (isThematicBreak(lines[index])) { + blocks.push({ type: "thematic-break" }); + index++; + continue; + } + + const firstListItem = getListItem(lines[index]); + if (firstListItem) { + const items: InlineNode[][] = []; + const ordered = firstListItem.ordered; + + while (index < lines.length) { + if (isBlank(lines[index])) { + let next = index; + while (next < lines.length && isBlank(lines[next])) next++; + const nextItem = next < lines.length ? getListItem(lines[next]) : undefined; + if (nextItem && nextItem.ordered === ordered) { + index = next; + continue; + } + break; + } + + const item = getListItem(lines[index]); + if (!item || item.ordered !== ordered) break; + let value = item.value; + index++; + + while (index < lines.length && /^\s{2,}\S/.test(lines[index]) && !getListItem(lines[index])) { + value += " " + lines[index].trim(); + index++; + } + items.push(parseInline(value)); + } + + blocks.push({ + type: ordered ? "ordered-list" : "unordered-list", + items + }); + continue; + } + + if (/^\s{0,3}>/.test(lines[index])) { + const quoteLines: string[] = []; + while (index < lines.length && (/^\s{0,3}>/.test(lines[index]) || isBlank(lines[index]))) { + quoteLines.push(lines[index].replace(/^\s{0,3}>\s?/, "")); + index++; + } + blocks.push({ type: "blockquote", children: parseMarkdown(quoteLines.join("\n")) }); + continue; + } + + const paragraphLines = [lines[index]]; + index++; + while (index < lines.length && !isBlank(lines[index]) && !isBlockStart(lines[index])) { + paragraphLines.push(lines[index]); + index++; + } + blocks.push({ type: "paragraph", children: parseInline(paragraphLines.join("\n")) }); + } + + return blocks; +} + +function renderInline(nodes: InlineNode[], keyPrefix: string): ReactNode[] { + return nodes.map((node, index) => { + const key = `${keyPrefix}-${index}`; + switch (node.type) { + case "text": + return node.value; + case "code": + return {node.value}; + case "strong": + return {renderInline(node.children || [], key)}; + case "em": + return {renderInline(node.children || [], key)}; + case "link": + return + {renderInline(node.children, key)} + ; + case "image": + return {node.alt}; + } + }); +} + +function renderBlocks(blocks: MarkdownBlock[], keyPrefix = "block"): ReactNode[] { + return blocks.map((block, index) => { + const key = `${keyPrefix}-${index}`; + switch (block.type) { + case "paragraph": + return

    {renderInline(block.children, key)}

    ; + case "heading": + return React.createElement(`h${block.level}`, { key }, renderInline(block.children, key)); + case "unordered-list": + return
      {block.items.map((item, itemIndex) => +
    • {renderInline(item, `${key}-${itemIndex}`)}
    • + )}
    ; + case "ordered-list": + return
      {block.items.map((item, itemIndex) => +
    1. {renderInline(item, `${key}-${itemIndex}`)}
    2. + )}
    ; + case "code": + return
    +          {block.value}
    +        
    ; + case "blockquote": + return
    {renderBlocks(block.children, key)}
    ; + case "thematic-break": + return
    ; + } + }); +} + +export const MiniMarkdown: React.FC<{children: string}> = ({children}) => { + return <>{renderBlocks(parseMarkdown(children))}; +};