@suzumiyaaoba/mdxr 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2902 -0
- package/dist/components.d.mts +791 -0
- package/dist/components.mjs +2 -0
- package/dist/config-SI9IyFiC.mjs +184 -0
- package/dist/doc-context-CEqzMYKv.d.mts +568 -0
- package/dist/index.d.mts +20 -0
- package/dist/index.mjs +4 -0
- package/dist/ui-GKFD0mx5.mjs +15986 -0
- package/package.json +131 -0
- package/skill/SKILL.md +49 -0
- package/skill/references/components/charts.md +172 -0
- package/skill/references/components/document.md +98 -0
- package/skill/references/components/forms.md +35 -0
- package/skill/references/components/investigation.md +198 -0
- package/skill/references/components/layout.md +68 -0
- package/skill/references/components/output.md +158 -0
- package/skill/references/components/planning.md +155 -0
- package/skill/references/components/reports.md +252 -0
- package/skill/references/components/shadcn.md +19 -0
- package/skill/references/components.md +186 -0
- package/skill/references/extending.md +48 -0
- package/src/ask-sheet.ts +40 -0
- package/src/assets/css.ts +392 -0
- package/src/assets/scripts.ts +78 -0
- package/src/catalog.ts +287 -0
- package/src/cli.ts +178 -0
- package/src/client/doc-events.ts +1108 -0
- package/src/client/entry.ts +27 -0
- package/src/client-js.ts +47 -0
- package/src/component-map.ts +47 -0
- package/src/components/ui/accordion.tsx +77 -0
- package/src/components/ui/alert-dialog.tsx +185 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +208 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +52 -0
- package/src/components/ui/breadcrumb.tsx +121 -0
- package/src/components/ui/bubble.tsx +128 -0
- package/src/components/ui/button-group.tsx +88 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/calendar.tsx +226 -0
- package/src/components/ui/card.tsx +102 -0
- package/src/components/ui/carousel.tsx +246 -0
- package/src/components/ui/chart.tsx +379 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/combobox.tsx +298 -0
- package/src/components/ui/command.tsx +193 -0
- package/src/components/ui/context-menu.tsx +271 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +4 -0
- package/src/components/ui/drawer.tsx +227 -0
- package/src/components/ui/dropdown-menu.tsx +269 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +237 -0
- package/src/components/ui/fieldset.tsx +32 -0
- package/src/components/ui/frame.tsx +87 -0
- package/src/components/ui/hover-card.tsx +50 -0
- package/src/components/ui/input-group.tsx +159 -0
- package/src/components/ui/input-otp.tsx +83 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +202 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/marker.tsx +71 -0
- package/src/components/ui/menubar.tsx +284 -0
- package/src/components/ui/message-scroller.tsx +128 -0
- package/src/components/ui/message.tsx +91 -0
- package/src/components/ui/meter.tsx +80 -0
- package/src/components/ui/native-select.tsx +64 -0
- package/src/components/ui/navigation-menu.tsx +170 -0
- package/src/components/ui/pagination.tsx +133 -0
- package/src/components/ui/popover.tsx +87 -0
- package/src/components/ui/progress.tsx +82 -0
- package/src/components/ui/questionnaire.tsx +328 -0
- package/src/components/ui/radio-group.tsx +35 -0
- package/src/components/ui/resizable.tsx +49 -0
- package/src/components/ui/scroll-area.tsx +50 -0
- package/src/components/ui/select.tsx +201 -0
- package/src/components/ui/separator.tsx +22 -0
- package/src/components/ui/sheet.tsx +135 -0
- package/src/components/ui/sidebar.tsx +730 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +51 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toast.tsx +229 -0
- package/src/components/ui/toggle-group.tsx +87 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +65 -0
- package/src/components.ts +78 -0
- package/src/config.ts +56 -0
- package/src/define.ts +122 -0
- package/src/doc-context.ts +20 -0
- package/src/editor.ts +94 -0
- package/src/format-error.ts +78 -0
- package/src/guards.ts +84 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/html.ts +91 -0
- package/src/hydrate/export-index.ts +232 -0
- package/src/hydrate/import-scan.ts +169 -0
- package/src/hydrate/plugins.ts +118 -0
- package/src/hydrate/runtime-module.ts +145 -0
- package/src/hydrate-runtime.ts +67 -0
- package/src/hydrate.ts +147 -0
- package/src/index.ts +16 -0
- package/src/init.ts +57 -0
- package/src/langs.ts +148 -0
- package/src/lines.ts +53 -0
- package/src/load-user-module.ts +191 -0
- package/src/mdx.ts +248 -0
- package/src/paths.ts +36 -0
- package/src/rehype/shiki.ts +533 -0
- package/src/remark/alerts.ts +53 -0
- package/src/remark/ast.ts +96 -0
- package/src/remark/callouts.ts +25 -0
- package/src/remark/code-file.ts +85 -0
- package/src/remark/code-meta.ts +21 -0
- package/src/remark/directives.ts +170 -0
- package/src/remark/file-paths.ts +64 -0
- package/src/remark/headings.ts +131 -0
- package/src/remark/no-js.ts +40 -0
- package/src/render.ts +337 -0
- package/src/serve.ts +322 -0
- package/src/styles/globals.css +134 -0
- package/src/styles/shadcn.css +641 -0
- package/src/tailwind.ts +119 -0
- package/src/ui/approvals.tsx +76 -0
- package/src/ui/ask-question.tsx +386 -0
- package/src/ui/ask.tsx +206 -0
- package/src/ui/attrs.ts +51 -0
- package/src/ui/audit.tsx +139 -0
- package/src/ui/bar-chart.tsx +334 -0
- package/src/ui/benchmarks.tsx +143 -0
- package/src/ui/bits.tsx +537 -0
- package/src/ui/board.tsx +173 -0
- package/src/ui/bridge.tsx +207 -0
- package/src/ui/bumps.tsx +178 -0
- package/src/ui/callout.tsx +106 -0
- package/src/ui/changes.tsx +89 -0
- package/src/ui/chart-bits.tsx +52 -0
- package/src/ui/chart.ts +577 -0
- package/src/ui/checks.tsx +203 -0
- package/src/ui/child-index.tsx +44 -0
- package/src/ui/children.ts +43 -0
- package/src/ui/chips.tsx +49 -0
- package/src/ui/cmd.tsx +27 -0
- package/src/ui/columns.tsx +31 -0
- package/src/ui/comments.tsx +544 -0
- package/src/ui/compare.tsx +67 -0
- package/src/ui/decision.tsx +79 -0
- package/src/ui/deps.tsx +78 -0
- package/src/ui/details.tsx +43 -0
- package/src/ui/diff-parse.ts +307 -0
- package/src/ui/diff.tsx +458 -0
- package/src/ui/diffstat.tsx +58 -0
- package/src/ui/due.tsx +65 -0
- package/src/ui/effort.tsx +29 -0
- package/src/ui/endpoints.tsx +118 -0
- package/src/ui/envvars.tsx +96 -0
- package/src/ui/figure.tsx +37 -0
- package/src/ui/file-icon.ts +1020 -0
- package/src/ui/file-link.ts +32 -0
- package/src/ui/file-ref.tsx +39 -0
- package/src/ui/files.tsx +112 -0
- package/src/ui/findings.tsx +85 -0
- package/src/ui/flow.tsx +73 -0
- package/src/ui/funnel.tsx +121 -0
- package/src/ui/gantt.tsx +443 -0
- package/src/ui/gauges.tsx +135 -0
- package/src/ui/glossary.tsx +29 -0
- package/src/ui/graph-layout.ts +149 -0
- package/src/ui/graph-specs.tsx +102 -0
- package/src/ui/graph.tsx +278 -0
- package/src/ui/grid.tsx +119 -0
- package/src/ui/hypothesis.tsx +94 -0
- package/src/ui/icon.tsx +80 -0
- package/src/ui/incident.tsx +130 -0
- package/src/ui/index.ts +342 -0
- package/src/ui/ins-del.tsx +41 -0
- package/src/ui/json.tsx +190 -0
- package/src/ui/layout.ts +9 -0
- package/src/ui/line-chart.tsx +251 -0
- package/src/ui/matrix.tsx +208 -0
- package/src/ui/meta.tsx +73 -0
- package/src/ui/option.tsx +64 -0
- package/src/ui/owner.tsx +40 -0
- package/src/ui/packages.tsx +104 -0
- package/src/ui/pathway.tsx +96 -0
- package/src/ui/phase.tsx +41 -0
- package/src/ui/pie-chart.tsx +170 -0
- package/src/ui/plan.tsx +77 -0
- package/src/ui/pre.tsx +175 -0
- package/src/ui/priority.tsx +51 -0
- package/src/ui/props.tsx +77 -0
- package/src/ui/quadrant.tsx +172 -0
- package/src/ui/radar.tsx +200 -0
- package/src/ui/ref.tsx +160 -0
- package/src/ui/release.tsx +178 -0
- package/src/ui/req.tsx +42 -0
- package/src/ui/review.tsx +133 -0
- package/src/ui/risk.tsx +67 -0
- package/src/ui/sankey.tsx +287 -0
- package/src/ui/scatter.tsx +237 -0
- package/src/ui/schema.tsx +113 -0
- package/src/ui/score.tsx +105 -0
- package/src/ui/search.tsx +120 -0
- package/src/ui/series.tsx +38 -0
- package/src/ui/severity.tsx +69 -0
- package/src/ui/shadcn.tsx +216 -0
- package/src/ui/spark.tsx +86 -0
- package/src/ui/stack.tsx +57 -0
- package/src/ui/stats.tsx +63 -0
- package/src/ui/status-badge.tsx +66 -0
- package/src/ui/statuspage.tsx +238 -0
- package/src/ui/steps.tsx +74 -0
- package/src/ui/summary.tsx +41 -0
- package/src/ui/symbol-ref.tsx +73 -0
- package/src/ui/terminal.tsx +117 -0
- package/src/ui/tests.tsx +218 -0
- package/src/ui/timeline.tsx +63 -0
- package/src/ui/toc.tsx +56 -0
- package/src/ui/tones.ts +187 -0
- package/src/ui/trace.tsx +69 -0
- package/src/ui/tree.tsx +281 -0
- package/src/ui/treemap.tsx +128 -0
- package/src/ui/venn.tsx +258 -0
- package/src/ui/verdict.tsx +78 -0
- package/src/ui/waterfall.tsx +142 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import {
|
|
2
|
+
transformerMetaHighlight,
|
|
3
|
+
transformerMetaWordHighlight,
|
|
4
|
+
transformerNotationDiff,
|
|
5
|
+
transformerNotationErrorLevel,
|
|
6
|
+
transformerNotationFocus,
|
|
7
|
+
transformerNotationHighlight,
|
|
8
|
+
transformerNotationWordHighlight,
|
|
9
|
+
transformerRemoveNotationEscape,
|
|
10
|
+
} from "@shikijs/transformers";
|
|
11
|
+
import type { Element, ElementContent, Root } from "hast";
|
|
12
|
+
import { createHighlighter } from "shiki";
|
|
13
|
+
import type { Highlighter, LanguageInput, ShikiTransformer } from "shiki";
|
|
14
|
+
import { visit } from "unist-util-visit";
|
|
15
|
+
|
|
16
|
+
import { isRecord, own } from "../guards.js";
|
|
17
|
+
import { langForPath, SKIP_LANGS } from "../langs.js";
|
|
18
|
+
import { fenceFilename, fenceLang } from "../lines.js";
|
|
19
|
+
import { parseDiff } from "../ui/diff-parse.js";
|
|
20
|
+
import type {
|
|
21
|
+
DiffHl,
|
|
22
|
+
DiffHlToken,
|
|
23
|
+
DiffRow,
|
|
24
|
+
FileDiff,
|
|
25
|
+
} from "../ui/diff-parse.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fenced code blocks are syntax-highlighted with shiki at render time.
|
|
29
|
+
* `defaultColor: false` emits `--shiki-light`/`--shiki-dark` CSS variables on
|
|
30
|
+
* every token span; BASE_CSS switches between them via prefers-color-scheme.
|
|
31
|
+
* SKIP_LANGS (langs.ts) are left untouched: mermaid renders as a diagram,
|
|
32
|
+
* terminal langs as a transcript, plain-text aliases stay cheap.
|
|
33
|
+
*/
|
|
34
|
+
const THEMES = { dark: "github-dark", light: "github-light" } as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A middle ground between startup cost and coverage: grammars in this list are
|
|
38
|
+
* loaded with the highlighter, anything else in shiki's bundle is loaded on
|
|
39
|
+
* first use, and unknown names degrade to unhighlighted code.
|
|
40
|
+
*/
|
|
41
|
+
const PRELOADED_LANGS = [
|
|
42
|
+
"bash",
|
|
43
|
+
"c",
|
|
44
|
+
"cpp",
|
|
45
|
+
"csharp",
|
|
46
|
+
"css",
|
|
47
|
+
"diff",
|
|
48
|
+
"dockerfile",
|
|
49
|
+
"go",
|
|
50
|
+
"graphql",
|
|
51
|
+
"html",
|
|
52
|
+
"ini",
|
|
53
|
+
"java",
|
|
54
|
+
"javascript",
|
|
55
|
+
"json",
|
|
56
|
+
"jsonc",
|
|
57
|
+
"jsx",
|
|
58
|
+
"kotlin",
|
|
59
|
+
"lua",
|
|
60
|
+
"markdown",
|
|
61
|
+
"php",
|
|
62
|
+
"python",
|
|
63
|
+
"ruby",
|
|
64
|
+
"rust",
|
|
65
|
+
"scss",
|
|
66
|
+
"shell",
|
|
67
|
+
"sql",
|
|
68
|
+
"swift",
|
|
69
|
+
"toml",
|
|
70
|
+
"tsx",
|
|
71
|
+
"typescript",
|
|
72
|
+
"xml",
|
|
73
|
+
"yaml",
|
|
74
|
+
"zsh",
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fence-meta tokens that turn line numbers on: ` ```ts ln ` (also accepts
|
|
79
|
+
* `line-numbers` / `lineNumbers` / `showLineNumbers`).
|
|
80
|
+
*/
|
|
81
|
+
const LINE_NUMBER_RE =
|
|
82
|
+
/(?:^|\s)(?:ln|line-numbers|lineNumbers|showLineNumbers)(?:\s|$)/u;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Marks shiki's `<pre>` with `has-line-numbers` when the fence meta asks for
|
|
86
|
+
* them; BASE_CSS draws the numbers with a counter so copy stays clean.
|
|
87
|
+
*/
|
|
88
|
+
const transformerLineNumbers: ShikiTransformer = {
|
|
89
|
+
name: "mdxr:line-numbers",
|
|
90
|
+
pre(node) {
|
|
91
|
+
const raw = this.options.meta?.__raw ?? "";
|
|
92
|
+
if (LINE_NUMBER_RE.test(raw)) {
|
|
93
|
+
this.addClassToHast(node, "has-line-numbers");
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Transformers shared by every highlighted block. The notation set strips
|
|
100
|
+
* `// [!code …]` markers from the output and tags lines/spans; the meta set
|
|
101
|
+
* reads the fence info string (`{1,3-5}` line ranges, `/word/` matches).
|
|
102
|
+
*/
|
|
103
|
+
const TRANSFORMERS: ShikiTransformer[] = [
|
|
104
|
+
transformerNotationDiff(),
|
|
105
|
+
transformerNotationErrorLevel(),
|
|
106
|
+
transformerNotationFocus(),
|
|
107
|
+
transformerNotationHighlight(),
|
|
108
|
+
transformerNotationWordHighlight(),
|
|
109
|
+
transformerRemoveNotationEscape(),
|
|
110
|
+
transformerMetaHighlight(),
|
|
111
|
+
transformerMetaWordHighlight(),
|
|
112
|
+
transformerLineNumbers,
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
let highlighterPromise: Promise<Highlighter> | undefined;
|
|
116
|
+
|
|
117
|
+
const getHighlighter = async (): Promise<Highlighter> => {
|
|
118
|
+
highlighterPromise ??= createHighlighter({
|
|
119
|
+
langs: [...PRELOADED_LANGS],
|
|
120
|
+
themes: [THEMES.light, THEMES.dark],
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
return await highlighterPromise;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// A rejected promise would otherwise poison every later render in this
|
|
126
|
+
// process — clear it so a transient init failure can retry.
|
|
127
|
+
highlighterPromise = undefined;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const DIFF_FENCE_LANGS: ReadonlySet<string> = new Set(["diff", "patch"]);
|
|
133
|
+
|
|
134
|
+
const isElement = (node: unknown): node is Element =>
|
|
135
|
+
isRecord(node) && node.type === "element" && typeof node.tagName === "string";
|
|
136
|
+
|
|
137
|
+
/** mdast-sourced nodes use `className`; shiki's hast uses `class`. Read both. */
|
|
138
|
+
const classNames = (el: Element | undefined): string[] => {
|
|
139
|
+
const c = el?.properties?.className ?? el?.properties?.class;
|
|
140
|
+
if (typeof c === "string") {
|
|
141
|
+
return c.split(/\s+/u).filter(Boolean);
|
|
142
|
+
}
|
|
143
|
+
return Array.isArray(c) ? c.map(String) : [];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const textContent = (node: Element | ElementContent): string => {
|
|
147
|
+
if (node.type === "text") {
|
|
148
|
+
return node.value;
|
|
149
|
+
}
|
|
150
|
+
return "children" in node ? node.children.map(textContent).join("") : "";
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const languageOf = (code: Element): string | undefined =>
|
|
154
|
+
/language-(?<lang>[^\s]+)/u.exec(classNames(code).join(" "))?.groups?.lang;
|
|
155
|
+
|
|
156
|
+
/** In-flight grammar loads, deduplicated across concurrent code blocks. */
|
|
157
|
+
const langLoads = new Map<string, Promise<boolean>>();
|
|
158
|
+
|
|
159
|
+
/** Lazily loads a grammar; resolves false when shiki doesn't know the name. */
|
|
160
|
+
const ensureLanguage = async (
|
|
161
|
+
highlighter: Highlighter,
|
|
162
|
+
lang: string
|
|
163
|
+
): Promise<boolean> => {
|
|
164
|
+
if (highlighter.getLoadedLanguages().includes(lang)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
const pending = langLoads.get(lang);
|
|
168
|
+
if (pending !== undefined) {
|
|
169
|
+
return await pending;
|
|
170
|
+
}
|
|
171
|
+
const load = (async () => {
|
|
172
|
+
const bundled: Record<string, LanguageInput> =
|
|
173
|
+
highlighter.getBundledLanguages();
|
|
174
|
+
// own-property lookup: a fence like ```toString would otherwise hand
|
|
175
|
+
// Object.prototype.toString to loadLanguage.
|
|
176
|
+
const loader = own(bundled, lang);
|
|
177
|
+
if (loader === undefined) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
await highlighter.loadLanguage(loader);
|
|
181
|
+
return highlighter.getLoadedLanguages().includes(lang);
|
|
182
|
+
})();
|
|
183
|
+
langLoads.set(lang, load);
|
|
184
|
+
try {
|
|
185
|
+
return await load;
|
|
186
|
+
} finally {
|
|
187
|
+
langLoads.delete(lang);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The highlighter when `lang` can be handled — undefined for SKIP_LANGS and
|
|
193
|
+
* names shiki doesn't know, so callers degrade to unhighlighted code.
|
|
194
|
+
*/
|
|
195
|
+
const readyHighlighter = async (
|
|
196
|
+
lang: string
|
|
197
|
+
): Promise<Highlighter | undefined> => {
|
|
198
|
+
if (SKIP_LANGS.has(lang)) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const highlighter = await getHighlighter();
|
|
203
|
+
return (await ensureLanguage(highlighter, lang)) ? highlighter : undefined;
|
|
204
|
+
} catch {
|
|
205
|
+
// Highlighter init/grammar-load failure degrades to unhighlighted code
|
|
206
|
+
// rather than failing the whole document render.
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Highlight `text` as `lang`, returning the inner HTML of shiki's `<code>`
|
|
213
|
+
* element — the same token spans rehypeShiki grafts onto the `code` node.
|
|
214
|
+
* Used by the Storybook preview, where component stories bypass the rehype
|
|
215
|
+
* pipeline; kept here so both paths share the highlighter and themes.
|
|
216
|
+
*/
|
|
217
|
+
export const highlightToHtml = async (
|
|
218
|
+
lang: string,
|
|
219
|
+
text: string
|
|
220
|
+
): Promise<string | undefined> => {
|
|
221
|
+
const highlighter = await readyHighlighter(lang);
|
|
222
|
+
if (highlighter === undefined) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const html = highlighter.codeToHtml(text.replace(/\n$/u, ""), {
|
|
226
|
+
defaultColor: false,
|
|
227
|
+
lang,
|
|
228
|
+
themes: THEMES,
|
|
229
|
+
transformers: TRANSFORMERS,
|
|
230
|
+
});
|
|
231
|
+
return /<code[^>]*>(?<inner>[\s\S]*?)<\/code>/u.exec(html)?.groups?.inner;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One side of a hunk, highlighted as the file's own language: `ctx`/`add`
|
|
236
|
+
* rows read from the new side, `ctx`/`del` rows from the old side. Shiki's
|
|
237
|
+
* output line array aligns 1:1 with the joined row texts (rows never
|
|
238
|
+
* contain `\n`).
|
|
239
|
+
*/
|
|
240
|
+
const sideTokens = (
|
|
241
|
+
highlighter: Highlighter,
|
|
242
|
+
lang: string,
|
|
243
|
+
src: string
|
|
244
|
+
): DiffHlToken[][] => {
|
|
245
|
+
if (src === "") {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
const hast = highlighter.codeToHast(src, {
|
|
249
|
+
defaultColor: false,
|
|
250
|
+
lang,
|
|
251
|
+
themes: THEMES,
|
|
252
|
+
});
|
|
253
|
+
const pre = hast.children.find(
|
|
254
|
+
(child): child is Element => isElement(child) && child.tagName === "pre"
|
|
255
|
+
);
|
|
256
|
+
const code = pre?.children.find(
|
|
257
|
+
(child): child is Element => isElement(child) && child.tagName === "code"
|
|
258
|
+
);
|
|
259
|
+
if (code === undefined) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const lines: DiffHlToken[][] = [];
|
|
263
|
+
for (const child of code.children) {
|
|
264
|
+
if (
|
|
265
|
+
!isElement(child) ||
|
|
266
|
+
child.tagName !== "span" ||
|
|
267
|
+
!classNames(child).includes("line")
|
|
268
|
+
) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
lines.push(
|
|
272
|
+
child.children.flatMap((tok): DiffHlToken[] => {
|
|
273
|
+
if (tok.type === "text") {
|
|
274
|
+
return tok.value === "" ? [] : [{ t: tok.value }];
|
|
275
|
+
}
|
|
276
|
+
if (!isElement(tok)) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const style = tok.properties?.style;
|
|
280
|
+
return [
|
|
281
|
+
{
|
|
282
|
+
s: typeof style === "string" && style !== "" ? style : undefined,
|
|
283
|
+
t: textContent(tok),
|
|
284
|
+
},
|
|
285
|
+
];
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return lines;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** A resolved (highlighter, lang) pair for one side of a file diff. */
|
|
293
|
+
interface DiffSide {
|
|
294
|
+
highlighter: Highlighter;
|
|
295
|
+
lang: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Token lines per row of one hunk — `null` for note rows and for rows whose
|
|
299
|
+
* side had no usable grammar. Add/ctx rows read the new-side highlight, del
|
|
300
|
+
* rows the old-side one (the sides can carry different languages after a
|
|
301
|
+
* cross-extension rename). */
|
|
302
|
+
const hunkTokens = (
|
|
303
|
+
sides: { new?: DiffSide; old?: DiffSide },
|
|
304
|
+
rows: DiffRow[]
|
|
305
|
+
): (DiffHlToken[] | null)[] => {
|
|
306
|
+
const nt =
|
|
307
|
+
sides.new === undefined
|
|
308
|
+
? []
|
|
309
|
+
: sideTokens(
|
|
310
|
+
sides.new.highlighter,
|
|
311
|
+
sides.new.lang,
|
|
312
|
+
rows
|
|
313
|
+
.filter((r) => r.kind === "ctx" || r.kind === "add")
|
|
314
|
+
.map((r) => r.text)
|
|
315
|
+
.join("\n")
|
|
316
|
+
);
|
|
317
|
+
const ot =
|
|
318
|
+
sides.old === undefined
|
|
319
|
+
? []
|
|
320
|
+
: sideTokens(
|
|
321
|
+
sides.old.highlighter,
|
|
322
|
+
sides.old.lang,
|
|
323
|
+
rows
|
|
324
|
+
.filter((r) => r.kind === "ctx" || r.kind === "del")
|
|
325
|
+
.map((r) => r.text)
|
|
326
|
+
.join("\n")
|
|
327
|
+
);
|
|
328
|
+
let ni = 0;
|
|
329
|
+
let oi = 0;
|
|
330
|
+
return rows.map((row) => {
|
|
331
|
+
if (row.kind === "add") {
|
|
332
|
+
const t = nt[ni] ?? null;
|
|
333
|
+
ni += 1;
|
|
334
|
+
return t;
|
|
335
|
+
}
|
|
336
|
+
if (row.kind === "del") {
|
|
337
|
+
const t = ot[oi] ?? null;
|
|
338
|
+
oi += 1;
|
|
339
|
+
return t;
|
|
340
|
+
}
|
|
341
|
+
if (row.kind === "ctx") {
|
|
342
|
+
const t = nt[ni] ?? null;
|
|
343
|
+
ni += 1;
|
|
344
|
+
oi += 1;
|
|
345
|
+
return t;
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
});
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Language-highlighted rows for every file/hunk of a parsed diff. Language
|
|
353
|
+
* comes from the `lang=` meta override, else the file's own path (the fence's
|
|
354
|
+
* `title=`/`filename=` names a single-file diff). A file whose language can't
|
|
355
|
+
* be resolved keeps `null` rows — it renders plain, like today.
|
|
356
|
+
*/
|
|
357
|
+
const diffHighlight = async (
|
|
358
|
+
files: FileDiff[],
|
|
359
|
+
meta: string
|
|
360
|
+
): Promise<DiffHl | undefined> => {
|
|
361
|
+
const override = fenceLang(meta);
|
|
362
|
+
const named = files.length === 1 ? fenceFilename(meta) : undefined;
|
|
363
|
+
const langOf = (path: string | undefined): string | undefined =>
|
|
364
|
+
override ?? (path === undefined ? undefined : langForPath(path));
|
|
365
|
+
const perFile = files.map((file) => ({
|
|
366
|
+
file,
|
|
367
|
+
newLang: langOf(file.newPath ?? file.oldPath ?? named),
|
|
368
|
+
oldLang: langOf(file.oldPath ?? file.newPath ?? named),
|
|
369
|
+
}));
|
|
370
|
+
// One parallel grammar-load pass for every language the diff touches.
|
|
371
|
+
const wanted = new Set(
|
|
372
|
+
perFile.flatMap((l) =>
|
|
373
|
+
[l.newLang, l.oldLang].filter((x): x is string => x !== undefined)
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
const ready = new Map<string, Highlighter>();
|
|
377
|
+
await Promise.all(
|
|
378
|
+
[...wanted].map(async (lang) => {
|
|
379
|
+
const highlighter = await readyHighlighter(lang);
|
|
380
|
+
if (highlighter !== undefined) {
|
|
381
|
+
ready.set(lang, highlighter);
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
);
|
|
385
|
+
const sideOf = (lang: string | undefined): DiffSide | undefined => {
|
|
386
|
+
if (lang === undefined) {
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
const highlighter = ready.get(lang);
|
|
390
|
+
return highlighter === undefined ? undefined : { highlighter, lang };
|
|
391
|
+
};
|
|
392
|
+
let any = false;
|
|
393
|
+
const hl = perFile.map(({ file, newLang, oldLang }): DiffHl[number] => {
|
|
394
|
+
const newSide = sideOf(newLang);
|
|
395
|
+
// Deleted rows prefer the old side's language; when it didn't resolve
|
|
396
|
+
// they share the new side's rather than going plain.
|
|
397
|
+
const oldSide = sideOf(oldLang) ?? newSide;
|
|
398
|
+
return file.hunks.map((h) => {
|
|
399
|
+
try {
|
|
400
|
+
const rows = hunkTokens({ new: newSide, old: oldSide }, h.rows);
|
|
401
|
+
any ||= rows.some((r) => r !== null);
|
|
402
|
+
return rows;
|
|
403
|
+
} catch {
|
|
404
|
+
// One broken grammar must not fail the whole document — this hunk's
|
|
405
|
+
// rows degrade to plain text.
|
|
406
|
+
return h.rows.map((): null => null);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
return any ? hl : undefined;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The `data-diffhl` payload for a raw diff text — the Storybook counterpart of
|
|
415
|
+
* the rehype path, which component stories bypass (like `highlightToHtml` for
|
|
416
|
+
* ordinary fences). Undefined when the text doesn't parse as a structured diff
|
|
417
|
+
* or no row could be highlighted.
|
|
418
|
+
*/
|
|
419
|
+
export const diffHighlightJson = async (
|
|
420
|
+
text: string,
|
|
421
|
+
meta: string
|
|
422
|
+
): Promise<string | undefined> => {
|
|
423
|
+
const files = parseDiff(text).filter((f) => f.raw.length > 0);
|
|
424
|
+
if (files.length === 0) {
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
const hl = await diffHighlight(files, meta);
|
|
428
|
+
return hl === undefined ? undefined : JSON.stringify(hl);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Rehype plugin: replace the text inside `pre > code` with shiki's highlighted
|
|
433
|
+
* line spans. The `pre`/`code` elements and their properties (language class,
|
|
434
|
+
* `meta` from remarkCodeMeta) are kept so the `Pre` component's filename
|
|
435
|
+
* header, copy payload, and mermaid handling keep working. ```diff/```patch
|
|
436
|
+
* fences skip the generic path: `DiffView` replaces their markup, so instead
|
|
437
|
+
* each hunk's old/new sides are highlighted in the file's own language and
|
|
438
|
+
* parked as JSON on `code`'s `data-diffhl` prop — it serializes into the
|
|
439
|
+
* compiled module, reaching SSR and hydration renders identically.
|
|
440
|
+
*/
|
|
441
|
+
export const rehypeShiki = () => async (tree: Root) => {
|
|
442
|
+
const targets: { code: Element; lang: string; meta: string; text: string }[] =
|
|
443
|
+
[];
|
|
444
|
+
const diffs: {
|
|
445
|
+
code: Element;
|
|
446
|
+
files: FileDiff[];
|
|
447
|
+
meta: string;
|
|
448
|
+
}[] = [];
|
|
449
|
+
|
|
450
|
+
visit(tree, "element", (node) => {
|
|
451
|
+
if (node.tagName !== "pre") {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const code: ElementContent | undefined = node.children[0];
|
|
455
|
+
if (!isElement(code) || code.tagName !== "code") {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const lang = languageOf(code);
|
|
459
|
+
if (lang === undefined || SKIP_LANGS.has(lang)) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const meta =
|
|
463
|
+
typeof code.properties?.meta === "string" ? code.properties.meta : "";
|
|
464
|
+
const text = textContent(code);
|
|
465
|
+
// A ```diff fence with parseable structure renders as DiffView — the
|
|
466
|
+
// language highlight replaces the generic pass. Unparseable content
|
|
467
|
+
// falls back to the ordinary diff-grammar highlight below (DiffView
|
|
468
|
+
// renders it as a plain code block too).
|
|
469
|
+
if (DIFF_FENCE_LANGS.has(lang)) {
|
|
470
|
+
const files = parseDiff(text).filter((f) => f.raw.length > 0);
|
|
471
|
+
if (files.length > 0) {
|
|
472
|
+
diffs.push({ code, files, meta });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
targets.push({ code, lang, meta, text });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (targets.length === 0 && diffs.length === 0) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await Promise.all([
|
|
484
|
+
...targets.map(async ({ code, lang, meta, text }) => {
|
|
485
|
+
const highlighter = await readyHighlighter(lang);
|
|
486
|
+
if (highlighter === undefined) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
let hast: Root;
|
|
490
|
+
try {
|
|
491
|
+
// mdast adds a trailing newline to code values; keep it out of the
|
|
492
|
+
// highlight input or it surfaces as a stray empty line.
|
|
493
|
+
hast = highlighter.codeToHast(text.replace(/\n$/u, ""), {
|
|
494
|
+
defaultColor: false,
|
|
495
|
+
lang,
|
|
496
|
+
meta: { __raw: meta },
|
|
497
|
+
themes: THEMES,
|
|
498
|
+
transformers: TRANSFORMERS,
|
|
499
|
+
});
|
|
500
|
+
} catch {
|
|
501
|
+
// One broken grammar must not fail the whole document — the fence
|
|
502
|
+
// degrades to plain unhighlighted code.
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const shikiPre = hast.children.find(
|
|
506
|
+
(child): child is Element => isElement(child) && child.tagName === "pre"
|
|
507
|
+
);
|
|
508
|
+
const highlightedCode = shikiPre?.children[0];
|
|
509
|
+
if (!isElement(highlightedCode) || highlightedCode.tagName !== "code") {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
code.children = highlightedCode.children;
|
|
513
|
+
// Feature flags the transformers park on shiki's discarded <pre>
|
|
514
|
+
// (`has-diff`, `has-focused`, `has-line-numbers`, …) move onto our
|
|
515
|
+
// <code> so the CSS selectors below can see them.
|
|
516
|
+
const lifted = classNames(shikiPre).filter((c) => c.startsWith("has-"));
|
|
517
|
+
code.properties = {
|
|
518
|
+
...code.properties,
|
|
519
|
+
className: [...classNames(code), "shiki", ...lifted],
|
|
520
|
+
};
|
|
521
|
+
}),
|
|
522
|
+
...diffs.map(async ({ code, files, meta }) => {
|
|
523
|
+
const hl = await diffHighlight(files, meta);
|
|
524
|
+
if (hl === undefined) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
code.properties = {
|
|
528
|
+
...code.properties,
|
|
529
|
+
"data-diffhl": JSON.stringify(hl),
|
|
530
|
+
};
|
|
531
|
+
}),
|
|
532
|
+
]);
|
|
533
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Node } from "unist";
|
|
2
|
+
import { visit } from "unist-util-visit";
|
|
3
|
+
|
|
4
|
+
import type { MdxTarget } from "./ast.js";
|
|
5
|
+
import { isParent, toMdxElement } from "./ast.js";
|
|
6
|
+
import { ALERT_RE, normalizeCalloutKind } from "./callouts.js";
|
|
7
|
+
|
|
8
|
+
/** A text node with a mutable string `value` — callers edit it in place. */
|
|
9
|
+
const isTextValue = (n: Node | undefined): n is MdxTarget & { value: string } =>
|
|
10
|
+
n !== undefined &&
|
|
11
|
+
n.type === "text" &&
|
|
12
|
+
"value" in n &&
|
|
13
|
+
typeof n.value === "string";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* GitHub-style alerts become Callout components:
|
|
17
|
+
* > [!WARNING]
|
|
18
|
+
* > Be careful.
|
|
19
|
+
* renders identically to <Callout kind="warning">.
|
|
20
|
+
*/
|
|
21
|
+
export const remarkMdxrAlerts = () => (tree: Node) => {
|
|
22
|
+
visit(tree, "blockquote", (node: Node) => {
|
|
23
|
+
if (!isParent(node)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const [first] = node.children;
|
|
27
|
+
if (first === undefined || first.type !== "paragraph" || !isParent(first)) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const [text] = first.children;
|
|
31
|
+
if (!isTextValue(text)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const m = ALERT_RE.exec(text.value);
|
|
36
|
+
if (m === null || m.groups === undefined) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rest = text.value.slice(m[0].length);
|
|
41
|
+
text.value = rest;
|
|
42
|
+
if (rest === "") {
|
|
43
|
+
first.children.shift();
|
|
44
|
+
}
|
|
45
|
+
if (first.children.length === 0) {
|
|
46
|
+
node.children.shift();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
toMdxElement(node, "mdxJsxFlowElement", "Callout", {
|
|
50
|
+
kind: normalizeCalloutKind(m.groups.kind),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** AST helpers shared by the remark plugins (mdast/mdx node poking). */
|
|
2
|
+
|
|
3
|
+
import type { Node, Parent } from "unist";
|
|
4
|
+
import { visit } from "unist-util-visit";
|
|
5
|
+
|
|
6
|
+
import { isRecord } from "../guards.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A node mutated into an `mdxJsx*Element` (or read as one): `type`, `name`,
|
|
10
|
+
* `attributes`, and `children` are all assignable. The real
|
|
11
|
+
* `MdxJsxFlowElement` type is intentionally not required — these helpers
|
|
12
|
+
* accept any unist node mid-mutation.
|
|
13
|
+
*/
|
|
14
|
+
export interface MdxTarget extends Node {
|
|
15
|
+
attributes?: unknown;
|
|
16
|
+
children?: Node[];
|
|
17
|
+
name?: string;
|
|
18
|
+
value?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const isParent = (n: Node): n is Parent =>
|
|
22
|
+
"children" in n && Array.isArray(n.children);
|
|
23
|
+
|
|
24
|
+
export const isFlowElement = (n: Node): n is MdxTarget =>
|
|
25
|
+
n.type === "mdxJsxFlowElement";
|
|
26
|
+
|
|
27
|
+
/** All literal text under `node`, concatenated in tree order — `inlineCode`
|
|
28
|
+
* carries its code in `value` too, and dropping it would truncate heading
|
|
29
|
+
* slugs and directive labels at the first backtick span. */
|
|
30
|
+
export const textContent = (node: Node): string => {
|
|
31
|
+
let out = "";
|
|
32
|
+
visit(node, ["text", "inlineCode"], (n: Node) => {
|
|
33
|
+
if ("value" in n && typeof n.value === "string") {
|
|
34
|
+
out += n.value;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return out;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A JSX attribute's string value, or undefined when absent/empty/non-string. */
|
|
41
|
+
export const jsxAttr = (node: MdxTarget, name: string): string | undefined => {
|
|
42
|
+
if (!Array.isArray(node.attributes)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
for (const a of node.attributes) {
|
|
46
|
+
if (
|
|
47
|
+
isRecord(a) &&
|
|
48
|
+
a.name === name &&
|
|
49
|
+
typeof a.value === "string" &&
|
|
50
|
+
a.value !== ""
|
|
51
|
+
) {
|
|
52
|
+
return a.value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `Record` → `mdxJsxAttribute[]`. null/undefined/"" values are dropped —
|
|
60
|
+
* MDX attributes arrive as strings, so everything is `String()`-coerced.
|
|
61
|
+
*/
|
|
62
|
+
export const jsxAttrs = (
|
|
63
|
+
attrs: Record<string, unknown>
|
|
64
|
+
): { name: string; type: "mdxJsxAttribute"; value: string }[] =>
|
|
65
|
+
Object.entries(attrs)
|
|
66
|
+
.filter(([, val]) => val !== null && val !== undefined && val !== "")
|
|
67
|
+
.map(([key, val]) => ({
|
|
68
|
+
name: key,
|
|
69
|
+
type: "mdxJsxAttribute",
|
|
70
|
+
value: String(val),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
/** Mutate `node` into an `mdxJsxFlowElement`/`mdxJsxTextElement` in place. */
|
|
74
|
+
export const toMdxElement = (
|
|
75
|
+
node: MdxTarget,
|
|
76
|
+
kind: "mdxJsxFlowElement" | "mdxJsxTextElement",
|
|
77
|
+
name: string,
|
|
78
|
+
attrs: Record<string, unknown>
|
|
79
|
+
): void => {
|
|
80
|
+
node.type = kind;
|
|
81
|
+
node.name = name;
|
|
82
|
+
node.attributes = jsxAttrs(attrs);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Merge `key: value` into `node.data.hProperties` (creating both levels).
|
|
87
|
+
* The mdast→hast bridge turns hProperties into element props.
|
|
88
|
+
*/
|
|
89
|
+
export const setHProperty = (node: Node, key: string, value: unknown): void => {
|
|
90
|
+
const data: Record<string, unknown> = isRecord(node.data) ? node.data : {};
|
|
91
|
+
node.data = data;
|
|
92
|
+
data.hProperties = {
|
|
93
|
+
...(isRecord(data.hProperties) ? data.hProperties : {}),
|
|
94
|
+
[key]: value,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Callout kinds shared by `:::kind` directives and `> [!KIND]` alerts. */
|
|
2
|
+
|
|
3
|
+
export const CALLOUT_KINDS = new Set([
|
|
4
|
+
"note",
|
|
5
|
+
"tip",
|
|
6
|
+
"important",
|
|
7
|
+
"warning",
|
|
8
|
+
"caution",
|
|
9
|
+
"danger",
|
|
10
|
+
"decision",
|
|
11
|
+
"goal",
|
|
12
|
+
"nongoal",
|
|
13
|
+
"question",
|
|
14
|
+
"answer",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** Lowercases and maps the `non-goal`/`nongoal` spellings to `nongoal`. */
|
|
18
|
+
export const normalizeCalloutKind = (kind: string): string =>
|
|
19
|
+
kind.toLowerCase().replace(/^non-goal$/u, "nongoal");
|
|
20
|
+
|
|
21
|
+
/** GitHub alert marker: `> [!WARNING]` — kinds derived from CALLOUT_KINDS. */
|
|
22
|
+
export const ALERT_RE = new RegExp(
|
|
23
|
+
`^\\[!(?<kind>${[...CALLOUT_KINDS].map((k) => (k === "nongoal" ? "non-?goal" : k)).join("|")})\\]\\s*`,
|
|
24
|
+
"iu"
|
|
25
|
+
);
|