返回 reveal.js
markdown.ts
根目录 / react / src / utils / markdown.ts
1 import type { HTMLAttributes } from 'react';
2 import { Marked } from 'marked';
3 import { markedSmartypants } from 'marked-smartypants';
4 import type { MarkdownOptions } from '../types';
5
6 export const DEFAULT_SLIDE_SEPARATOR = '\r?\n---\r?\n';
7 export const DEFAULT_VERTICAL_SEPARATOR = null;
8 export const DEFAULT_NOTES_SEPARATOR = '^\\s*notes?:';
9 export const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\.element\\s*?(.+?)$';
10 export const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\.slide:\\s*?(\\S.+?)$';
11
12 const CODE_LINE_NUMBER_REGEX = /\[\s*((\d*):)?\s*([\s\d,|-]*)\]/;
13
14 const HTML_ESCAPE_MAP: Record<string, string> = {
15 '&': '&amp;',
16 '<': '&lt;',
17 '>': '&gt;',
18 '"': '&quot;',
19 "'": '&#39;',
20 };
21
22 type MarkdownLeaf = {
23 key: string;
24 html: string;
25 };
26
27 export type MarkdownRenderNode =
28 | {
29 type: 'slide';
30 key: string;
31 slide: MarkdownLeaf;
32 }
33 | {
34 type: 'stack';
35 key: string;
36 slides: MarkdownLeaf[];
37 };
38
39 export function normalizeMarkdownSource(markdown: string) {
40 const leadingWhitespace = markdown.match(/^\n?(\s*)/)?.[1].length ?? 0;
41 const leadingTabs = markdown.match(/^\n?(\t*)/)?.[1].length ?? 0;
42
43 if (leadingTabs > 0) {
44 return markdown.replace(new RegExp(`\\n?\\t{${leadingTabs}}(.*)`, 'g'), (_match, line) => {
45 return `\n${line}`;
46 });
47 }
48
49 if (leadingWhitespace > 1) {
50 return markdown.replace(new RegExp(`\\n? {${leadingWhitespace}}(.*)`, 'g'), (_match, line) => {
51 return `\n${line}`;
52 });
53 }
54
55 return markdown;
56 }
57
58 function escapeForHTML(input: string) {
59 return input.replace(/([&<>'"])/g, (char) => HTML_ESCAPE_MAP[char]);
60 }
61
62 export function createMarkedInstance(options: MarkdownOptions = {}) {
63 const { renderer: customRenderer, animateLists, smartypants, ...markedOptions } = options;
64
65 const renderer =
66 customRenderer ||
67 ({
68 code({ text, lang }: { text: string; lang?: string }) {
69 let language = lang || '';
70 let lineNumberOffset = '';
71 let lineNumbers = '';
72
73 if (CODE_LINE_NUMBER_REGEX.test(language)) {
74 const lineNumberOffsetMatch = language.match(CODE_LINE_NUMBER_REGEX)?.[2];
75 if (lineNumberOffsetMatch) {
76 lineNumberOffset = `data-ln-start-from="${lineNumberOffsetMatch.trim()}"`;
77 }
78
79 lineNumbers = language.match(CODE_LINE_NUMBER_REGEX)?.[3]?.trim() || '';
80 lineNumbers = `data-line-numbers="${lineNumbers}"`;
81 language = language.replace(CODE_LINE_NUMBER_REGEX, '').trim();
82 }
83
84 text = escapeForHTML(text);
85
86 return `<pre><code ${lineNumbers} ${lineNumberOffset} class="${language}">${text}</code></pre>`;
87 },
88 } satisfies Record<string, unknown>);
89
90 if (animateLists === true && !customRenderer) {
91 (renderer as { listitem?: (this: any, token: any) => string }).listitem = function (
92 this: { parser: { parseInline(tokens: unknown[]): string } },
93 token: { tokens?: unknown[]; text?: string }
94 ) {
95 const text = token.tokens ? this.parser.parseInline(token.tokens) : token.text || '';
96 return `<li class="fragment">${text}</li>`;
97 };
98 }
99
100 const markedInstance = new Marked();
101 markedInstance.use({ renderer, ...markedOptions });
102
103 if (smartypants) {
104 markedInstance.use(markedSmartypants());
105 }
106
107 return markedInstance;
108 }
109
110 function ensureParsedMarkdown(result: string | Promise<string>) {
111 if (typeof result === 'string') return result;
112
113 throw new Error(
114 'Async markdown parsing is not supported here because Reveal markdown parsing is synchronous.'
115 );
116 }
117
118 function createSlideHtml(markdown: string, markedInstance: Marked, notesSeparator: string) {
119 const notesMatch = markdown.split(new RegExp(notesSeparator, 'mgi'));
120 let slideMarkdown = markdown;
121 let notesHtml = '';
122
123 if (notesMatch.length === 2) {
124 slideMarkdown = notesMatch[0];
125 notesHtml = `<aside class="notes">${ensureParsedMarkdown(
126 markedInstance.parse(notesMatch[1].trim())
127 )}</aside>`;
128 }
129
130 return `${ensureParsedMarkdown(markedInstance.parse(slideMarkdown))}${notesHtml}`;
131 }
132
133 export function buildMarkdownNodes(
134 markdown: string,
135 markedInstance: Marked,
136 separator: string,
137 verticalSeparator: string | null,
138 notesSeparator: string
139 ): MarkdownRenderNode[] {
140 const separatorRegex = new RegExp(
141 separator + (verticalSeparator ? `|${verticalSeparator}` : ''),
142 'mg'
143 );
144 const horizontalSeparatorRegex = new RegExp(separator);
145
146 let matches: RegExpExecArray | null;
147 let lastIndex = 0;
148 let isHorizontal = true;
149 let wasHorizontal = true;
150 const sectionStack: Array<string | string[]> = [];
151
152 // This mirrors the core plugin's slidify pass so that horizontal and vertical
153 // separators produce the exact same section nesting as reveal.js markdown does.
154 while ((matches = separatorRegex.exec(markdown))) {
155 isHorizontal = horizontalSeparatorRegex.test(matches[0]);
156
157 if (!isHorizontal && wasHorizontal) {
158 sectionStack.push([]);
159 }
160
161 const content = markdown.substring(lastIndex, matches.index);
162
163 if (isHorizontal && wasHorizontal) {
164 sectionStack.push(content);
165 } else {
166 (sectionStack[sectionStack.length - 1] as string[]).push(content);
167 }
168
169 lastIndex = separatorRegex.lastIndex;
170 wasHorizontal = isHorizontal;
171 }
172
173 (wasHorizontal ? sectionStack : (sectionStack[sectionStack.length - 1] as string[])).push(
174 markdown.substring(lastIndex)
175 );
176
177 return sectionStack.map((entry, horizontalIndex) => {
178 if (Array.isArray(entry)) {
179 return {
180 type: 'stack' as const,
181 key: `h${horizontalIndex}`,
182 slides: entry.map((slideMarkdown, verticalIndex) => ({
183 key: `h${horizontalIndex}-v${verticalIndex}`,
184 html: createSlideHtml(slideMarkdown, markedInstance, notesSeparator),
185 })),
186 };
187 }
188
189 return {
190 type: 'slide' as const,
191 key: `h${horizontalIndex}`,
192 slide: {
193 key: `h${horizontalIndex}`,
194 html: createSlideHtml(entry, markedInstance, notesSeparator),
195 },
196 };
197 });
198 }
199
200 function addAttributeInElement(node: ChildNode, elementTarget: Element | null, separator: string) {
201 if (!elementTarget || node.nodeType !== Node.COMMENT_NODE || node.nodeValue == null) {
202 return false;
203 }
204
205 const markdownClassesInElementsRegex = new RegExp(separator, 'mg');
206 const markdownClassRegex = new RegExp('([^"= ]+?)="([^"]+?)"|(data-[^"= ]+?)(?=[" ])', 'mg');
207 let nodeValue = node.nodeValue;
208 const matches = markdownClassesInElementsRegex.exec(nodeValue);
209
210 if (!matches) return false;
211
212 const classes = matches[1];
213 nodeValue =
214 nodeValue.substring(0, matches.index) +
215 nodeValue.substring(markdownClassesInElementsRegex.lastIndex);
216 node.nodeValue = nodeValue;
217
218 let matchesClass: RegExpExecArray | null;
219 while ((matchesClass = markdownClassRegex.exec(classes))) {
220 if (matchesClass[2]) {
221 elementTarget.setAttribute(matchesClass[1], matchesClass[2]);
222 } else {
223 elementTarget.setAttribute(matchesClass[3], '');
224 }
225 }
226
227 return true;
228 }
229
230 export function addAttributes(
231 section: HTMLElement,
232 element: ChildNode | HTMLElement,
233 previousElement: Element | null,
234 separatorElementAttributes: string,
235 separatorSectionAttributes: string
236 ) {
237 if ('childNodes' in element && element.childNodes.length > 0) {
238 let previousParentElement: Element | null =
239 element instanceof Element ? element : previousElement;
240
241 for (let index = 0; index < element.childNodes.length; index += 1) {
242 const childElement = element.childNodes[index];
243
244 if (index > 0) {
245 let previousIndex = index - 1;
246 while (previousIndex >= 0) {
247 const previousChildElement = element.childNodes[previousIndex];
248 if (
249 typeof (previousChildElement as Element).setAttribute === 'function' &&
250 (previousChildElement as Element).tagName !== 'BR'
251 ) {
252 previousParentElement = previousChildElement as Element;
253 break;
254 }
255 previousIndex -= 1;
256 }
257 }
258
259 let parentSection = section;
260 if ((childElement as Element).nodeName === 'SECTION') {
261 parentSection = childElement as HTMLElement;
262 previousParentElement = childElement as Element;
263 }
264
265 if (
266 typeof (childElement as Element).setAttribute === 'function' ||
267 childElement.nodeType === Node.COMMENT_NODE
268 ) {
269 addAttributes(
270 parentSection,
271 childElement as ChildNode | HTMLElement,
272 previousParentElement,
273 separatorElementAttributes,
274 separatorSectionAttributes
275 );
276 }
277 }
278 }
279
280 if (element.nodeType !== Node.COMMENT_NODE) return;
281
282 let targetElement = previousElement;
283 if (targetElement && (targetElement.tagName === 'UL' || targetElement.tagName === 'OL')) {
284 targetElement = targetElement.lastElementChild || targetElement;
285 }
286
287 if (addAttributeInElement(element, targetElement, separatorElementAttributes) === false) {
288 addAttributeInElement(element, section, separatorSectionAttributes);
289 }
290 }
291
292 export function hashString(input: string) {
293 let hash = 5381;
294
295 for (let index = 0; index < input.length; index += 1) {
296 hash = (hash * 33) ^ input.charCodeAt(index);
297 }
298
299 return (hash >>> 0).toString(36);
300 }
301
302 function serializeSignatureValue(value: unknown): unknown {
303 if (
304 value == null ||
305 typeof value === 'string' ||
306 typeof value === 'number' ||
307 typeof value === 'boolean'
308 ) {
309 return value;
310 }
311
312 if (Array.isArray(value)) {
313 return value.map(serializeSignatureValue);
314 }
315
316 if (typeof value === 'object') {
317 return Object.entries(value as Record<string, unknown>)
318 .filter(([, entryValue]) => entryValue !== undefined && typeof entryValue !== 'function')
319 .sort(([a], [b]) => a.localeCompare(b))
320 .map(([key, entryValue]) => [key, serializeSignatureValue(entryValue)]);
321 }
322
323 return String(value);
324 }
325
326 export function getSectionPropsSignature(attributes: HTMLAttributes<HTMLElement>) {
327 return JSON.stringify(
328 Object.entries(attributes)
329 .filter(([, value]) => value !== undefined && typeof value !== 'function')
330 .sort(([a], [b]) => a.localeCompare(b))
331 .map(([key, value]) => [key, serializeSignatureValue(value)])
332 );
333 }
334
335 export function getErrorMessage(error: unknown) {
336 if (error instanceof Error && error.message) return error.message;
337 return String(error);
338 }
339
339 lines TYPESCRIPT