@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.4

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.
@@ -0,0 +1,505 @@
1
+ // @noflow
2
+ //
3
+ // Which words in a highlighted line are Flow's, and what kind of word each is.
4
+ //
5
+ // No highlighter ships a Flow grammar, so a JavaScript one runs over uf's code
6
+ // samples and Flow's own vocabulary — `component`, `hook`, `renders`, `match`,
7
+ // `opaque`, `mixed` — arrives as ordinary identifiers. This module decides,
8
+ // after the grammar has run, which of those occurrences are really Flow's, and
9
+ // `internal/highlight.js` turns the decision into a class.
10
+ //
11
+ // # Why this is not a regular expression over a token
12
+ //
13
+ // It used to be. A `(?<![.\w$])` look-behind was supposed to leave
14
+ // `text.match(…)` alone; it cannot, because the grammar splits a line into
15
+ // tokens wherever it likes and a look-behind only ever sees inside the token
16
+ // it is matching. Given `text.` and `match` as two tokens — which is what a
17
+ // grammar actually produces — the look-behind starts at the beginning of a
18
+ // string, finds nothing, and `match` came out red.
19
+ //
20
+ // So the line is reassembled and every rule is asked about the whole line.
21
+ //
22
+ // # Why the line is redacted first
23
+ //
24
+ // The class the mark produces sets `color` with `!important`, so marking a
25
+ // word inside a string literal does not merely add a colour: it *replaces* the
26
+ // string's own, in the middle of the string. `const s = "component renders"`
27
+ // came out with two red words inside a blue string, and `// component hook`
28
+ // with two red words inside a grey comment. Both were live in this repository.
29
+ //
30
+ // A rule cannot be trusted to notice that on its own, so nothing is asked:
31
+ // {@link redact} blanks every string, template, comment and regular expression
32
+ // on the line before any rule sees it, keeping the line's length so every
33
+ // position still means what it meant. A word that was inside one of those is
34
+ // then not there to match, and the punctuation around it — the `.` of
35
+ // `"x".match(y)` — still is.
36
+ //
37
+ // # Why the rules are per word rather than one shared rule
38
+ //
39
+ // The words are Flow's for different reasons and the counter-examples differ.
40
+ // `hook` is a keyword in `hook useThing()` and a property in `{ hook: 1 }`;
41
+ // `match` is a keyword in `match (x) {` and a method in `text.match(re)`;
42
+ // `mixed` is a *type*, not a keyword, and wants the colour `string` and
43
+ // `number` get rather than the one `export` gets. One predicate over all of
44
+ // them is how `const component = 1` ended up red.
45
+
46
+ /** The mark a decided word carries, read by `internal/highlight.js`. */
47
+ export const FLOW_MARK = Symbol.for("uf.flowMark");
48
+
49
+ /** A word Flow reserves. Painted like `export` and `return`. */
50
+ export const KEYWORD = "keyword";
51
+
52
+ /**
53
+ * A type Flow has and JavaScript does not. Painted like `string` and `number`,
54
+ * because that is what it is — `mixed` beside a red `component` said they were
55
+ * the same kind of word, and they are not.
56
+ */
57
+ export const TYPE = "type";
58
+
59
+ /**
60
+ * Every occurrence this module will consider.
61
+ *
62
+ * Whole words only, so `components`, `matcher` and `hooks` never reach a rule.
63
+ * `%checks` is here as one candidate rather than as `checks`, because the `%`
64
+ * is the only thing that distinguishes Flow's predicate marker from a variable
65
+ * called `checks` — and a JavaScript grammar paints that variable as a
66
+ * function call, which is the wrong answer twice over.
67
+ *
68
+ * `enum` is absent: every JavaScript grammar already treats it as a keyword.
69
+ */
70
+ const CANDIDATES =
71
+ /%checks(?![\w$])|(?<![\w$])(?:component|hook|renders|match|opaque|typeof|of|mixed|empty)(?![\w$])/g;
72
+
73
+ /**
74
+ * Words that may not be immediately followed by a Flow declaration keyword,
75
+ * because what follows them is a name being bound.
76
+ *
77
+ * `function match(re) {` satisfies every shape rule for a `match` expression —
78
+ * a subject in parentheses and a block after it — and is a function called
79
+ * `match`.
80
+ */
81
+ const BINDERS = /(?:^|[^\w$])(?:const|let|var|function|class|new)\s*$/;
82
+
83
+ /** Characters after which a `/` opens a regular expression, not a division. */
84
+ const BEFORE_REGEX = "(,=:[!&|?{};+-*%~^<>";
85
+
86
+ /**
87
+ * Keywords after which a `/` opens a regular expression.
88
+ *
89
+ * `return /x/` and `case /x/` are the ones that turn up; without them the
90
+ * literal is read as a division and its contents are left un-redacted.
91
+ */
92
+ const BEFORE_REGEX_WORD =
93
+ /(?:^|[^\w$])(?:return|case|typeof|in|of|new|do|else|yield|await|void|delete)$/;
94
+
95
+ /** Scanner carry: the line begins in ordinary code. */
96
+ const CODE = 0;
97
+
98
+ /** Scanner carry: the line begins inside a `/* … *\/`. */
99
+ const BLOCK_COMMENT = 1;
100
+
101
+ /** Scanner carry: the line begins inside a template literal. */
102
+ const TEMPLATE = 2;
103
+
104
+ /**
105
+ * The line with everything that is not code blanked out, same length.
106
+ *
107
+ * Blanking rather than removing, and blanking with spaces rather than dropping
108
+ * the characters, because every rule works in line coordinates and the marks
109
+ * are sliced out of the *original* text afterwards. A redacted line has to
110
+ * agree with the real one about where every remaining character is.
111
+ *
112
+ * `carry` is the state the previous line ended in, so a block comment or a
113
+ * template literal that spans lines stays redacted on the lines below its
114
+ * opener. The returned `next` is that state for the line after this one.
115
+ *
116
+ * Two deliberate approximations, both erring towards redacting more:
117
+ *
118
+ * * A template literal is blanked whole, `${…}` included. A Flow keyword
119
+ * inside an interpolation is therefore missed. Tracking brace depth through
120
+ * nested templates to recover it would be a JavaScript lexer, and the cost
121
+ * of the approximation is a missing colour rather than a wrong one.
122
+ * * A regular expression is recognised by what precedes it, which is the only
123
+ * way to tell `/re/` from a division without parsing. `a / b` is never
124
+ * mistaken for one; a literal in a position this does not list is left
125
+ * un-redacted.
126
+ */
127
+ export function redact(text, carry = CODE) {
128
+ let out = "";
129
+ let at = 0;
130
+ let state = carry;
131
+
132
+ while (at < text.length) {
133
+ if (state === BLOCK_COMMENT) {
134
+ const close = text.indexOf("*/", at);
135
+ const to = close === -1 ? text.length : close + 2;
136
+ out += " ".repeat(to - at);
137
+ at = to;
138
+ state = close === -1 ? BLOCK_COMMENT : CODE;
139
+ continue;
140
+ }
141
+ if (state === TEMPLATE) {
142
+ const close = closingAt(text, at, "`");
143
+ const to = close === -1 ? text.length : close;
144
+ out += " ".repeat(to - at);
145
+ at = to;
146
+ state = close === -1 ? TEMPLATE : CODE;
147
+ continue;
148
+ }
149
+
150
+ const here = text[at];
151
+ const next = text[at + 1];
152
+
153
+ if (here === "/" && next === "/") {
154
+ out += " ".repeat(text.length - at);
155
+ return { code: out, next: CODE };
156
+ }
157
+ if (here === "/" && next === "*") {
158
+ out += " ";
159
+ at += 2;
160
+ state = BLOCK_COMMENT;
161
+ continue;
162
+ }
163
+ if (here === '"' || here === "'") {
164
+ const close = closingAt(text, at + 1, here);
165
+ const to = close === -1 ? text.length : close;
166
+ out += " ".repeat(to - at);
167
+ at = to;
168
+ continue;
169
+ }
170
+ if (here === "`") {
171
+ const close = closingAt(text, at + 1, "`");
172
+ const to = close === -1 ? text.length : close;
173
+ out += " ".repeat(to - at);
174
+ at = to;
175
+ state = close === -1 ? TEMPLATE : CODE;
176
+ continue;
177
+ }
178
+ if (here === "/" && opensRegex(out)) {
179
+ const close = closingAt(text, at + 1, "/");
180
+ const to = close === -1 ? text.length : close;
181
+ out += " ".repeat(to - at);
182
+ at = to;
183
+ continue;
184
+ }
185
+
186
+ out += here;
187
+ at += 1;
188
+ }
189
+ return { code: out, next: state === BLOCK_COMMENT || state === TEMPLATE ? state : CODE };
190
+ }
191
+
192
+ /**
193
+ * One past the `close` that ends the literal begun before `from`, or `-1` when
194
+ * the literal does not close on this line.
195
+ *
196
+ * `-1` rather than the line's length, because a literal that ends on the last
197
+ * character of the line and one that does not end at all are different
198
+ * answers, and returning the length for both made ``const a = `x` `` carry a
199
+ * template into the next line and redact it whole.
200
+ */
201
+ function closingAt(text, from, close) {
202
+ let at = from;
203
+ while (at < text.length) {
204
+ const here = text[at];
205
+ if (here === "\\") {
206
+ at += 2;
207
+ continue;
208
+ }
209
+ if (here === close) {
210
+ return at + 1;
211
+ }
212
+ at += 1;
213
+ }
214
+ return -1;
215
+ }
216
+
217
+ /** Whether a `/` written after `before` opens a regular expression. */
218
+ function opensRegex(before) {
219
+ const code = before.trimEnd();
220
+ if (code === "") {
221
+ return true;
222
+ }
223
+ return BEFORE_REGEX.includes(code[code.length - 1]) || BEFORE_REGEX_WORD.test(code);
224
+ }
225
+
226
+ /**
227
+ * The rule for each word, and the kind of mark a decided occurrence gets.
228
+ *
229
+ * A rule is given the redacted line either side of the candidate. Both sides,
230
+ * because both sides carry the answer: `hook` is decided by what follows it
231
+ * and `typeof` by what precedes it.
232
+ *
233
+ * `modifier` is the one character a decided word may swallow. `renders*` and
234
+ * `renders?` are single tokens in Flow, and leaving the arity to be coloured
235
+ * as a multiplication or a ternary splits one word into two colours.
236
+ */
237
+ const WORDS = new Map([
238
+ ["component", { kind: KEYWORD, decide: declaresOrTypesAFunction }],
239
+ ["hook", { kind: KEYWORD, decide: declaresOrTypesAFunction }],
240
+ ["renders", { kind: KEYWORD, decide: introducesARenderType, modifier: /^[*?]/ }],
241
+ ["match", { kind: KEYWORD, decide: takesAMatchSubject }],
242
+ ["opaque", { kind: KEYWORD, decide: precedesTypeAlias }],
243
+ ["typeof", { kind: KEYWORD, decide: qualifiesAnImport }],
244
+ ["of", { kind: KEYWORD, decide: givesEnumRepresentation }],
245
+ ["%checks", { kind: KEYWORD, decide: () => true }],
246
+ ["mixed", { kind: TYPE, decide: standsInATypePosition }],
247
+ ["empty", { kind: TYPE, decide: standsInATypePosition }],
248
+ ]);
249
+
250
+ /**
251
+ * Whether the candidate is a member name — `text.match`, `a?.hook`.
252
+ *
253
+ * Checked before every other rule for every word: a name after a dot is never
254
+ * Flow's, whatever it is spelled.
255
+ */
256
+ function isMemberName(before) {
257
+ return before.trimEnd().endsWith(".");
258
+ }
259
+
260
+ /** The last character of `before` that is not whitespace. */
261
+ function lastSignificant(before) {
262
+ const code = before.trimEnd();
263
+ return code === "" ? "" : code[code.length - 1];
264
+ }
265
+
266
+ /**
267
+ * `component Avatar(…)`, `hook useNow(…)`, and the function types that share
268
+ * their spelling — `type Fn = component<T>(…) renders mixed`.
269
+ *
270
+ * The declaration form needs no context beyond its own shape, because nothing
271
+ * else in JavaScript is `word Name(`. The type form does: `component(props)`
272
+ * and `hook(x)` are ordinary calls, so the bare-parenthesis spelling is only
273
+ * Flow's where a type can appear at all. `component<` needs no such test —
274
+ * a comparison against a generic call is not a thing anyone writes.
275
+ *
276
+ * This is what leaves `const component = 1;` and `{ hook: 1 }` alone: neither
277
+ * is followed by a name and a parenthesis, and neither sits in a type
278
+ * position.
279
+ */
280
+ function declaresOrTypesAFunction(before, after) {
281
+ if (isMemberName(before) || BINDERS.test(before)) {
282
+ return false;
283
+ }
284
+ if (/^\s+[A-Za-z_$][\w$]*\s*[(<]/.test(after)) {
285
+ return true;
286
+ }
287
+ if (/^\s*</.test(after)) {
288
+ return true;
289
+ }
290
+ return /^\s*\(/.test(after) && ":|&,(<[=>".includes(lastSignificant(before));
291
+ }
292
+
293
+ /**
294
+ * `renders T`, `renders? T`, `renders* T`.
295
+ *
296
+ * A render type is always followed by a type, so requiring one is what
297
+ * separates it from every other use of the spelling: `{ +renders: Node }` is
298
+ * followed by a colon, `const renders = 1` by an equals sign, `x.renders` by
299
+ * nothing at all.
300
+ *
301
+ * `renders (A | B)` is not marked. Parenthesised render types are legal and
302
+ * rare, and accepting them would accept every call to a function named
303
+ * `renders`, which is neither.
304
+ */
305
+ function introducesARenderType(before, after) {
306
+ if (isMemberName(before) || BINDERS.test(before)) {
307
+ return false;
308
+ }
309
+ return /^\s*[*?]?\s*[A-Za-z_$]/.test(after);
310
+ }
311
+
312
+ /**
313
+ * `match (subject) { … }`, statement or expression.
314
+ *
315
+ * The subject and the block are both required, and that is the whole rule:
316
+ * `text.match(re)` has a subject and no block, and a function called `match`
317
+ * declared as `function match(re) {` has both — which is why {@link BINDERS}
318
+ * is consulted first.
319
+ *
320
+ * A subject that runs past the end of the line is accepted on the strength of
321
+ * the unclosed parenthesis; the block is on a line this rule cannot see, and
322
+ * an unclosed subject is not something a method call does.
323
+ */
324
+ function takesAMatchSubject(before, after) {
325
+ if (isMemberName(before) || BINDERS.test(before)) {
326
+ return false;
327
+ }
328
+ const open = /^\s*\(/.exec(after);
329
+ if (open == null) {
330
+ return false;
331
+ }
332
+ let depth = 0;
333
+ for (let at = open[0].length - 1; at < after.length; at += 1) {
334
+ const here = after[at];
335
+ if (here === "(") {
336
+ depth += 1;
337
+ } else if (here === ")") {
338
+ depth -= 1;
339
+ if (depth === 0) {
340
+ return /^\s*\{/.test(after.slice(at + 1));
341
+ }
342
+ }
343
+ }
344
+ return true;
345
+ }
346
+
347
+ /** `opaque type ID = string`. Nothing else in Flow spells `opaque`. */
348
+ function precedesTypeAlias(before, after) {
349
+ return !isMemberName(before) && /^\s+type(?![\w$])/.test(after);
350
+ }
351
+
352
+ /**
353
+ * The `typeof` of `import typeof Bar from …`.
354
+ *
355
+ * A grammar gives `import type` its keyword colour and leaves `import typeof`
356
+ * grey, because `type` is a modifier it knows and `typeof` in that position is
357
+ * not. The `typeof` of an expression is already coloured and is not this one,
358
+ * which is why the rule asks what precedes rather than matching the word.
359
+ */
360
+ function qualifiesAnImport(before) {
361
+ return /^\s*(?:import|export)\s+$/.test(before);
362
+ }
363
+
364
+ /** The `of` of `enum Status of string { … }`, and no other `of`. */
365
+ function givesEnumRepresentation(before) {
366
+ return /^\s*(?:export\s+)?(?:declare\s+)?enum\s+[A-Za-z_$][\w$]*\s+$/.test(before);
367
+ }
368
+
369
+ /**
370
+ * Whether `mixed` or `empty` is being used as a type rather than as a name.
371
+ *
372
+ * A type follows the punctuation that introduces one — `a: mixed`,
373
+ * `A | mixed`, `Array<mixed>`, `(x) => mixed`, `renders mixed`. An equals sign
374
+ * only introduces one inside a type alias, which is the difference between
375
+ * `type T = mixed` and `const mixed = 1`; both have an identifier after an
376
+ * `=`, and only one of them is Flow's.
377
+ */
378
+ function standsInATypePosition(before, after) {
379
+ if (isMemberName(before) || /^\s*:/.test(after)) {
380
+ return false;
381
+ }
382
+ const code = before.trimEnd();
383
+ if (code.endsWith("=>")) {
384
+ return true;
385
+ }
386
+ const previous = lastSignificant(before);
387
+ if (":|&,(<[".includes(previous)) {
388
+ return true;
389
+ }
390
+ if (previous === "=") {
391
+ return /(?:^|[^\w$])type\s+[A-Za-z_$][\w$]*[^=]*=$/.test(code);
392
+ }
393
+ return /(?:^|[^\w$])renders\s*[*?]?$/.test(code);
394
+ }
395
+
396
+ /**
397
+ * The marks a line carries, as `[start, end, kind]` in line coordinates.
398
+ *
399
+ * `code` is the redacted line; the marks are sliced out of the real one.
400
+ */
401
+ function marksIn(code) {
402
+ const marks = [];
403
+ for (const found of code.matchAll(CANDIDATES)) {
404
+ const start = found.index ?? 0;
405
+ const end = start + found[0].length;
406
+ const word = WORDS.get(found[0]);
407
+ if (word == null || !word.decide(code.slice(0, start), code.slice(end))) {
408
+ continue;
409
+ }
410
+ const swallows = word.modifier != null && word.modifier.test(code.slice(end));
411
+ marks.push([start, swallows ? end + 1 : end, word.kind]);
412
+ }
413
+ return marks;
414
+ }
415
+
416
+ /**
417
+ * A whole block of tokenised lines, with Flow's words marked.
418
+ *
419
+ * The block rather than the line is the unit because a block comment and a
420
+ * template literal both run past a line ending, and a line inside one is
421
+ * indistinguishable from code when it is read on its own.
422
+ */
423
+ export function markLines(lines) {
424
+ let carry = CODE;
425
+ return lines.map((line) => {
426
+ const text = line.map((token) => token.content).join("");
427
+ const { code, next } = redact(text, carry);
428
+ carry = next;
429
+ return split(line, text, marksIn(code));
430
+ });
431
+ }
432
+
433
+ /**
434
+ * One line, read as if the block began there.
435
+ *
436
+ * Exported because it is where the decision is visible without starting Shiki:
437
+ * give it the token split a grammar would produce and it says which words it
438
+ * marked. `tests/library/highlight.test.js` uses exactly that, and the splits
439
+ * in it are the ones that had bugs.
440
+ */
441
+ export function markLine(line) {
442
+ return markLines([line])[0];
443
+ }
444
+
445
+ /**
446
+ * The line's tokens, cut around each mark.
447
+ *
448
+ * A grammar puts `component Avatar(src: string) {` in one token, so the marked
449
+ * word usually has to be cut out of a token rather than found as one. The cuts
450
+ * are taken from the original `text`, so a mark that spans a token boundary —
451
+ * `%checks`, which arrives as ` %` and `checks` — becomes one marked piece per
452
+ * token it crosses rather than being lost.
453
+ */
454
+ function split(line, text, marks) {
455
+ if (marks.length === 0) {
456
+ return line;
457
+ }
458
+
459
+ const out = [];
460
+ let at = 0;
461
+ for (const token of line) {
462
+ const start = at;
463
+ const end = at + token.content.length;
464
+ at = end;
465
+
466
+ const cuts = [];
467
+ for (const [from, to, kind] of marks) {
468
+ if (from < end && to > start) {
469
+ cuts.push([Math.max(from, start), Math.min(to, end), kind]);
470
+ }
471
+ }
472
+ if (cuts.length === 0) {
473
+ out.push(token);
474
+ continue;
475
+ }
476
+
477
+ let index = start;
478
+ for (const [from, to, kind] of cuts) {
479
+ if (from > index) {
480
+ out.push(piece(token, text.slice(index, from), index - start));
481
+ }
482
+ out.push({ ...piece(token, text.slice(from, to), from - start), [FLOW_MARK]: kind });
483
+ index = to;
484
+ }
485
+ if (index < end) {
486
+ out.push(piece(token, text.slice(index, end), index - start));
487
+ }
488
+ }
489
+ return out;
490
+ }
491
+
492
+ /**
493
+ * A slice of one token.
494
+ *
495
+ * `offset` is carried through for every piece, because Shiki and any other
496
+ * transformer use it to map a token back to the source; a piece with the wrong
497
+ * offset breaks anything that reads positions, such as line highlighting.
498
+ */
499
+ function piece(token, content, at) {
500
+ return {
501
+ ...token,
502
+ content,
503
+ offset: (token.offset ?? 0) + at,
504
+ };
505
+ }
@@ -0,0 +1,181 @@
1
+ // @noflow
2
+ //
3
+ // Syntax highlighting for fenced code, at build time.
4
+ //
5
+ // uf's claim is that MDX works without a plugin list, and a documentation page
6
+ // whose code samples are undifferentiated grey does not meet it. So this is on
7
+ // by default, it runs during the build rather than shipping a highlighter to
8
+ // the browser, and it knows about Flow.
9
+ //
10
+ // # Flow
11
+ //
12
+ // No highlighter has a Flow grammar. A JavaScript one runs instead, and it
13
+ // fails at Flow's syntax in two different ways that need two different
14
+ // answers:
15
+ //
16
+ // * It **mis-labels** the words it does tokenise. `component`, `hook`,
17
+ // `renders`, `match`, `opaque` and `mixed` are ordinary identifiers to it,
18
+ // so they would be the only uncoloured words in a uf sample — which is the
19
+ // wrong way round, since they are the reason the sample is there.
20
+ // `internal/flow-keywords.js` decides which occurrences are really Flow's,
21
+ // after the grammar has run.
22
+ //
23
+ // * It **stops** at a `component` or `hook` declaration and mis-scopes
24
+ // everything after an exact object type, so the rest of the construct — or
25
+ // the rest of the block — arrives as one unstyled run, or in the colours of
26
+ // whatever the grammar fell into. There is nothing to re-label; the tokens
27
+ // do not exist. `internal/flow-grammar-shim.js` shows the grammar JavaScript
28
+ // it can parse instead and rebuilds the tokens over the real text, before
29
+ // this module ever sees them.
30
+ //
31
+ // The order is fixed and is the reason they are separate modules: the shim
32
+ // runs before tokenising, the marking runs after, and each is testable without
33
+ // the other. Writing and maintaining a Flow TextMate grammar would replace
34
+ // both; until one exists, this is the honest approximation, and its limits are
35
+ // recorded in each module's header.
36
+ //
37
+ // Marking rather than recolouring, because the colour is not knowable here.
38
+ // Copying the colour from another keyword in the same block was the first
39
+ // attempt and it fails exactly where it matters: a snippet that is nothing but
40
+ // `component Tab(label: string) renders React.Node` contains no keyword the
41
+ // grammar recognises, so there was nothing to copy from. A class moves the
42
+ // decision to CSS, which is where the theme's colours already live.
43
+
44
+ import rehypeShiki from "@shikijs/rehype";
45
+
46
+ import { shimFlowGrammar } from "./flow-grammar-shim.js";
47
+ import { FLOW_MARK, KEYWORD, TYPE, markLines } from "./flow-keywords.js";
48
+
49
+ /**
50
+ * The languages a documentation page actually uses.
51
+ *
52
+ * Loading every grammar Shiki ships would cost seconds and megabytes for
53
+ * languages nobody writes here; `highlight.langs` extends this.
54
+ */
55
+ const DEFAULT_LANGS = [
56
+ "javascript",
57
+ "jsx",
58
+ "json",
59
+ "css",
60
+ "html",
61
+ "markdown",
62
+ "mdx",
63
+ "shellscript",
64
+ "rust",
65
+ "toml",
66
+ "yaml",
67
+ "diff",
68
+ ];
69
+
70
+ /**
71
+ * `flow` and the shorthands people type in a fence, mapped to a real grammar.
72
+ *
73
+ * Only names that are *not* grammars in their own right: Shiki rejects an
74
+ * alias pointing at itself, and `jsx`, `md` and `yml` are already loaded
75
+ * languages or aliases it knows.
76
+ */
77
+ const ALIASES = {
78
+ flow: "javascript",
79
+ console: "shellscript",
80
+ };
81
+
82
+ /**
83
+ * The fences both Flow passes apply to.
84
+ *
85
+ * Flow is JavaScript, and both passes read the source as JavaScript: the shim
86
+ * rewrites JavaScript declarations, and the marking pass lexes strings and
87
+ * comments to keep out of them. Neither means anything in another language. A
88
+ * `match` in a Rust sample is Rust's and Rust's grammar has already coloured
89
+ * it; the backticks that fence a code block inside an `mdx` sample are not an
90
+ * unterminated template literal, though a JavaScript lexer pointed at them
91
+ * says they are.
92
+ *
93
+ * `js` is here because Shiki's own alias table resolves it and this runs
94
+ * before that.
95
+ */
96
+ const FLOW_FENCES = new Set(["javascript", "js", "jsx"]);
97
+
98
+ /** The class each kind of mark carries, for the stylesheet to colour. */
99
+ const MARK_CLASSES = {
100
+ [KEYWORD]: "uf-flow-keyword",
101
+ [TYPE]: "uf-flow-type",
102
+ };
103
+
104
+ /**
105
+ * Where the shim's undo is parked between the two hooks that need it.
106
+ *
107
+ * Shiki builds a fresh context object for each block it highlights and calls
108
+ * every hook with it, so `this.meta` is the one place a `preprocess` can leave
109
+ * something for the `tokens` of the same block — and only that block. A field
110
+ * on the transformer would be shared by every block in the document.
111
+ *
112
+ * Its absence is also how `tokens` knows the fence was not a Flow one: the
113
+ * language is `preprocess`'s to see, and asking twice is how the two halves
114
+ * would come to disagree about a block.
115
+ */
116
+ const RESTORE = Symbol.for("uf.flowRestore");
117
+
118
+ /**
119
+ * Give Flow's syntax the colours the grammar could not.
120
+ *
121
+ * Three hooks, one per phase: rewrite the declarations the grammar cannot
122
+ * parse, undo that and mark Flow's words on the token stream, and put the
123
+ * mark's class on the span.
124
+ */
125
+ function flowSyntax() {
126
+ return {
127
+ name: "uf:flow-syntax",
128
+ preprocess(code, options) {
129
+ if (!FLOW_FENCES.has(ALIASES[options.lang] ?? options.lang)) {
130
+ return code;
131
+ }
132
+ const shim = shimFlowGrammar(code);
133
+ this.meta[RESTORE] = shim.restore;
134
+ return shim.code;
135
+ },
136
+ tokens(lines) {
137
+ const restore = this.meta[RESTORE];
138
+ return restore == null ? lines : markLines(restore(lines));
139
+ },
140
+ span(node, _line, _col, _lineElement, token) {
141
+ const added = MARK_CLASSES[token[FLOW_MARK]];
142
+ if (added == null) {
143
+ return;
144
+ }
145
+ const existing = node.properties.class;
146
+ node.properties.class = existing == null ? added : `${existing} ${added}`;
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * The rehype plugin entry, or `null` when a project turns highlighting off.
153
+ *
154
+ * Two themes rather than one, emitted as CSS variables (`defaultColor: false`),
155
+ * because a page follows the reader's light or dark preference and a build-time
156
+ * highlighter cannot know it. The stylesheet picks between them.
157
+ *
158
+ * @param {{enabled?: boolean, themes?: {light: string, dark: string}, langs?: string[]}} options
159
+ */
160
+ export function highlightPlugin(options) {
161
+ const config = options ?? {};
162
+ if (config.enabled === false) {
163
+ return null;
164
+ }
165
+ const themes = config.themes ?? { light: "github-light", dark: "github-dark-dimmed" };
166
+ const langs = [...new Set([...DEFAULT_LANGS, ...(config.langs ?? [])])];
167
+
168
+ return [
169
+ rehypeShiki,
170
+ {
171
+ themes,
172
+ langs,
173
+ langAlias: ALIASES,
174
+ defaultColor: false,
175
+ // A fence naming a language Shiki does not have should render as plain
176
+ // code, not fail the build.
177
+ fallbackLanguage: "text",
178
+ transformers: [flowSyntax()],
179
+ },
180
+ ];
181
+ }