markdown-maker 1.10.1 → 1.10.3

Sign up to get free protection for your applications and to get access to all the features.
package/src/main.ts ADDED
@@ -0,0 +1,123 @@
1
+ import path from "path";
2
+ import { WebSocketServer } from "ws";
3
+ import * as fs from "fs";
4
+
5
+ import choki from "chokidar";
6
+ import Parser from "./parser";
7
+
8
+ /* for adding colours to strings */
9
+ import { enable as ColorsEnable } from "colors.ts";
10
+ ColorsEnable();
11
+
12
+ import { argParser, CommandLineArgs, ParserOptions } from "./cltool";
13
+ const configFileName = ".mdmconfig.json";
14
+
15
+ function main() {
16
+ let clargs: CommandLineArgs;
17
+ let server: WebSocketServer | undefined;
18
+
19
+ /* Read config file or parse args from cmd-line */
20
+ if (fs.existsSync(configFileName)) {
21
+ let data: CommandLineArgs = JSON.parse(
22
+ fs.readFileSync(configFileName).toString()
23
+ ).opts;
24
+
25
+ let args: (string | number)[] = [];
26
+ Object.entries(data).forEach(([key, value]) => {
27
+ if (key != "src" && value !== false) {
28
+ args.push("--" + key);
29
+ }
30
+ if (typeof value != "boolean") {
31
+ args.push(value);
32
+ }
33
+ });
34
+
35
+ /* We skip [0] and [1], as it is the binary and source file, even when compiled*/
36
+ for (let i = 2; i < process.argv.length; i++)
37
+ args.push(process.argv[i]);
38
+
39
+ clargs = argParser.parse_args(args.map((x) => x.toString()));
40
+ } else {
41
+ clargs = argParser.parse_args();
42
+ }
43
+
44
+ /* if src is init, create config file and exit */
45
+ if (clargs.src == "init") {
46
+ const template = fs.readFileSync(
47
+ path.join(
48
+ __dirname,
49
+ "..",
50
+ "src",
51
+ "templates",
52
+ "configTemplate.json"
53
+ )
54
+ );
55
+ fs.writeFileSync(configFileName, template);
56
+ fs.writeFileSync("main.md", "# Main\n");
57
+ return;
58
+ }
59
+
60
+ /* helper method for calling parser */
61
+ const compile = (source, output, cb?) => {
62
+ /* load data from file, if it exists,
63
+ * otherwise, interpret as string */
64
+
65
+ const parser = new Parser(source, clargs);
66
+ parser.to(output, (file) => {
67
+ console.log(`Compiled ${file}`.green);
68
+ if (cb) cb();
69
+ });
70
+ return parser;
71
+ };
72
+
73
+ const internalCooldown = 1000;
74
+ function watcher(_, path: string) {
75
+ const now = Date.now();
76
+
77
+ if (!this.time) this.time = now;
78
+ if (now - this.time < internalCooldown) return;
79
+ console.log(`Detected change in ${path}...`);
80
+ try {
81
+ compile(clargs.src, clargs.output, () => {
82
+ /* after compile, send refresh command to clients */
83
+ server.clients.forEach((client) => {
84
+ if (client.OPEN) client.send("refresh");
85
+ });
86
+ });
87
+ } catch (e) {
88
+ console.log(e.message);
89
+ }
90
+
91
+ this.time = now;
92
+ }
93
+
94
+ /* in case source is a directory, look for entry in directory */
95
+ if (fs.existsSync(clargs.src) && fs.lstatSync(clargs.src).isDirectory()) {
96
+ clargs.src = path.join(clargs.src, clargs.entry);
97
+ }
98
+
99
+ if (clargs.debug) console.dir(clargs);
100
+
101
+ /* compile once if not watching
102
+ otherwise watch the folder and recompile on change */
103
+ if (!clargs.watch) compile(clargs.src, clargs.output);
104
+ else {
105
+ const srcDirName = path.dirname(clargs.src);
106
+ console.log(`Watching ${srcDirName} for changes...`.yellow);
107
+ server = new WebSocketServer({ port: 7788 });
108
+
109
+ const _watcher = choki.watch(srcDirName).on("all", watcher);
110
+ try {
111
+ compile(clargs.src, clargs.output);
112
+ } catch (e) {
113
+ console.log(e.message);
114
+ }
115
+ }
116
+ }
117
+ export default {
118
+ Parser,
119
+ };
120
+ /* main entrypoint */
121
+ if (require.main === module) {
122
+ main();
123
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,398 @@
1
+ import fs from "fs"; /* for handling reading of files */
2
+ import path from "path"; /* for handling file paths */
3
+
4
+ import marked from "marked";
5
+
6
+ import { Command, commands, load_extensions } from "./commands";
7
+ import {
8
+ argParser,
9
+ IncompleteCommandLineArgs,
10
+ IncompleteParserOptions,
11
+ ParserOptions,
12
+ } from "./cltool";
13
+ import { MDMError, MDMNonParserError, MDMWarning } from "./errors";
14
+ import { CommandGroupType, TaggedElement, TargetType } from "./types";
15
+
16
+ /* parse some md
17
+ * recursively with extra options */
18
+ class Parser {
19
+ file: string;
20
+ parent?: Parser;
21
+ line_num: number;
22
+ wd: string;
23
+ wd_full: string;
24
+ blobs: {
25
+ [key: number]: string | undefined;
26
+ };
27
+ opts: ParserOptions;
28
+ raw: string;
29
+
30
+ static TOKEN = "#md";
31
+
32
+ constructor(
33
+ filename: string,
34
+ clargs?: IncompleteCommandLineArgs,
35
+ opts?: IncompleteParserOptions
36
+ ) {
37
+ /* this.working_directory */
38
+ this.file = filename;
39
+
40
+ this.line_num = 0;
41
+ this.wd = path.dirname(filename);
42
+ this.wd_full = path.resolve(this.wd);
43
+
44
+ /* finished blob */
45
+ this.blobs = {};
46
+
47
+ if (!clargs) {
48
+ clargs = argParser.parse_args([filename]);
49
+ }
50
+
51
+ /* get default options, and overwrite with the ones present
52
+ in the arguments */
53
+ this.opts = defaultParserOptions();
54
+ Object.assign(this.opts, clargs);
55
+ Object.assign(this.opts, opts);
56
+
57
+ this.raw = this.opts.isFileCallback(filename) || filename;
58
+ }
59
+
60
+ private parse() {
61
+ load_extensions(this);
62
+ if (this.opts.verbose || this.opts.debug) {
63
+ console.log(
64
+ `parsing ${this.file}: depth=${this.opts.depth}`.magenta
65
+ );
66
+ }
67
+
68
+ if (this.opts.debug) {
69
+ console.log("Parsing options:");
70
+ console.log(this.opts);
71
+ }
72
+
73
+ /* reset sections for beginning parse */
74
+ if (this.opts.depth === 0) this.opts.secs = [];
75
+ let __blob = this.raw;
76
+
77
+ /* apply preproccessing to raw file */
78
+ __blob = this.preprocess(__blob);
79
+
80
+ /* main parser instance call */
81
+ __blob = this.mainparse(__blob);
82
+
83
+ /**
84
+ * apply postprocessing after */
85
+ __blob = this.postprocess(__blob);
86
+
87
+ return __blob;
88
+ }
89
+
90
+ private mainparse(blob: string) {
91
+ if (this.opts.verbose || this.opts.debug) {
92
+ console.debug(`beginning mainparse of '${this.file}'`.blue);
93
+ }
94
+
95
+ /* main parser instance loop */
96
+ blob.split("\n").forEach((line, lnum) => {
97
+ this.line_num = lnum;
98
+
99
+ /* if line looks like a title */
100
+ const titleMatch = line.trim().match(/^(#+) (.+)$/);
101
+
102
+ if (titleMatch) {
103
+ if (this.opts.verbose || this.opts.debug)
104
+ console.log("found toc element: " + line);
105
+
106
+ /* implement toc level */
107
+ let level = titleMatch[1].length;
108
+ let title = titleMatch[2];
109
+
110
+ this.opts.secs.push({ level, title });
111
+
112
+ if (this.opts.debug) {
113
+ console.log("updated sections:", { level, title });
114
+ }
115
+ }
116
+ });
117
+
118
+ return this.parse_commands(blob, commands.parse);
119
+ }
120
+
121
+ private preprocess(blob: string) {
122
+ if (this.opts.verbose || this.opts.debug) {
123
+ console.debug(`beginning preprocess of '${this.file}'`.blue);
124
+ }
125
+
126
+ return this.parse_commands(blob, commands.preparse);
127
+ }
128
+
129
+ private postprocess(blob: string) {
130
+ if (this.opts.verbose || this.opts.debug) {
131
+ console.debug(`beginning postprocess of '${this.file}'`.blue);
132
+ }
133
+
134
+ blob = this.parse_commands(blob, commands.postparse);
135
+
136
+ /* remove double empty lines */
137
+ blob = this.remove_double_blank_lines(blob);
138
+ blob = blob.trimEnd() + "\n\n";
139
+ return blob;
140
+ }
141
+
142
+ private parse_commands(blob: string, commands: Command[]) {
143
+ commands.forEach((command) => {
144
+ /* Add global flag to RegExp */
145
+ const re = new RegExp(
146
+ command.validator.source,
147
+ (command.validator.flags || "") + "g"
148
+ );
149
+
150
+ const replacer = (args: RegExpExecArray) => {
151
+ try {
152
+ return command.act(args, this) || "";
153
+ } catch (error) {
154
+ switch (true) {
155
+ case error instanceof MDMError:
156
+ throw error;
157
+ case error instanceof MDMWarning:
158
+ console.warn(error.message);
159
+ return `**Warning: ${error.message}**`;
160
+ default:
161
+ console.error(error);
162
+ throw error;
163
+ }
164
+ }
165
+ };
166
+
167
+ /* */
168
+
169
+ let match: RegExpExecArray | null;
170
+ while ((match = re.exec(blob)) !== null) {
171
+ blob =
172
+ blob.slice(0, match.index) +
173
+ replacer(match) +
174
+ blob.slice(match.index + match[0].length);
175
+ }
176
+ });
177
+ return blob;
178
+ }
179
+
180
+ /* Parse all commands sequentially on a sub-blob */
181
+ private parse_all_commands(blob: string, commands: CommandGroupType) {
182
+ blob = this.parse_commands(blob, commands.preparse);
183
+ blob = this.parse_commands(blob, commands.parse);
184
+ blob = this.parse_commands(blob, commands.postparse);
185
+ return blob;
186
+ }
187
+
188
+ titleId(title: string) {
189
+ const sep = this.opts.use_underscore ? "_" : "-";
190
+
191
+ title = title
192
+ .toLowerCase()
193
+ .replace(/[^\w\s]+/g, "")
194
+ .replace(/[\s_]+/g, sep);
195
+ return title;
196
+ }
197
+
198
+ get_toc() {
199
+ let __blob = [];
200
+ let tabSize = 2;
201
+ const beg = "* ";
202
+ const hor = " ".repeat(tabSize);
203
+
204
+ this.opts.secs.forEach((sec) => {
205
+ if (sec.level > this.opts.toc_level) return;
206
+ let title = sec.title.replace(/_/g, " ");
207
+ title = this.parse_all_commands(title, commands);
208
+ const link = this.titleId(title);
209
+
210
+ let __line =
211
+ hor.repeat(Math.max(sec.level - 1, 0)) +
212
+ beg +
213
+ `[${title}](#${link})`;
214
+
215
+ __blob.push(__line);
216
+ });
217
+ return __blob.join("\n");
218
+ }
219
+
220
+ add_hook(
221
+ name: string,
222
+ hook: (map: { [key: string]: TaggedElement }) => void
223
+ ) {
224
+ if (this.opts.hooks[name] != undefined)
225
+ throw new MDMNonParserError(`Hook "${name}" already exists!`);
226
+ this.opts.hooks[name] = hook;
227
+ }
228
+
229
+ private line_num_from_index(index: number) {
230
+ return this.raw.substring(0, index).split("\n").length;
231
+ }
232
+
233
+ private remove_double_blank_lines(blob) {
234
+ /* replace all triple newlines, and EOF by double newline */
235
+ blob = blob.replace(/(\r\n|\n){3,}/g, "\n\n");
236
+
237
+ return blob;
238
+ }
239
+
240
+ /* output the parsed document to bundle */
241
+ to(bundleName: string, callback: (fileName: string) => void) {
242
+ const dir = path.dirname(bundleName);
243
+ if (callback === undefined) callback = () => {};
244
+
245
+ if (!fs.existsSync(dir)) {
246
+ fs.mkdirSync(dir, { recursive: true });
247
+ }
248
+
249
+ if (!this.opts.html) {
250
+ this.get(TargetType.MARKDOWN, (blob) => {
251
+ fs.writeFile(bundleName, blob, () => callback(bundleName));
252
+ });
253
+ } else {
254
+ const htmlFileName = bundleName.replace(".md", ".html");
255
+ fs.writeFile(htmlFileName, this.html(), () =>
256
+ callback(htmlFileName)
257
+ );
258
+ }
259
+ }
260
+
261
+ html() {
262
+ const htmlFormatted = marked
263
+ .parse(this.get(TargetType.HTML))
264
+ .toString();
265
+ if (this.opts.watch) {
266
+ return (
267
+ `<script>` +
268
+ `w=new WebSocket("ws:localhost:7788");` +
269
+ `w.addEventListener("message",(e)=>` +
270
+ ` {if(e.data=="refresh")location.reload();}` +
271
+ `);` +
272
+ `</script>\n` +
273
+ htmlFormatted
274
+ );
275
+ }
276
+ return htmlFormatted;
277
+ }
278
+
279
+ createChild(file: string) {
280
+ return new Parser(file, undefined, {
281
+ parent: this,
282
+ depth: this.opts.depth + 1,
283
+ ...this.opts,
284
+ });
285
+ }
286
+
287
+ get(targetType?: TargetType, callback?: (blob: string) => void): string {
288
+ /* If target type is undefined, markdown is the default */
289
+ if (targetType === undefined) targetType = TargetType.MARKDOWN;
290
+ if (this.blobs[targetType]) {
291
+ if (callback) {
292
+ callback(this.blobs[targetType]);
293
+ }
294
+ return this.blobs[targetType];
295
+ } else {
296
+ try {
297
+ this.opts.targetType = targetType;
298
+ let blob = this.parse();
299
+ this.opts.targetType = undefined;
300
+ if (callback) callback(blob);
301
+ return blob;
302
+ } catch (error) {
303
+ /* Compile a traceback of error */
304
+ let traceback = "";
305
+ let p: Parser = this;
306
+
307
+ do {
308
+ if (error instanceof MDMError)
309
+ traceback += `\n...on line ${p.line_num_from_index(
310
+ error.match.index
311
+ )} in ${p.file}`.grey(15);
312
+ else
313
+ traceback +=
314
+ `\n...on line ${p.line_num} in ${p.file}`.grey(15);
315
+ if (p.parent) p = p.parent;
316
+ } while (p.parent);
317
+
318
+ error.message += traceback;
319
+
320
+ /* only interested in node stacktrace when debugging */
321
+ if (!this.opts.debug) error.stack = "";
322
+
323
+ if (this.opts.only_warn) console.error(error);
324
+ else throw error;
325
+ }
326
+ }
327
+ }
328
+ }
329
+
330
+ function defaultParserOptions(): ParserOptions {
331
+ return {
332
+ defs: {},
333
+ secs: [],
334
+ args: [],
335
+ depth: 0,
336
+ verbose: false,
337
+ debug: false,
338
+ max_depth: 5,
339
+ use_underscore: false,
340
+ toc_level: 3,
341
+ allow_undefined: false,
342
+ html: false,
343
+ watch: false,
344
+ targetType: undefined,
345
+ only_warn: false,
346
+ parent: undefined,
347
+ hooks: {},
348
+ isFileCallback: (f) => {
349
+ if (!fs.existsSync(f)) return false;
350
+ return fs.readFileSync(f, "utf-8") + "\n";
351
+ },
352
+ };
353
+ }
354
+
355
+ export function splice(
356
+ str: string,
357
+ startIndex: number,
358
+ width: number,
359
+ newSubStr: string
360
+ ) {
361
+ const start = str.slice(0, startIndex);
362
+ const end = str.slice(startIndex + width);
363
+ return start + newSubStr + end;
364
+ }
365
+
366
+ /* add extention to marked for classed blockquotes*/
367
+ marked.use({
368
+ renderer: {
369
+ blockquote(quote: string) {
370
+ /* find the ending, and if not, return the default */
371
+ const ending = quote.match(/\{(.+)\}\s*<\/p>/);
372
+ if (!ending) return `<blockquote>${quote}</blockquote>`;
373
+
374
+ const args = ending[1].split(" ");
375
+
376
+ const classes = args.filter((arg) => arg.startsWith("."));
377
+ const id = args.filter((arg) => arg.startsWith("#"));
378
+
379
+ const classNames = classes.map((c) => c.slice(1));
380
+ const classText =
381
+ classes.length > 0 ? `class="${classNames.join(" ")}"` : "";
382
+ const idText = id.length > 0 ? `id="${id[0].slice(1)}"` : "";
383
+
384
+ /* remove the ending from the quote */
385
+ quote = quote.replace(/\{(.+)\}\s*<\/p>/, "</p>");
386
+
387
+ return `<blockquote ${classText} ${idText}>\n${quote.trim()}</blockquote>`;
388
+ },
389
+ heading(text: string, level: number) {
390
+ /* add an id to each heading */
391
+ return `<h${level} id="${text
392
+ .replace(/ /g, "-")
393
+ .toLowerCase()}">${text}</h${level}>`;
394
+ },
395
+ },
396
+ });
397
+
398
+ export default Parser;
package/src/templates.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { MDMError, MDMNonParserError } from "./errors";
2
+
1
3
  const templates: { [key: string]: string } = {};
2
4
 
3
5
  /**
@@ -6,7 +8,9 @@ const templates: { [key: string]: string } = {};
6
8
  * @param content The replacement string
7
9
  */
8
10
  export function new_template(name: string, content: string) {
9
- templates[name] = content;
11
+ if (name in templates)
12
+ throw new MDMNonParserError(`Template "${name}" already exists`);
13
+ templates[name] = content;
10
14
  }
11
15
 
12
16
  /* initialize default templates */
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { HTMLElement } from "node-html-parser";
2
+ import { Command } from "./commands";
3
+
4
+ export type CommandGroupType = {
5
+ preparse: Command[];
6
+ parse: Command[];
7
+ postparse: Command[];
8
+ };
9
+
10
+ type TaggedElementArguments = {
11
+ repeat?: number;
12
+ };
13
+
14
+ export type TaggedElement = {
15
+ "html-tag": string;
16
+ "var-tag": string;
17
+ _raw: string;
18
+ args: TaggedElementArguments;
19
+ node: HTMLElement;
20
+ };
21
+
22
+ export enum CommandType {
23
+ PREPARSE,
24
+ PARSE,
25
+ POSTPARSE,
26
+ }
27
+
28
+ export enum TargetType {
29
+ HTML,
30
+ MARKDOWN,
31
+ }
32
+
33
+ export type Checksum = string;
@@ -0,0 +1,44 @@
1
+ import fs from "fs";
2
+ import Parser from "../src/parser";
3
+ import path from "path";
4
+ import { expect, jest, test } from "@jest/globals";
5
+ import { TargetType } from "../src/types";
6
+
7
+ beforeAll(() => {
8
+ return new Promise((res, rej) => {
9
+ fs.mkdir(path.join("tests", "test-files"), res);
10
+ });
11
+ });
12
+ // afterAll(() => {
13
+ // return new Promise((res, rej) => {
14
+ // fs.rm(path.join("tests", "test-files"), { recursive: true }, res);
15
+ // });
16
+ // });
17
+
18
+ function put(text: string | NodeJS.ArrayBufferView, file: string) {
19
+ fs.writeFileSync(path.join("tests", "test-files", file), text);
20
+ }
21
+ function putDir(name: string) {
22
+ fs.mkdirSync(path.join("tests", "test-files", name));
23
+ }
24
+
25
+ beforeEach(() => {
26
+ return new Promise((res, rej) => {
27
+ fs.mkdir("tests/test-files", res);
28
+ });
29
+ });
30
+
31
+ afterEach(() => {
32
+ return new Promise((res, rej) => {
33
+ fs.rm("tests/test-files", { recursive: true }, res);
34
+ });
35
+ });
36
+
37
+ export default {
38
+ expect,
39
+ path,
40
+ Parser,
41
+ put,
42
+ putDir,
43
+ TargetType,
44
+ };
@@ -0,0 +1,92 @@
1
+ import util from "./_test-util";
2
+
3
+ describe("Use of templates", function () {
4
+ it("should import presentation template as expected", function () {
5
+ const output = new util.Parser("#mdtemplate<presentation>").get();
6
+ const template = `<style>html {width: 100vw;height: 100vh;}.slide {padding: 5%;border-radius: 25px;margin: 0;}div > .slide-num {position: absolute;top: 12.5%;right: 15%;/* font-size: 150%; */}body {margin: 5% 15%;}img {max-width: 100%;max-height: 40vh;}</style><script>document.addEventListener("DOMContentLoaded", () => {let current_slide = 0;const all_slides = document.querySelectorAll("div.slide");const num_slides = all_slides.length;all_slides.forEach((slide) => {const num_elem = document.createElement("p");num_elem.classList.add("slide-num");slide.appendChild(num_elem);});onkeydown = (ev) => {if (ev.key == "ArrowRight" && current_slide < all_slides.length - 1)update_slide(++current_slide);else if (ev.key == "ArrowLeft" && current_slide > 0)update_slide(--current_slide);};const update_slide = (index) => {all_slides.forEach((slide) => (slide.style.display = "none"));all_slides[current_slide].style.display = "block";all_slides[current_slide].lastChild.textContent = \`\${current_slide + 1} / \${num_slides}\`;};update_slide(current_slide);});</script>`;
7
+
8
+ util.expect(output).toBe(template + "\n\n");
9
+ });
10
+
11
+ it("should use custom templates from project extensions.js file", () => {
12
+ util.put(
13
+ "module.exports = {main: (new_template, _) => {new_template('hi', 'hello');}};",
14
+ "extensions.js"
15
+ );
16
+ util.put("#mdtemplate<hi>", "sample1.md");
17
+
18
+ util.expect(new util.Parser("tests/test-files/sample1.md").get()).toBe(
19
+ "hello\n\n"
20
+ );
21
+ });
22
+
23
+ it("should use custom commands from project extensions.js file", () => {
24
+ util.put(
25
+ 'module.exports = {main: (_, new_command) => {new_command(/#teep/, () => "#peet")}};',
26
+ "extensions.js"
27
+ );
28
+ util.put("#teep", "sample1.md");
29
+
30
+ const parser = new util.Parser("tests/test-files/sample1.md");
31
+ const output = parser.get();
32
+
33
+ util.expect(output).toEqual("#peet\n\n");
34
+ });
35
+ });
36
+
37
+ describe("Use of markdown hooks for SSR", () => {
38
+ it("should allow hooks hooks to be used", () => {
39
+ util.put("#mdhook<t1>\n<p::var>\n#mdendhook<t1>", "sample1.md");
40
+
41
+ const parser = new util.Parser("tests/test-files/sample1.md", {
42
+ allow_undefined: true,
43
+ });
44
+ parser.add_hook("t1", (map) => {
45
+ map["var"].node.textContent = "complete";
46
+ });
47
+
48
+ const output = parser.get();
49
+
50
+ util.expect(output).toBe("<p>complete</p>\n\n");
51
+ });
52
+
53
+ it("should allow for extracting a node from the document as a template using map", () => {
54
+ util.put(
55
+ `<html><body>#mdhook<template><b::name><p::class>#mdendhook<template></body></html>`,
56
+ "sample1.html"
57
+ );
58
+
59
+ const parser = new util.Parser("tests/test-files/sample1.html", {
60
+ allow_undefined: true,
61
+ });
62
+
63
+ parser.add_hook("template", (map) => {
64
+ map["name"].node.textContent = "bold";
65
+ map["class"].node.textContent = "paragraph";
66
+ });
67
+ const output = parser.get();
68
+
69
+ util.expect(output).toBe(
70
+ "<html><body><b>bold</b><p>paragraph</p></body></html>\n\n"
71
+ );
72
+ });
73
+ it("should allow for nested hooks to be used", () => {
74
+ util.put(
75
+ "#mdhook<t1><p::outer1>#mdhook<t2><p::inner>#mdendhook<t2><p::outer2>#mdendhook<t1>",
76
+ "sample1.md"
77
+ );
78
+
79
+ const parser = new util.Parser("tests/test-files/sample1.md", {});
80
+ parser.add_hook("t1", (map) => {
81
+ map["outer1"].node.textContent = "hello";
82
+ map["outer2"].node.textContent = "world";
83
+ });
84
+ parser.add_hook("t2", (map) => {
85
+ map["inner"].node.textContent = "!";
86
+ });
87
+
88
+ const output = parser.get();
89
+
90
+ util.expect(output).toBe("<p>hello</p><p>!</p><p>world</p>\n\n");
91
+ });
92
+ });