@human-synthesis/norns-core 0.0.10 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,9 +37,24 @@ export default {
37
37
 
38
38
  - `.n` files default `<script>` to `lang="civet"` and `<template>` to `lang="pug"` — write neither attribute and it just works.
39
39
  - `<script lang="civet">` blocks are compiled to JavaScript via [@danielx/civet](https://civet.dev) before svelte-preprocess sees them.
40
- - Top-level Pug-only content is auto-wrapped in `<template lang="pug">` so you don't need the wrapper boilerplate.
41
- - Pug class shorthand is rewritten so Tailwind variants (`.hover:bg-X`) and fractional values (`.gap-2.5`) work without escaping.
42
- - `+if` / `+elseif` / `+else` chains are rewritten to Svelte block syntax (`{#if}/{:else if}/{:else}/{/if}`).
40
+ - Top-level Pug-only content is auto-wrapped in `<template lang="pug">` so you don't need the wrapper boilerplate; a trailing `<script>` block may omit its closing tag.
41
+ - Pug class shorthand is rewritten so Tailwind variants (`.hover:bg-X`), fractional values (`.gap-2.5`) and slashes (`.bg-white/40`) work without escaping.
42
+ - `+if` / `+elseif` / `+else` chains are rewritten to Svelte block syntax (`{#if}/{:else if}/{:else}/{/if}`), and `+snippet('name', args)` blocks to `{#snippet name(args)}`.
43
+ - Pug and Civet errors are reported as `file:line:column` **in the `.n` file you wrote**, with a code frame — not against svelte-preprocess's ~50-line mixin prelude or the script block's own numbering.
44
+
45
+ ## Template syntax
46
+
47
+ | Write | Becomes |
48
+ |---|---|
49
+ | `+if('cond')` … `+elseif('other')` … `+else` | `{#if cond}` … `{:else if other}` … `{:else}` … `{/if}` |
50
+ | `+each('items as item (item.id)')` | `{#each items as item (item.id)}` … `{/each}` — the Svelte `as` form; `item of items` is **not** valid |
51
+ | `+snippet('row', user, idx)` | `{#snippet row(user, idx)}` … `{/snippet}` |
52
+ | `\| {@render row(u, i)}` / `\| {@html raw}` | passed through as text — any line starting with `{` needs the `\| ` prefix |
53
+ | `attr!="{expr}"` | `attr={expr}` (Svelte expression); plain `attr="text"` stays a string |
54
+ | `.flex.items-center.gap-2.5(class="hover:bg-x")` | `class="flex items-center gap-2.5 hover:bg-x"` |
55
+ | `+key('expr')`, `+await('p')` / `+then('v')` / `+catch('e')` | the matching Svelte blocks (svelte-preprocess mixins) |
56
+
57
+ The `<script>` block is Civet by default (`lang="ts"` / `lang="js"` opt out): `{ a, b = 1 } := $props()`, `count .= $state 0` (use `.=` for anything you reassign), `total := $derived a + b`, `$effect => …`.
43
58
 
44
59
  ## Auto-imports — see the umbrella
45
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-core",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "Norns core — Svelte preprocessor for Civet, Pug, and .n files",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -11,28 +11,37 @@
11
11
  },
12
12
  "files": [
13
13
  "src",
14
+ "types",
14
15
  "README.md"
15
16
  ],
17
+ "types": "./types/index.d.ts",
16
18
  "scripts": {
17
- "test": "bun test"
19
+ "test": "bun test",
20
+ "build:types": "tsc -p tsconfig.types.json"
18
21
  },
19
22
  "exports": {
20
- ".": "./src/index.js",
21
- "./preprocess": "./src/preprocess.js",
22
- "./package.json": "./package.json"
23
+ ".": {
24
+ "types": "./types/index.d.ts",
25
+ "default": "./src/index.js"
23
26
  },
24
- "peerDependencies": {
25
- "svelte": "^5.0.0"
26
- },
27
- "dependencies": {
28
- "@danielx/civet": "^0.11.0",
29
- "pug": "^3.0.3",
30
- "svelte-preprocess": "^6.0.3"
31
- },
32
- "engines": {
33
- "node": ">=18"
34
- },
35
- "publishConfig": {
36
- "access": "public"
37
- }
27
+ "./preprocess": {
28
+ "types": "./types/preprocess.d.ts",
29
+ "default": "./src/preprocess.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "peerDependencies": {
34
+ "svelte": "^5.0.0"
35
+ },
36
+ "dependencies": {
37
+ "@danielx/civet": "^0.11.0",
38
+ "pug": "^3.0.3",
39
+ "svelte-preprocess": "^6.0.3"
40
+ },
41
+ "engines": {
42
+ "node": ">=18"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ }
38
47
  }
package/src/preprocess.js CHANGED
@@ -3,7 +3,6 @@ import { compile as compileCivet } from '@danielx/civet';
3
3
 
4
4
  export { transformIfChains, transformSnippets, rewritePugClasses, extractPugClasses };
5
5
 
6
-
7
6
  const SCRIPT_TAG = /<script\b([^>]*)>/i;
8
7
  const TEMPLATE_TAG = /<template\b([^>]*)>/i;
9
8
 
@@ -47,6 +46,259 @@ function escapeRegex(s) {
47
46
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
47
  }
49
48
 
49
+ /* === Error mapping ===========================================================
50
+ *
51
+ * svelte-preprocess renders Pug with a ~50-line mixin prelude prepended, so
52
+ * raw Pug errors point ~50 lines past the real one; on top of that, `.n`
53
+ * files are re-arranged (template first, +if/+snippet rewritten) before Pug
54
+ * sees them. Civet errors inside `<script>` blocks are relative to the
55
+ * block, not the file. Both are mapped back to `file:line:column` in the
56
+ * source the user actually wrote, so an agent (or a human) opens the right
57
+ * line on the first try.
58
+ */
59
+
60
+ /** Original file contents, keyed by filename, stashed by nornDefaultLangs. */
61
+ const ORIGINAL_SOURCES = new Map();
62
+ const FALLBACK_PUG_PRELUDE = 52;
63
+
64
+ function rememberSource(filename, content) {
65
+ if (!filename) return;
66
+ if (ORIGINAL_SOURCES.size > 2000) ORIGINAL_SOURCES.clear();
67
+ ORIGINAL_SOURCES.set(filename, content);
68
+ }
69
+
70
+ /** Parse Pug's numbered code frame out of an error message. */
71
+ function parsePugFrame(message) {
72
+ const frame = [];
73
+ let marked = null;
74
+ for (const line of String(message).split('\n')) {
75
+ const m = line.match(/^\s*(>)?\s*(\d+)\|(.*)$/);
76
+ if (!m) continue;
77
+ const n = Number(m[2]);
78
+ frame.push({ n, text: m[3].replace(/^ /, ''), marked: !!m[1] });
79
+ if (m[1]) marked = n;
80
+ }
81
+ return { frame, marked };
82
+ }
83
+
84
+ /** The human-readable core of a Pug error (last non-frame, non-location line). */
85
+ function pugMessageCore(message) {
86
+ const lines = String(message).split('\n');
87
+ let core = '';
88
+ for (const raw of lines) {
89
+ const line = raw.trim();
90
+ if (!line) continue;
91
+ if (/^\[svelte-preprocess\]/.test(line)) continue;
92
+ if (/^>?\s*\d+\|/.test(line)) continue;
93
+ if (/^-+\^?$/.test(line)) continue;
94
+ if (/^\S+:\d+:\d+$/.test(line)) continue;
95
+ core = line;
96
+ }
97
+ return core || 'Pug error';
98
+ }
99
+
100
+ function extractTemplate(content) {
101
+ const m = content.match(/<template\b[^>]*>([\s\S]*?)<\/template>/i);
102
+ return m ? m[1] : content;
103
+ }
104
+
105
+ /**
106
+ * Reverse the `.n` rewrites for one template line so it can be looked up in
107
+ * the original source: `| {#if x}` came from `+if('x')`, etc.
108
+ */
109
+ function sourceVariants(text) {
110
+ const t = text.trim();
111
+ const out = [text, t];
112
+ let m;
113
+ if ((m = t.match(/^\|\s*\{#if (.+)\}$/))) out.push(`+if('${m[1]}')`, `+if("${m[1]}")`);
114
+ else if ((m = t.match(/^\|\s*\{:else if (.+)\}$/)))
115
+ out.push(`+elseif('${m[1]}')`, `+elseif("${m[1]}")`);
116
+ else if (/^\|\s*\{:else\}$/.test(t)) out.push('+else');
117
+ else if ((m = t.match(/^\|\s*\{#snippet (\w+)\((.*)\)\}$/))) {
118
+ out.push(m[2] ? `+snippet('${m[1]}', ${m[2]})` : `+snippet('${m[1]}')`);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * Find the original line for a (possibly rewritten) template line.
125
+ *
126
+ * @returns {{ line: number, exact: boolean }}
127
+ */
128
+ function findOriginalLine(text, origLines, approx) {
129
+ const trimmed = text.trim();
130
+ const clamp = (n) => Math.min(Math.max(1, n), Math.max(1, origLines.length));
131
+ if (!trimmed) return { line: clamp(approx), exact: false };
132
+
133
+ const nearest = (idxs) =>
134
+ idxs.reduce(
135
+ (best, j) => (Math.abs(j + 1 - approx) < Math.abs(best + 1 - approx) ? j : best),
136
+ idxs[0]
137
+ );
138
+
139
+ for (const variant of sourceVariants(text)) {
140
+ const v = variant.trim();
141
+ if (!v) continue;
142
+ const hits = [];
143
+ for (let j = 0; j < origLines.length; j++) {
144
+ if (origLines[j] === variant || origLines[j].trim() === v) hits.push(j);
145
+ }
146
+ if (hits.length === 1) return { line: hits[0] + 1, exact: true };
147
+ if (hits.length > 1) return { line: nearest(hits) + 1, exact: true };
148
+ }
149
+
150
+ // Class-shorthand rewrites move classes into `(class="...")`; fall back to
151
+ // the tag/leading-class prefix.
152
+ const prefix = trimmed.match(/^[\w.#-]+/)?.[0];
153
+ if (prefix && prefix.length >= 3) {
154
+ const hits = [];
155
+ for (let j = 0; j < origLines.length; j++) {
156
+ if (origLines[j].trim().startsWith(prefix)) hits.push(j);
157
+ }
158
+ if (hits.length > 0) return { line: nearest(hits) + 1, exact: false };
159
+ }
160
+ return { line: clamp(approx), exact: false };
161
+ }
162
+
163
+ function frameOf(lines, line, column) {
164
+ const from = Math.max(1, line - 2);
165
+ const to = Math.min(lines.length, line + 1);
166
+ const width = String(to).length;
167
+ const out = [];
168
+ for (let n = from; n <= to; n++) {
169
+ out.push(`${n === line ? '>' : ' '} ${String(n).padStart(width)}| ${lines[n - 1] ?? ''}`);
170
+ if (n === line && column)
171
+ out.push(` ${' '.repeat(width)}| ${' '.repeat(Math.max(0, column - 1))}^`);
172
+ }
173
+ return out.join('\n');
174
+ }
175
+
176
+ /**
177
+ * Turn a raw svelte-preprocess Pug error into one that points at the
178
+ * source file. Non-Pug errors pass through untouched.
179
+ */
180
+ function mapPugError(e, content, filename) {
181
+ if (!e || typeof e.message !== 'string') return e;
182
+ if (!/Pug error|pug/i.test(e.message) && typeof e.line !== 'number') return e;
183
+
184
+ const { frame, marked } = parsePugFrame(e.message);
185
+ const pugLine = typeof e.line === 'number' ? e.line : marked;
186
+ if (!pugLine) return e;
187
+
188
+ const tplLines = extractTemplate(content).split('\n');
189
+
190
+ // Vote for the prelude offset using every frame line we can find verbatim
191
+ // in the template we handed to Pug.
192
+ const votes = new Map();
193
+ for (const f of frame) {
194
+ if (!f.text.trim()) continue;
195
+ for (let j = 0; j < tplLines.length; j++) {
196
+ if (tplLines[j] === f.text) {
197
+ const off = f.n - (j + 1);
198
+ votes.set(off, (votes.get(off) ?? 0) + 1);
199
+ }
200
+ }
201
+ }
202
+ let offset = FALLBACK_PUG_PRELUDE;
203
+ let best = 0;
204
+ for (const [off, n] of votes) {
205
+ if (n > best) {
206
+ best = n;
207
+ offset = off;
208
+ }
209
+ }
210
+
211
+ const tplLine = pugLine - offset;
212
+ const text = tplLines[tplLine - 1] ?? '';
213
+
214
+ const original = (filename && ORIGINAL_SOURCES.get(filename)) ?? content;
215
+ const origLines = original.split('\n');
216
+ const tplStart = Math.max(
217
+ 0,
218
+ origLines.findIndex((l) => /<template\b/i.test(l))
219
+ );
220
+ const { line, exact } = findOriginalLine(text, origLines, tplLine + tplStart);
221
+ const column = typeof e.column === 'number' && e.column > 0 ? e.column : null;
222
+
223
+ const core = pugMessageCore(e.message);
224
+ const name = filename ? filename.split(/[\\/]/).pop() : 'template';
225
+ const frameText = frameOf(origLines, line, column);
226
+ const err = new Error(
227
+ `${name}:${line}:${column ?? 1}: Pug: ${core}${exact ? '' : ' (approximate line)'}\n\n${frameText}`
228
+ );
229
+ err.name = 'PugError';
230
+ err.code = 'norns_pug_error';
231
+ err.line = line;
232
+ err.column = column;
233
+ err.filename = filename;
234
+ err.frame = frameText;
235
+ err.approximate = !exact;
236
+ err.pugLine = pugLine;
237
+ err.cause = e;
238
+ return err;
239
+ }
240
+
241
+ /**
242
+ * Map a Civet ParseError thrown for a `<script>` block back to the line in
243
+ * the containing file.
244
+ */
245
+ function mapCivetScriptError(e, content, filename) {
246
+ if (!e || typeof e.line !== 'number') return e;
247
+ const original = filename && ORIGINAL_SOURCES.get(filename);
248
+ if (!original) return e;
249
+
250
+ const blockRe = /<script\b[^>]*>([\s\S]*?)<\/script>/gi;
251
+ let m;
252
+ let bodyStart = -1;
253
+ let firstBodyStart = -1;
254
+ while ((m = blockRe.exec(original)) !== null) {
255
+ const start = m.index + m[0].indexOf('>') + 1;
256
+ if (firstBodyStart < 0) firstBodyStart = start;
257
+ if (m[1] === content) {
258
+ bodyStart = start;
259
+ break;
260
+ }
261
+ }
262
+ if (bodyStart < 0) bodyStart = firstBodyStart;
263
+ if (bodyStart < 0) return e;
264
+
265
+ const bodyStartLine = original.slice(0, bodyStart).split('\n').length;
266
+ const line = bodyStartLine + e.line - 1;
267
+ const column = typeof e.column === 'number' ? e.column : null;
268
+ const origLines = original.split('\n');
269
+ const name = filename.split(/[\\/]/).pop();
270
+ const rest = String(e.message).split('\n');
271
+ const head = rest.shift() ?? '';
272
+ const core = head.replace(/^\S+:\d+:\d+\s*/, '');
273
+ const frameText = frameOf(origLines, line, column);
274
+ e.message = [`${name}:${line}:${column ?? 1}: Civet: ${core}`, ...rest, '', frameText].join('\n');
275
+ e.line = line;
276
+ e.column = column;
277
+ e.filename = filename;
278
+ e.frame = frameText;
279
+ e.code = 'norns_civet_error';
280
+ return e;
281
+ }
282
+
283
+ /**
284
+ * Wrap svelte-preprocess so Pug failures come back mapped to the source.
285
+ */
286
+ function withMappedPugErrors(sp) {
287
+ return {
288
+ ...sp,
289
+ name: sp.name ?? 'norns-svelte-preprocess',
290
+ markup: sp.markup
291
+ ? async (args) => {
292
+ try {
293
+ return await sp.markup(args);
294
+ } catch (e) {
295
+ throw mapPugError(e, args.content, args.filename);
296
+ }
297
+ }
298
+ : undefined
299
+ };
300
+ }
301
+
50
302
  function stripQuotes(s) {
51
303
  s = s.trim();
52
304
  if (s.length >= 2) {
@@ -501,6 +753,7 @@ function nornDefaultLangs() {
501
753
  name: 'norns-default-langs',
502
754
  markup({ content, filename }) {
503
755
  if (!filename || !filename.endsWith('.n')) return null;
756
+ rememberSource(filename, content);
504
757
 
505
758
  let out = autoCloseTrailingBlock(content);
506
759
  out = transformIfChains(out);
@@ -565,11 +818,16 @@ function nornsCivetScript() {
565
818
  name: 'norns-civet-script',
566
819
  async script({ content, attributes, filename }) {
567
820
  if (attributes.lang !== 'civet' && attributes.lang !== 'cv') return null;
568
- const result = await compileCivet(content, {
569
- js: true,
570
- sourceMap: true,
571
- filename: filename ?? 'unknown'
572
- });
821
+ let result;
822
+ try {
823
+ result = await compileCivet(content, {
824
+ js: true,
825
+ sourceMap: true,
826
+ filename: filename ?? 'unknown'
827
+ });
828
+ } catch (e) {
829
+ throw mapCivetScriptError(e, content, filename);
830
+ }
573
831
  // Drop the `lang` attribute so svelte-preprocess doesn't try to load
574
832
  // a `./transformers/civet` module — at this point the script body is
575
833
  // already plain JS, no further script-level transform needed.
@@ -583,7 +841,6 @@ function nornsCivetScript() {
583
841
  };
584
842
  }
585
843
 
586
-
587
844
  /**
588
845
  * Norns preprocessor stack.
589
846
  *
@@ -601,22 +858,24 @@ export function nornsPreprocess(options = {}) {
601
858
  return [
602
859
  nornDefaultLangs(),
603
860
  nornsCivetScript(),
604
- sveltePreprocess({
605
- pug: {},
606
- typescript: {
607
- compilerOptions: {
608
- // Silence TS 6.x's deprecation warning for older moduleResolution
609
- // values (node10) that some toolchains still default to.
610
- ignoreDeprecations: '6.0',
611
- // Preserve value imports (Svelte component imports look "unused"
612
- // to the TS transpiler since their usage lives in the template,
613
- // but they MUST be emitted). verbatimModuleSyntax keeps any
614
- // non-`import type` imports verbatim.
615
- verbatimModuleSyntax: true,
616
- isolatedModules: true
617
- }
618
- },
619
- ...options
620
- })
861
+ withMappedPugErrors(
862
+ sveltePreprocess({
863
+ pug: {},
864
+ typescript: {
865
+ compilerOptions: {
866
+ // Silence TS 6.x's deprecation warning for older moduleResolution
867
+ // values (node10) that some toolchains still default to.
868
+ ignoreDeprecations: '6.0',
869
+ // Preserve value imports (Svelte component imports look "unused"
870
+ // to the TS transpiler since their usage lives in the template,
871
+ // but they MUST be emitted). verbatimModuleSyntax keeps any
872
+ // non-`import type` imports verbatim.
873
+ verbatimModuleSyntax: true,
874
+ isolatedModules: true
875
+ }
876
+ },
877
+ ...options
878
+ })
879
+ )
621
880
  ];
622
881
  }
@@ -0,0 +1 @@
1
+ export { nornsPreprocess } from "./preprocess.js";
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Norns preprocessor stack.
3
+ *
4
+ * - `.norn` files default `<script>` to Civet and `<template>` to Pug (and
5
+ * auto-wrap top-level content in `<template lang="pug">` if no template
6
+ * block is present).
7
+ * - `<script lang="civet">` blocks are compiled to JS via @danielx/civet
8
+ * before svelte-preprocess sees them. Civet emits ESM-correct
9
+ * `let count = $state(0)` directly, so no rune-fusion or import-lift
10
+ * passes are needed.
11
+ *
12
+ * @param {import('svelte-preprocess').AutoPreprocessOptions} [options]
13
+ */
14
+ export function nornsPreprocess(options?: import("svelte-preprocess").AutoPreprocessOptions): any[];
15
+ /**
16
+ * Rewrite Pug `+if('expr') / +elseif('expr') / +else` chains to raw Svelte
17
+ * block syntax emitted via Pug `|` text. Bypasses svelte-preprocess's `+if`
18
+ * mixin (which doesn't support chaining).
19
+ *
20
+ * Input:
21
+ * +if('a')
22
+ * div one
23
+ * +elseif('b')
24
+ * div two
25
+ * +else
26
+ * div three
27
+ *
28
+ * Output:
29
+ * | {#if a}
30
+ * div one
31
+ * | {:else if b}
32
+ * div two
33
+ * | {:else}
34
+ * div three
35
+ * | {/if}
36
+ */
37
+ export function transformIfChains(content: any): any;
38
+ /**
39
+ * Rewrite Pug `+snippet('name', args…)` blocks to Svelte 5 `{#snippet name(args)}`
40
+ * via Pug `|` text emit. Recurses into the body so nested snippets work
41
+ * (`Tabs > +snippet('item', tab) > Card > +snippet('header')`).
42
+ *
43
+ * Input:
44
+ * +snippet('header')
45
+ * h2 Title
46
+ *
47
+ * +snippet('row', user, idx)
48
+ * .row Hello {user.name} {idx}
49
+ *
50
+ * Output:
51
+ * | {#snippet header()}
52
+ * h2 Title
53
+ * | {/snippet}
54
+ *
55
+ * | {#snippet row(user, idx)}
56
+ * .row Hello {user.name} {idx}
57
+ * | {/snippet}
58
+ */
59
+ export function transformSnippets(content: any): any;
60
+ /**
61
+ * Rewrite Pug element lines whose class shorthand contains characters Pug's
62
+ * lexer rejects (`:`, `/`) or that Pug already mis-parses (fractional `.\d+`
63
+ * continuations like `.gap-2.5`). Route those classes from shorthand into the
64
+ * `(class="...")` attribute. Pug then sees only safe class shorthand.
65
+ *
66
+ * Examples:
67
+ * `.text-blue.hover:bg-red(href="/")`
68
+ * → `.text-blue(class="hover:bg-red" href="/")`
69
+ * `.gap-2.5.flex`
70
+ * → `.flex(class="gap-2.5")`
71
+ * `.bg-white/40.text-4xl(class="static")`
72
+ * → `.text-4xl(class="bg-white/40 static")`
73
+ *
74
+ * Skips lines inside `<script>` / `<style>` blocks.
75
+ */
76
+ export function rewritePugClasses(content: any): any;
77
+ /**
78
+ * Walk `content` and return the set of Pug class-shorthand names found in
79
+ * element class chains (`.foo.bar-baz.hover:bg-red`) plus the value of any
80
+ * `class="..."` attribute on the same lines.
81
+ *
82
+ * Tailwind v4's content scanner extracts utility candidates from string
83
+ * contexts (`class="…"`, JS strings) but doesn't recognise Pug's chained
84
+ * shorthand — the dotted chain looks like one token. Pages render with the
85
+ * class names present in the markup but no matching CSS, which is silent
86
+ * and hard to spot. The companion `nornsTailwindPlugin()` Vite plugin in
87
+ * `@human-synthesis/norns` calls this and feeds the union into Tailwind via
88
+ * an injected `@source inline(...)` directive.
89
+ *
90
+ * Skips lines inside `<script>` / `<style>` blocks. Skips lines that begin
91
+ * with `|`, `<`, `+`, `:`, or `//` (Pug text emits, raw HTML, mixin calls,
92
+ * pug filters, and comments). For each remaining line, reads an optional
93
+ * tag, then chained `.<class>` segments (handling `:`, `/`, and fractional
94
+ * `.\d+` continuations), then collects the value of any `class="…"` or
95
+ * `class!="…"` attribute that follows.
96
+ *
97
+ * Pure function — does not mutate `content`. Returns a `Set<string>` so
98
+ * callers can union across many files without dedup work.
99
+ *
100
+ * @param {string} content
101
+ * @returns {Set<string>}
102
+ */
103
+ export function extractPugClasses(content: string): Set<string>;