@godxjp/markdown 0.0.0-stage → 31.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -2
- package/dist/index.d.ts +158 -0
- package/dist/index.js +371 -0
- package/package.json +52 -4
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @godxjp/markdown
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The one Markdown renderer for GoDX apps (godx-task, godx-content, and later mailer, chatter and
|
|
4
|
+
approval). One pipeline, one sanitiser schema, one fixture corpus — so a security fix happens once.
|
|
5
|
+
|
|
6
|
+
````bash
|
|
7
|
+
pnpm add @godxjp/markdown # + `mermaid` (optional) to draw ```mermaid fences
|
|
8
|
+
````
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import { Prose } from "@godxjp/ui/data-display";
|
|
12
|
+
import { Markdown } from "@godxjp/markdown";
|
|
13
|
+
|
|
14
|
+
<Prose>
|
|
15
|
+
<Markdown>{page.body}</Markdown>
|
|
16
|
+
</Prose>;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it renders
|
|
20
|
+
|
|
21
|
+
GFM — tables, task lists (read-only), fenced code with `language-*` classes, autolinks, footnotes,
|
|
22
|
+
strikethrough — and an `id` on every heading (GitHub slugs, de-duplicated). The output is the same
|
|
23
|
+
as `react-markdown` + `remark-gfm` + `rehype-sanitize`, which is what the apps rendered before.
|
|
24
|
+
|
|
25
|
+
## Safety
|
|
26
|
+
|
|
27
|
+
- **No raw HTML.** HTML in the source is dropped, never parsed into elements.
|
|
28
|
+
- **One schema** (`markdownSchema`): links allow `http`, `https`, `mailto` and relative URLs;
|
|
29
|
+
images allow `http`, `https` and relative URLs. `javascript:`, `data:` and every other scheme are
|
|
30
|
+
removed — including a scheme split by tabs or newlines. SVG is never inlined from a URL.
|
|
31
|
+
- **Mermaid is a picture only after its SVG passes `checkMermaidSvg`**: no `<script>`,
|
|
32
|
+
`<foreignObject>`, `<a>`, `<image>`, event handlers, non-fragment `href`, `url()` that leaves the
|
|
33
|
+
document, or `@import`. A diagram that fails, or a host without `mermaid`, shows the code.
|
|
34
|
+
|
|
35
|
+
## Host extension points
|
|
36
|
+
|
|
37
|
+
| prop | use |
|
|
38
|
+
| ----------------------------------- | -------------------------------------------------------------------------------------- |
|
|
39
|
+
| `remarkPlugins` | Host markers (callouts, embeds). Their output is still sanitised. |
|
|
40
|
+
| `schema` | `{ tagNames, attributes }` the markers need. Additive: it cannot widen the URL policy. |
|
|
41
|
+
| `resolveUrl(url, key)` | Map a host scheme (`asset:…`) to a real URL before the sanitiser judges it. |
|
|
42
|
+
| `headingId({ depth, text, index })` | Use server-assigned anchors instead of slugs. |
|
|
43
|
+
| `components` | Element overrides; a host `pre` replaces the Mermaid default. |
|
|
44
|
+
| `rehypePlugins` | Run after the sanitiser — presentation only. |
|
|
45
|
+
| `mermaid={false}` | Keep ```mermaid fences as code. |
|
|
46
|
+
|
|
47
|
+
## Stored versions
|
|
48
|
+
|
|
49
|
+
Record `MARKDOWN_FORMAT` (`"md"`) and `RENDERER_VERSION` on every saved version.
|
|
50
|
+
`RENDERER_VERSION` changes when the same input renders differently.
|
|
51
|
+
|
|
52
|
+
## Fixtures
|
|
53
|
+
|
|
54
|
+
`MARKDOWN_FIXTURES` is the shared corpus: input Markdown → the exact sanitised HTML, with `forbidden`
|
|
55
|
+
substrings for the hostile ones. A server or export path conforms to this list.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Options as Options$1, Components } from 'react-markdown';
|
|
3
|
+
import { Root } from 'hast';
|
|
4
|
+
import { Options } from 'rehype-sanitize';
|
|
5
|
+
|
|
6
|
+
/** What a host is told about each heading, in document order. */
|
|
7
|
+
type HeadingInfo = {
|
|
8
|
+
depth: number;
|
|
9
|
+
text: string;
|
|
10
|
+
index: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Chooses a heading's anchor. Return `undefined` to give the heading no id.
|
|
14
|
+
*
|
|
15
|
+
* The default is GitHub's slug (`github-slugger`, the same rule `rehype-slug` uses), de-duplicated
|
|
16
|
+
* in document order (`a`, `a-1`, `a-2`). A host whose server already assigned anchors (godx-task
|
|
17
|
+
* keeps its outline server-side, so a link and the page agree) passes its own: computing "the same"
|
|
18
|
+
* slug in two languages is one rule written twice, and it drifts.
|
|
19
|
+
*/
|
|
20
|
+
type HeadingIdResolver = (heading: HeadingInfo) => string | undefined;
|
|
21
|
+
/** rehype plugin: an `id` on every heading. Runs BEFORE the sanitiser, which allows `id` on h1–h6. */
|
|
22
|
+
declare function rehypeHeadingIds(options?: {
|
|
23
|
+
resolve?: HeadingIdResolver;
|
|
24
|
+
}): (tree: Root) => void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* THE ONE SANITISER SCHEMA (gh#1108). godx-task and godx-content each carried their own copy of
|
|
28
|
+
* this; every consumer now renders through this one, so a policy change happens once.
|
|
29
|
+
*
|
|
30
|
+
* Built on `rehype-sanitize`'s `defaultSchema` (GitHub's), with three deliberate changes:
|
|
31
|
+
*
|
|
32
|
+
* - `clobberPrefix: ""`. The default re-prefixes every `id`, but remark-gfm already writes footnote
|
|
33
|
+
* ids and their `href`s as a matched `user-content-` pair, so the second prefix split the pair and
|
|
34
|
+
* footnote links went nowhere (measured in godx-task). The clobber exists to stop a body minting
|
|
35
|
+
* an id that collides with the host's; this renderer never parses raw HTML (no `rehype-raw`), so
|
|
36
|
+
* the only ids in the tree are the ones the pipeline generated. `raw-html.test` pins that.
|
|
37
|
+
* - `protocols`: links and images allow only `http`, `https`, `mailto` (links) and relative URLs.
|
|
38
|
+
* `javascript:`, `data:`, `vbscript:` and every other scheme are dropped.
|
|
39
|
+
* - Task-list checkboxes stay `disabled` (the default): ticking one would be a write to the body.
|
|
40
|
+
*
|
|
41
|
+
* Hosts extend it with `extendSchema`, never by editing a copy.
|
|
42
|
+
*/
|
|
43
|
+
declare const markdownSchema: Options;
|
|
44
|
+
/** Extra tags / attributes a host needs (a callout marker, a section element). Additive only. */
|
|
45
|
+
type SchemaExtension = {
|
|
46
|
+
tagNames?: string[];
|
|
47
|
+
attributes?: Record<string, NonNullable<Options["attributes"]>[string]>;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The schema plus a host's additions. ADDITIVE: an extension can allow a tag or an attribute, never
|
|
51
|
+
* widen `protocols` or bring raw HTML back, so a consumer cannot loosen the URL policy by accident.
|
|
52
|
+
* An attribute list for a tag the base already lists is appended to, not replaced.
|
|
53
|
+
*/
|
|
54
|
+
declare function extendSchema(extension?: SchemaExtension): Options;
|
|
55
|
+
/**
|
|
56
|
+
* react-markdown's `urlTransform`, with the same allow-list as the schema, so a URL is checked
|
|
57
|
+
* where it is written as well as where it is sanitised. Relative URLs (`/x`, `x`, `#a`, `?q`) pass;
|
|
58
|
+
* a scheme outside the list becomes `""`. A host scheme such as `asset:` is resolved by the host's
|
|
59
|
+
* `resolveUrl` BEFORE this check, so it can only turn into an allowed URL or be dropped.
|
|
60
|
+
*/
|
|
61
|
+
declare function safeUrl(url: string, key: string): string;
|
|
62
|
+
|
|
63
|
+
type PluggableList = NonNullable<Options$1["remarkPlugins"]>;
|
|
64
|
+
/**
|
|
65
|
+
* The stored body's format, and the renderer generation that produced its output. A consumer
|
|
66
|
+
* records both on every saved version (content-media contracts § 6.6) and never re-interprets an
|
|
67
|
+
* old version under a different format. `RENDERER_VERSION` moves when the SAME input renders
|
|
68
|
+
* differently — a schema change, a new default plugin — not on every release.
|
|
69
|
+
*/
|
|
70
|
+
declare const MARKDOWN_FORMAT = "md";
|
|
71
|
+
declare const RENDERER_VERSION = 1;
|
|
72
|
+
type MarkdownProps = {
|
|
73
|
+
/** The Markdown source. */
|
|
74
|
+
children: string;
|
|
75
|
+
/**
|
|
76
|
+
* remark plugins run after GFM (a callout or embed marker plugin). Whatever they emit still goes
|
|
77
|
+
* through the sanitiser, so a marker attribute must be allowed with `schema`.
|
|
78
|
+
*/
|
|
79
|
+
remarkPlugins?: PluggableList;
|
|
80
|
+
/**
|
|
81
|
+
* rehype plugins run AFTER the sanitiser, on a tree that is already safe. Presentation only: a
|
|
82
|
+
* plugin here must not introduce URLs, raw HTML or event handlers — it is past the gate.
|
|
83
|
+
*/
|
|
84
|
+
rehypePlugins?: PluggableList;
|
|
85
|
+
/** Tags / attributes a host plugin needs. Additive; cannot widen the URL policy. */
|
|
86
|
+
schema?: SchemaExtension;
|
|
87
|
+
/** Element overrides (react-markdown `components`). A host `pre` replaces the Mermaid default. */
|
|
88
|
+
components?: Components;
|
|
89
|
+
/**
|
|
90
|
+
* Resolves a host-specific URL (`asset:…`, a wiki link) to a real one. Runs before the sanitiser
|
|
91
|
+
* and the scheme allow-list, so whatever it returns is still checked; `undefined` keeps the URL.
|
|
92
|
+
*/
|
|
93
|
+
resolveUrl?: (url: string, key: "href" | "src" | string) => string | undefined;
|
|
94
|
+
/** Heading anchors: GitHub slugs by default; a host with server-side anchors supplies its own. */
|
|
95
|
+
headingId?: HeadingIdResolver;
|
|
96
|
+
/** Render ```mermaid fences as gated diagrams (default true). `false` keeps them as code. */
|
|
97
|
+
mermaid?: boolean;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* THE ONE RENDERER (gh#1108). GFM — tables, task lists, fenced code, autolinks, footnotes,
|
|
101
|
+
* strikethrough — plus heading anchors, sanitised by `markdownSchema`, with ```mermaid drawn only
|
|
102
|
+
* through the SVG gate. Raw HTML in the source is never parsed into elements (no `rehype-raw`): it
|
|
103
|
+
* stays visible text. Output matches `react-markdown` + `remark-gfm` + `rehype-sanitize`, which is
|
|
104
|
+
* what godx-task and godx-content rendered before, so the swap is drop-in.
|
|
105
|
+
*
|
|
106
|
+
* Renders bare elements; the host wraps it (`<Prose>` from `@godxjp/ui/data-display`), so this
|
|
107
|
+
* package carries no UI-kit dependency.
|
|
108
|
+
*/
|
|
109
|
+
declare function Markdown({ children, remarkPlugins, rehypePlugins, schema, components, resolveUrl, headingId, mermaid, }: MarkdownProps): React.JSX.Element;
|
|
110
|
+
|
|
111
|
+
type MermaidSvgCheck = {
|
|
112
|
+
ok: true;
|
|
113
|
+
svg: SVGSVGElement;
|
|
114
|
+
} | {
|
|
115
|
+
ok: false;
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Parses `svg` as XML and checks every element and attribute. On success returns the PARSED root,
|
|
120
|
+
* which the caller inserts as a node — never re-serialised into HTML, so what was checked is what
|
|
121
|
+
* is shown (an HTML re-parse of an SVG string can differ from the XML parse: mutation XSS).
|
|
122
|
+
*/
|
|
123
|
+
declare function checkMermaidSvg(svg: string): MermaidSvgCheck;
|
|
124
|
+
type MermaidDiagramProps = {
|
|
125
|
+
/** The fence's text. */
|
|
126
|
+
source: string;
|
|
127
|
+
/** Accessible name of the rendered diagram (the figure). */
|
|
128
|
+
label?: string;
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* A ```mermaid fence. Shows the code until the diagram has rendered AND passed `checkMermaidSvg`,
|
|
132
|
+
* and keeps showing it if either fails — so the page is never blank and never unsafe.
|
|
133
|
+
*/
|
|
134
|
+
declare function MermaidDiagram({ source, label }: MermaidDiagramProps): React.JSX.Element;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* THE SHARED FIXTURE CORPUS (gh#1108). Input Markdown → the exact sanitised HTML this renderer
|
|
138
|
+
* produces with its defaults. Browser, server and export paths (a PHP exporter included) conform to
|
|
139
|
+
* these rather than to each other: "same output" is checked against one list, not argued about.
|
|
140
|
+
*
|
|
141
|
+
* `hostile` fixtures carry an attack; `forbidden` lists substrings that must never appear in their
|
|
142
|
+
* output, so a schema change that re-opens one fails even if somebody re-blesses the HTML.
|
|
143
|
+
*
|
|
144
|
+
* Changing an expected `html` for an existing input is a renderer change: bump `RENDERER_VERSION`.
|
|
145
|
+
*
|
|
146
|
+
* The HTML is the renderer's, not React's: compare after removing the `<link rel="preload">` React's
|
|
147
|
+
* static renderer adds for each image (`normalizeFixtureHtml` in the tests).
|
|
148
|
+
*/
|
|
149
|
+
type MarkdownFixture = {
|
|
150
|
+
name: string;
|
|
151
|
+
markdown: string;
|
|
152
|
+
html: string;
|
|
153
|
+
hostile?: boolean;
|
|
154
|
+
forbidden?: string[];
|
|
155
|
+
};
|
|
156
|
+
declare const MARKDOWN_FIXTURES: MarkdownFixture[];
|
|
157
|
+
|
|
158
|
+
export { type HeadingIdResolver, type HeadingInfo, MARKDOWN_FIXTURES, MARKDOWN_FORMAT, Markdown, type MarkdownFixture, type MarkdownProps, MermaidDiagram, type MermaidDiagramProps, type MermaidSvgCheck, RENDERER_VERSION, type SchemaExtension, checkMermaidSvg, extendSchema, markdownSchema, rehypeHeadingIds, safeUrl };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/markdown.tsx
|
|
4
|
+
import * as React2 from "react";
|
|
5
|
+
import ReactMarkdown from "react-markdown";
|
|
6
|
+
import rehypeSanitize from "rehype-sanitize";
|
|
7
|
+
import remarkGfm from "remark-gfm";
|
|
8
|
+
import { visit as visit2 } from "unist-util-visit";
|
|
9
|
+
|
|
10
|
+
// src/headings.ts
|
|
11
|
+
import GithubSlugger from "github-slugger";
|
|
12
|
+
import { visit } from "unist-util-visit";
|
|
13
|
+
var HEADING = /^h([1-6])$/;
|
|
14
|
+
function textOf(node) {
|
|
15
|
+
if (node.type === "text") return node.value;
|
|
16
|
+
if ("children" in node) return node.children.map(textOf).join("");
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
function rehypeHeadingIds(options = {}) {
|
|
20
|
+
return (tree) => {
|
|
21
|
+
const slugger = new GithubSlugger();
|
|
22
|
+
let index = 0;
|
|
23
|
+
visit(tree, "element", (node) => {
|
|
24
|
+
const match = HEADING.exec(node.tagName);
|
|
25
|
+
if (!match) return;
|
|
26
|
+
if (node.properties?.id != null) return;
|
|
27
|
+
const text = textOf(node);
|
|
28
|
+
const info = { depth: Number(match[1]), text, index: index++ };
|
|
29
|
+
const id = options.resolve ? options.resolve(info) : slugger.slug(text);
|
|
30
|
+
if (id) node.properties = { ...node.properties, id };
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/mermaid.tsx
|
|
36
|
+
import * as React from "react";
|
|
37
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
38
|
+
var ALLOWED_ELEMENTS = /* @__PURE__ */ new Set([
|
|
39
|
+
"svg",
|
|
40
|
+
"g",
|
|
41
|
+
"path",
|
|
42
|
+
"rect",
|
|
43
|
+
"circle",
|
|
44
|
+
"ellipse",
|
|
45
|
+
"line",
|
|
46
|
+
"polyline",
|
|
47
|
+
"polygon",
|
|
48
|
+
"text",
|
|
49
|
+
"tspan",
|
|
50
|
+
"textpath",
|
|
51
|
+
"marker",
|
|
52
|
+
"defs",
|
|
53
|
+
"style",
|
|
54
|
+
"title",
|
|
55
|
+
"desc",
|
|
56
|
+
"lineargradient",
|
|
57
|
+
"radialgradient",
|
|
58
|
+
"stop",
|
|
59
|
+
"clippath",
|
|
60
|
+
"mask",
|
|
61
|
+
"pattern",
|
|
62
|
+
"symbol",
|
|
63
|
+
"use",
|
|
64
|
+
"filter",
|
|
65
|
+
"fegaussianblur",
|
|
66
|
+
"feoffset",
|
|
67
|
+
"feblend",
|
|
68
|
+
"feflood",
|
|
69
|
+
"fecomposite",
|
|
70
|
+
"femerge",
|
|
71
|
+
"femergenode",
|
|
72
|
+
"fedropshadow"
|
|
73
|
+
]);
|
|
74
|
+
var URL_REF = /url\s*\(\s*(['"]?)([^'")]*)\1\s*\)/gi;
|
|
75
|
+
var CSS_HAZARD = /@import|expression\s*\(|javascript:|behavior\s*:|-moz-binding|<\//i;
|
|
76
|
+
function urlsAreLocal(value) {
|
|
77
|
+
for (const match of value.matchAll(URL_REF)) {
|
|
78
|
+
if (!match[2].trim().startsWith("#")) return false;
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
function checkMermaidSvg(svg) {
|
|
83
|
+
if (typeof DOMParser === "undefined") return { ok: false, reason: "no DOMParser" };
|
|
84
|
+
const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
|
|
85
|
+
if (doc.getElementsByTagName("parsererror").length > 0) return { ok: false, reason: "parse" };
|
|
86
|
+
const root = doc.documentElement;
|
|
87
|
+
if (root.localName.toLowerCase() !== "svg") return { ok: false, reason: "root" };
|
|
88
|
+
const elements = [root, ...Array.from(root.getElementsByTagName("*"))];
|
|
89
|
+
for (const element of elements) {
|
|
90
|
+
const name = element.localName.toLowerCase();
|
|
91
|
+
if (!ALLOWED_ELEMENTS.has(name)) return { ok: false, reason: `element ${name}` };
|
|
92
|
+
if (name === "style") {
|
|
93
|
+
const css = element.textContent ?? "";
|
|
94
|
+
if (CSS_HAZARD.test(css) || !urlsAreLocal(css)) return { ok: false, reason: "style" };
|
|
95
|
+
}
|
|
96
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
97
|
+
const attr = attribute.localName.toLowerCase();
|
|
98
|
+
const value = attribute.value;
|
|
99
|
+
if (attr.startsWith("on")) return { ok: false, reason: `handler ${attr}` };
|
|
100
|
+
if ((attr === "href" || attr === "src") && !value.trim().startsWith("#")) {
|
|
101
|
+
return { ok: false, reason: `ref ${attr}` };
|
|
102
|
+
}
|
|
103
|
+
if (attr === "style" && CSS_HAZARD.test(value)) return { ok: false, reason: "style attr" };
|
|
104
|
+
if (!urlsAreLocal(value)) return { ok: false, reason: `url ${attr}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, svg: root };
|
|
108
|
+
}
|
|
109
|
+
var loading = null;
|
|
110
|
+
function loadMermaid() {
|
|
111
|
+
loading ??= import("mermaid").then((module) => {
|
|
112
|
+
const api = module.default;
|
|
113
|
+
api.initialize({
|
|
114
|
+
startOnLoad: false,
|
|
115
|
+
securityLevel: "strict",
|
|
116
|
+
// SVG text labels, not HTML in <foreignObject> — which the gate rejects outright.
|
|
117
|
+
htmlLabels: false,
|
|
118
|
+
flowchart: { htmlLabels: false },
|
|
119
|
+
theme: document.documentElement.dataset.theme === "dark" ? "dark" : "default"
|
|
120
|
+
});
|
|
121
|
+
return api;
|
|
122
|
+
}).catch(() => null);
|
|
123
|
+
return loading;
|
|
124
|
+
}
|
|
125
|
+
function MermaidCode({ source }) {
|
|
126
|
+
return /* @__PURE__ */ jsx("pre", { "data-mermaid": "code", children: /* @__PURE__ */ jsx("code", { className: "language-mermaid", children: source }) });
|
|
127
|
+
}
|
|
128
|
+
function MermaidDiagram({ source, label }) {
|
|
129
|
+
const host = React.useRef(null);
|
|
130
|
+
const id = `godx-mermaid-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
131
|
+
const [state, setState] = React.useState("code");
|
|
132
|
+
React.useEffect(() => {
|
|
133
|
+
let cancelled = false;
|
|
134
|
+
setState("code");
|
|
135
|
+
void (async () => {
|
|
136
|
+
const api = await loadMermaid();
|
|
137
|
+
if (!api || cancelled) return;
|
|
138
|
+
try {
|
|
139
|
+
const { svg } = await api.render(id, source);
|
|
140
|
+
const checked = checkMermaidSvg(svg);
|
|
141
|
+
if (cancelled || !checked.ok || !host.current) return;
|
|
142
|
+
host.current.replaceChildren(document.importNode(checked.svg, true));
|
|
143
|
+
setState("diagram");
|
|
144
|
+
} catch {
|
|
145
|
+
} finally {
|
|
146
|
+
document.getElementById(`d${id}`)?.remove();
|
|
147
|
+
}
|
|
148
|
+
})();
|
|
149
|
+
return () => {
|
|
150
|
+
cancelled = true;
|
|
151
|
+
};
|
|
152
|
+
}, [id, source]);
|
|
153
|
+
return /* @__PURE__ */ jsxs("figure", { "data-mermaid": state, "aria-label": state === "diagram" ? label : void 0, children: [
|
|
154
|
+
/* @__PURE__ */ jsx("div", { ref: host, hidden: state !== "diagram" }),
|
|
155
|
+
state === "code" ? /* @__PURE__ */ jsx(MermaidCode, { source }) : null
|
|
156
|
+
] });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/schema.ts
|
|
160
|
+
import { defaultSchema } from "rehype-sanitize";
|
|
161
|
+
var markdownSchema = {
|
|
162
|
+
...defaultSchema,
|
|
163
|
+
clobberPrefix: "",
|
|
164
|
+
protocols: {
|
|
165
|
+
...defaultSchema.protocols,
|
|
166
|
+
href: ["http", "https", "mailto"],
|
|
167
|
+
src: ["http", "https"],
|
|
168
|
+
cite: ["http", "https"]
|
|
169
|
+
},
|
|
170
|
+
attributes: {
|
|
171
|
+
...defaultSchema.attributes,
|
|
172
|
+
// Heading anchors (rehype-headings, below) and the code fence language.
|
|
173
|
+
h1: [...defaultSchema.attributes?.h1 ?? [], "id"],
|
|
174
|
+
h2: [...defaultSchema.attributes?.h2 ?? [], "id"],
|
|
175
|
+
h3: [...defaultSchema.attributes?.h3 ?? [], "id"],
|
|
176
|
+
h4: [...defaultSchema.attributes?.h4 ?? [], "id"],
|
|
177
|
+
h5: [...defaultSchema.attributes?.h5 ?? [], "id"],
|
|
178
|
+
h6: [...defaultSchema.attributes?.h6 ?? [], "id"]
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
function extendSchema(extension = {}) {
|
|
182
|
+
const attributes = { ...markdownSchema.attributes };
|
|
183
|
+
for (const [tag, list] of Object.entries(extension.attributes ?? {})) {
|
|
184
|
+
attributes[tag] = [...attributes[tag] ?? [], ...list];
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
...markdownSchema,
|
|
188
|
+
tagNames: [...markdownSchema.tagNames ?? [], ...extension.tagNames ?? []],
|
|
189
|
+
attributes
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
var SAFE_HREF = /^(https?:|mailto:)/i;
|
|
193
|
+
var SAFE_SRC = /^https?:/i;
|
|
194
|
+
var SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
195
|
+
function safeUrl(url, key) {
|
|
196
|
+
const value = url.trim();
|
|
197
|
+
const probe = value.replace(/[\x00-\x20\x7f]/g, "");
|
|
198
|
+
if (!SCHEME.test(probe)) return value;
|
|
199
|
+
if (key === "src") return SAFE_SRC.test(probe) ? value : "";
|
|
200
|
+
return SAFE_HREF.test(probe) ? value : "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/markdown.tsx
|
|
204
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
205
|
+
var MARKDOWN_FORMAT = "md";
|
|
206
|
+
var RENDERER_VERSION = 1;
|
|
207
|
+
var URL_ATTRIBUTES = {
|
|
208
|
+
a: "href",
|
|
209
|
+
img: "src",
|
|
210
|
+
blockquote: "cite",
|
|
211
|
+
q: "cite"
|
|
212
|
+
};
|
|
213
|
+
function rehypeResolveUrls(options) {
|
|
214
|
+
return (tree) => {
|
|
215
|
+
if (!options.resolve) return;
|
|
216
|
+
visit2(tree, "element", (node) => {
|
|
217
|
+
const key = URL_ATTRIBUTES[node.tagName];
|
|
218
|
+
const value = key ? node.properties?.[key] : void 0;
|
|
219
|
+
if (typeof value !== "string") return;
|
|
220
|
+
const resolved = options.resolve(value, key);
|
|
221
|
+
if (resolved !== void 0) node.properties = { ...node.properties, [key]: resolved };
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function fenceOf(node) {
|
|
226
|
+
const code = node?.children?.[0];
|
|
227
|
+
if (!code || code.type !== "element" || code.tagName !== "code") return void 0;
|
|
228
|
+
const classes = code.properties?.className ?? [];
|
|
229
|
+
const language = classes.find((name) => name.startsWith("language-"))?.slice(9);
|
|
230
|
+
const text = code.children.map((child) => child.type === "text" ? child.value : "").join("");
|
|
231
|
+
return { language, text };
|
|
232
|
+
}
|
|
233
|
+
function Markdown({
|
|
234
|
+
children,
|
|
235
|
+
remarkPlugins = [],
|
|
236
|
+
rehypePlugins = [],
|
|
237
|
+
schema,
|
|
238
|
+
components,
|
|
239
|
+
resolveUrl,
|
|
240
|
+
headingId,
|
|
241
|
+
mermaid = true
|
|
242
|
+
}) {
|
|
243
|
+
const sanitizeSchema = React2.useMemo(() => extendSchema(schema), [schema]);
|
|
244
|
+
const merged = React2.useMemo(() => {
|
|
245
|
+
const base = {};
|
|
246
|
+
if (mermaid) {
|
|
247
|
+
base.pre = ({ node, children: inner, ...props }) => {
|
|
248
|
+
const fence = fenceOf(node);
|
|
249
|
+
if (fence?.language === "mermaid") return /* @__PURE__ */ jsx2(MermaidDiagram, { source: fence.text });
|
|
250
|
+
return /* @__PURE__ */ jsx2("pre", { ...props, children: inner });
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { ...base, ...components };
|
|
254
|
+
}, [components, mermaid]);
|
|
255
|
+
return /* @__PURE__ */ jsx2(
|
|
256
|
+
ReactMarkdown,
|
|
257
|
+
{
|
|
258
|
+
remarkPlugins: [remarkGfm, ...remarkPlugins],
|
|
259
|
+
rehypePlugins: [
|
|
260
|
+
[rehypeResolveUrls, { resolve: resolveUrl }],
|
|
261
|
+
[rehypeHeadingIds, { resolve: headingId }],
|
|
262
|
+
[rehypeSanitize, sanitizeSchema],
|
|
263
|
+
...rehypePlugins
|
|
264
|
+
],
|
|
265
|
+
urlTransform: safeUrl,
|
|
266
|
+
components: merged,
|
|
267
|
+
children
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/fixtures.ts
|
|
273
|
+
var MARKDOWN_FIXTURES = [
|
|
274
|
+
{
|
|
275
|
+
name: "headings get GitHub anchors, de-duplicated",
|
|
276
|
+
markdown: "# \u6982\u8981\n\n## Setup\n\n## Setup",
|
|
277
|
+
html: '<h1 id="\u6982\u8981">\u6982\u8981</h1>\n<h2 id="setup">Setup</h2>\n<h2 id="setup-1">Setup</h2>'
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
name: "emphasis, strong, strikethrough, inline code",
|
|
281
|
+
markdown: "*em* **strong** ~~gone~~ `code`",
|
|
282
|
+
html: "<p><em>em</em> <strong>strong</strong> <del>gone</del> <code>code</code></p>"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
name: "GFM table with alignment",
|
|
286
|
+
markdown: "| a | b |\n|:--|--:|\n| 1 | 2 |",
|
|
287
|
+
html: '<table><thead><tr><th style="text-align:left">a</th><th style="text-align:right">b</th></tr></thead><tbody><tr><td style="text-align:left">1</td><td style="text-align:right">2</td></tr></tbody></table>'
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: "task list stays read-only",
|
|
291
|
+
markdown: "- [x] done\n- [ ] todo",
|
|
292
|
+
html: '<ul class="contains-task-list">\n<li class="task-list-item"><input type="checkbox" disabled="" checked=""/> done</li>\n<li class="task-list-item"><input type="checkbox" disabled=""/> todo</li>\n</ul>'
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
name: "fenced code keeps its language class",
|
|
296
|
+
markdown: "```ts\nconst a = 1;\n```",
|
|
297
|
+
html: '<pre><code class="language-ts">const a = 1;\n</code></pre>'
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "links and autolinks",
|
|
301
|
+
markdown: "[docs](https://example.com/a) <https://example.com> mail@example.com",
|
|
302
|
+
html: '<p><a href="https://example.com/a">docs</a> <a href="https://example.com">https://example.com</a> <a href="mailto:mail@example.com">mail@example.com</a></p>'
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "relative and fragment links pass",
|
|
306
|
+
markdown: "[a](/wiki/Page) [b](#setup) [c](other)",
|
|
307
|
+
html: '<p><a href="/wiki/Page">a</a> <a href="#setup">b</a> <a href="other">c</a></p>'
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: "image by URL",
|
|
311
|
+
markdown: "",
|
|
312
|
+
html: '<p><img src="https://cdn.example.com/c.png" alt="chart"/></p>'
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: "footnote link and its target agree",
|
|
316
|
+
markdown: "Text[^1]\n\n[^1]: Note.",
|
|
317
|
+
html: '<p>Text<sup><a href="#user-content-fn-1" id="user-content-fnref-1" data-footnote-ref="true" aria-describedby="footnote-label">1</a></sup></p>\n<section data-footnotes="true" class="footnotes"><h2 class="sr-only" id="footnote-label">Footnotes</h2>\n<ol>\n<li id="user-content-fn-1">\n<p>Note. <a href="#user-content-fnref-1" data-footnote-backref="" aria-label="Back to reference 1" class="data-footnote-backref">\u21A9</a></p>\n</li>\n</ol>\n</section>'
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "mermaid fence before the diagram has rendered",
|
|
321
|
+
markdown: "```mermaid\ngraph TD; A-->B\n```",
|
|
322
|
+
html: '<figure data-mermaid="code"><div hidden=""></div><pre data-mermaid="code"><code class="language-mermaid">graph TD; A-->B\n</code></pre></figure>'
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: "raw HTML blocks are dropped, never parsed",
|
|
326
|
+
hostile: true,
|
|
327
|
+
markdown: '<script>alert(1)</script>\n\n<img src=x onerror="alert(1)">\n\n<div id="app-sidebar">x</div>',
|
|
328
|
+
html: "\n\n",
|
|
329
|
+
forbidden: ["<script", "<img", "onerror", "app-sidebar"]
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: "inline raw HTML is dropped, its text kept",
|
|
333
|
+
hostile: true,
|
|
334
|
+
markdown: 'a <b onclick="alert(1)">bold</b> <iframe src="https://evil.example"></iframe> z',
|
|
335
|
+
html: "<p>a bold z</p>",
|
|
336
|
+
forbidden: ["<b", "onclick", "<iframe", "evil.example"]
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
name: "javascript: and data: links are dropped",
|
|
340
|
+
hostile: true,
|
|
341
|
+
markdown: "[a](javascript:alert(1)) [b](JAVASCRIPT:alert(1)) [c](data:text/html,<script>alert(1)</script>) [d](vbscript:x)",
|
|
342
|
+
html: "<p><a>a</a> <a>b</a> <a>c</a> <a>d</a></p>",
|
|
343
|
+
forbidden: ["javascript:", "JAVASCRIPT:", "data:", "vbscript:"]
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "a scheme split by control characters is dropped",
|
|
347
|
+
hostile: true,
|
|
348
|
+
markdown: "[a](<java script:alert(1)>)",
|
|
349
|
+
html: "<p><a>a</a></p>",
|
|
350
|
+
forbidden: ["script:"]
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: "data: and javascript: images are dropped",
|
|
354
|
+
hostile: true,
|
|
355
|
+
markdown: " )",
|
|
356
|
+
html: '<p><img alt="x"/> <img alt="y"/></p>',
|
|
357
|
+
forbidden: ["data:", "javascript:"]
|
|
358
|
+
}
|
|
359
|
+
];
|
|
360
|
+
export {
|
|
361
|
+
MARKDOWN_FIXTURES,
|
|
362
|
+
MARKDOWN_FORMAT,
|
|
363
|
+
Markdown,
|
|
364
|
+
MermaidDiagram,
|
|
365
|
+
RENDERER_VERSION,
|
|
366
|
+
checkMermaidSvg,
|
|
367
|
+
extendSchema,
|
|
368
|
+
markdownSchema,
|
|
369
|
+
rehypeHeadingIds,
|
|
370
|
+
safeUrl
|
|
371
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/markdown",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "31.18.0",
|
|
4
|
+
"description": "The one Markdown renderer for GoDX apps: GFM + heading anchors, one sanitiser schema, a shared fixture corpus, and Mermaid drawn only after its SVG passes a fail-closed gate.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"registry": "https://registry.npmjs.org/",
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/godx-jp/godxjp-ui.git",
|
|
28
|
+
"directory": "packages/markdown"
|
|
29
|
+
},
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"github-slugger": "^2.0.0",
|
|
33
|
+
"react-markdown": "^10.1.0",
|
|
34
|
+
"rehype-sanitize": "^6.0.0",
|
|
35
|
+
"remark-gfm": "^4.0.1",
|
|
36
|
+
"unist-util-visit": "^5.1.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"mermaid": "^11.0.0",
|
|
40
|
+
"react": ">=19.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"mermaid": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"markdown",
|
|
49
|
+
"gfm",
|
|
50
|
+
"sanitize",
|
|
51
|
+
"mermaid",
|
|
52
|
+
"godxjp"
|
|
53
|
+
]
|
|
54
|
+
}
|