gp-dimm-city 1.0.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +32 -0
  3. package/README.md +165 -0
  4. package/components.yaml +832 -0
  5. package/fonts/lixdu/LICENSE.txt +34 -0
  6. package/fonts/lixdu/lixdu.woff +0 -0
  7. package/fonts/titillium-web/OFL.txt +93 -0
  8. package/fonts/titillium-web/titillium-web-400-italic.ttf +0 -0
  9. package/fonts/titillium-web/titillium-web-400.ttf +0 -0
  10. package/fonts/titillium-web/titillium-web-600-italic.ttf +0 -0
  11. package/fonts/titillium-web/titillium-web-600.ttf +0 -0
  12. package/fonts/titillium-web/titillium-web-700-italic.ttf +0 -0
  13. package/fonts/titillium-web/titillium-web-700.ttf +0 -0
  14. package/fonts/tomorrow/OFL.txt +93 -0
  15. package/fonts/tomorrow/tomorrow-400-italic.ttf +0 -0
  16. package/fonts/tomorrow/tomorrow-400.ttf +0 -0
  17. package/fonts/tomorrow/tomorrow-500-italic.ttf +0 -0
  18. package/fonts/tomorrow/tomorrow-500.ttf +0 -0
  19. package/fonts/tomorrow/tomorrow-700-italic.ttf +0 -0
  20. package/fonts/tomorrow/tomorrow-700.ttf +0 -0
  21. package/images/brick-bg-01.png +0 -0
  22. package/package.json +78 -0
  23. package/plugin.js +2131 -0
  24. package/snippets/alert.md +2 -0
  25. package/snippets/block.md +5 -0
  26. package/snippets/callout.md +5 -0
  27. package/snippets/card.md +11 -0
  28. package/snippets/chapter-start-page.md +11 -0
  29. package/snippets/credits-page.md +13 -0
  30. package/snippets/definition.md +7 -0
  31. package/snippets/dm-note.md +5 -0
  32. package/snippets/gear.md +9 -0
  33. package/snippets/learning-path.md +10 -0
  34. package/snippets/lede.md +5 -0
  35. package/snippets/outcome.md +8 -0
  36. package/snippets/procedure.md +7 -0
  37. package/snippets/sidebar.md +7 -0
  38. package/snippets/skill.md +9 -0
  39. package/snippets/specialty-art.md +5 -0
  40. package/snippets/specialty-card.md +5 -0
  41. package/snippets/specialty.md +11 -0
  42. package/snippets/toc-page.md +15 -0
  43. package/styles/components/callouts.css +227 -0
  44. package/styles/components/cards.css +487 -0
  45. package/styles/components/chrome.css +483 -0
  46. package/styles/components/data.css +1026 -0
  47. package/styles/components/images.css +215 -0
  48. package/styles/components/section.css +1074 -0
  49. package/styles/components/specialty-identity.css +315 -0
  50. package/styles/components/specialty.css +273 -0
  51. package/styles/dc-component-defaults.css +214 -0
  52. package/styles/dc-core.css +268 -0
  53. package/styles/dc-fonts.css +107 -0
  54. package/styles/dc-identity.css +66 -0
  55. package/styles/dc-native.css +330 -0
  56. package/styles/dc-palette.css +198 -0
  57. package/styles/page-rules.css +207 -0
  58. package/styles/page-templates.css +636 -0
package/plugin.js ADDED
@@ -0,0 +1,2131 @@
1
+ /**
2
+ * Dimm City — the gp-dimm-city Gutterpress plugin (server-side markdown-it)
3
+ *
4
+ * IMPORTANT: This plugin only transforms content marked with special markers.
5
+ * Regular markdown content passes through unchanged.
6
+ *
7
+ * MARKERS (support optional key="value" attributes):
8
+ * @sidebar → Start a dc-sidebar wrapper
9
+ * @end-sidebar → End sidebar wrapper
10
+ * @sidebar-box → Start a dc-sidebar-box wrapper
11
+ * @end-sidebar-box → End sidebar-box wrapper
12
+ * @specialty → Start a specialty wrapper (auto-closes any prior specialty,
13
+ * learning-path, or skill)
14
+ * @end-specialty → Manually end a specialty wrapper
15
+ * @definition → Start a dc-definition-block wrapper
16
+ * @end-definition → End definition wrapper
17
+ * @procedure → Start a dc-steps procedure wrapper
18
+ * @end-procedure → End procedure wrapper (auto-closes on EOF with a warning)
19
+ * @callout → Start a dc-alert callout (variant=note|warning|dm|vibe|origin|visit|gear)
20
+ * @end-callout → End callout wrapper
21
+ * @dm-note → Start a Dream Master note (sugar for @callout variant=dm)
22
+ * @end-dm-note → End dm-note wrapper
23
+ * @block → Section enclosure card (.dc-panel|.dc-slate|.dc-shard|.dc-codex label="Title")
24
+ * @end-block → End block enclosure
25
+ * @lede → Start a dc-intro lede wrapper
26
+ * @end-lede → End lede wrapper
27
+ * @learning-path → Start a learning path section (auto-closes previous sections)
28
+ * @end-learning-path → Manually end a learning path section
29
+ * @skill → Start a skill card (auto-closes previous skill)
30
+ * @end-skill → End skill transformation mode
31
+ * @continue → Continuation marker — emits a card with a "{name} ▸"
32
+ * tab so an oversized skill card can be split across pages
33
+ * while keeping a visible link to its origin card
34
+ * @specialty-intro → Cosmetic specialty intro wrapper
35
+ * @end-specialty-intro
36
+ * @specialty-card → Individual specialty card
37
+ * @end-specialty-card
38
+ * @specialty-art → Full-bleed specialty art panel (.dc-specialty-art)
39
+ * @end-specialty-art
40
+ * @gear → Gear card (shorthand for @card .dc-gear)
41
+ * @end-gear
42
+ * (chapter-opener composite is now markup-driven — see CSS notes below)
43
+ * @toc → Table-of-contents wrapper
44
+ * @end-toc
45
+ * @glossary → Glossary wrapper
46
+ * @end-glossary
47
+ * @outcome → 5-rung d20 outcome ladder block
48
+ * @end-outcome
49
+ * @tape → Inline tape divider (`<div class="dc-tape">— § —</div>`)
50
+ *
51
+ * GFM ALERT SYNTAX:
52
+ * `> [!NOTE]` / `[!WARNING]` / `[!DM]` / `[!VIBE]` / `[!ORIGIN]` / `[!VISIT]`
53
+ * / `[!GEAR]` / `[!FLAVOR]` / `[!PULLQUOTE]` blockquotes are transformed
54
+ * into `<div class="dc-alert dc-<type>">` (moved from print-md core 2026-05-17).
55
+ *
56
+ * SPECIALTY VARIANTS:
57
+ * Skill card and learning-path variants are controlled by the .specialty.<name>
58
+ * parent container (CSS parent-selector model), not per-card attributes.
59
+ * Authors wrap the entire specialty section in @specialty .augmerc and every
60
+ * card inside automatically inherits the shape and accent colors.
61
+ *
62
+ * ATTRIBUTE SUPPORT:
63
+ * Markers can include key="value", key='value', or key=value pairs:
64
+ *
65
+ * @learning-path data-foo="bar"
66
+ * → <div class="dc-learning-path dc-path-block" data-foo="bar">
67
+ *
68
+ * @skill id="my-skill" data-category="combat"
69
+ * → Extra attributes added to skill-card wrapper
70
+ *
71
+ * @skill {.dc-allow-split}
72
+ * → <div class="dc-skill-card dc-allow-split" ...>
73
+ *
74
+ * @learning-path {.custom-path}
75
+ * → <div class="dc-learning-path dc-path-block custom-path" ...>
76
+ *
77
+ * LEARNING PATH FORMAT:
78
+ * @learning-path
79
+ * ### Title
80
+ * > Subtitle/description
81
+ * - Skill A
82
+ * - Skill B
83
+ * - Skill C
84
+ *
85
+ * SKILL FORMAT:
86
+ * @skill
87
+ * #### Skill Name
88
+ * > Flavor text
89
+ * 1. **0 AP** *Ability Name:* Description
90
+ * 2. **2 AP** *Another:* Description
91
+ * ##### Outcomes (optional sub-header)
92
+ * | Roll | Outcome |
93
+ * | --- | --- |
94
+ * | 20 | Critical |
95
+ *
96
+ * Auto-closes at: EOF, @end-skill, @end-learning-path, @learning-path, or @skill
97
+ */
98
+
99
+ function esc(s) {
100
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
101
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
102
+ }
103
+
104
+ function makeToken(type, content, nesting) {
105
+ return {
106
+ type: type,
107
+ tag: '',
108
+ nesting: nesting || 0,
109
+ attrs: null,
110
+ map: null,
111
+ level: 0,
112
+ children: null,
113
+ content: typeof content === 'string' ? content : '',
114
+ markup: '',
115
+ info: '',
116
+ meta: null,
117
+ block: true,
118
+ hidden: false,
119
+ attrSet: function(name, value) {
120
+ if (!this.attrs) this.attrs = [];
121
+ const idx = this.attrIndex(name);
122
+ if (idx < 0) {
123
+ this.attrs.push([name, value]);
124
+ } else {
125
+ this.attrs[idx][1] = value;
126
+ }
127
+ },
128
+ attrIndex: function(name) {
129
+ if (!this.attrs) return -1;
130
+ for (let j = 0; j < this.attrs.length; j++) {
131
+ if (this.attrs[j][0] === name) return j;
132
+ }
133
+ return -1;
134
+ }
135
+ };
136
+ }
137
+
138
+ // Add/remove one class on a token's existing `class` attribute without
139
+ // disturbing any other class already there. Used to tag the LAST blockquote
140
+ // in a @card body as `.dc-card-footer` (dc#44): the plugin sees each body
141
+ // blockquote as it streams past and has no lookahead, so it tags the
142
+ // current one and untags whichever one it previously tagged — the tag
143
+ // only ever sits on the most recently seen blockquote, which is exactly
144
+ // the one `blockquote:last-of-type` will match once the card closes.
145
+ function addTokenClass(tok, cls) {
146
+ const current = (tok.attrGet('class') || '').split(/\s+/).filter(Boolean);
147
+ if (!current.includes(cls)) {
148
+ current.push(cls);
149
+ tok.attrSet('class', current.join(' '));
150
+ }
151
+ }
152
+
153
+ function removeTokenClass(tok, cls) {
154
+ const current = (tok.attrGet('class') || '').split(/\s+/).filter(Boolean);
155
+ const next = current.filter((c) => c !== cls);
156
+ if (next.length === current.length) return;
157
+ if (next.length) {
158
+ tok.attrSet('class', next.join(' '));
159
+ } else {
160
+ const idx = tok.attrIndex('class');
161
+ if (idx >= 0) tok.attrs.splice(idx, 1);
162
+ }
163
+ }
164
+
165
+ function parseSkillTitle(text) {
166
+ // Parse "Skill Name" or "Skill Name | T0" or "Skill Name | T0 | highlight"
167
+ const parts = text.split('|');
168
+ const name = parts[0].trim();
169
+ const tier = parts.length > 1 ? parts[1].trim() : '';
170
+ const flags = parts.slice(2).map(f => f.trim().toLowerCase());
171
+ const highlight = flags.includes('highlight');
172
+ return { name, tier, highlight };
173
+ }
174
+
175
+ function slugify(text) {
176
+ return (text || '')
177
+ .toLowerCase()
178
+ .replace(/[^a-z0-9]+/g, '-')
179
+ .replace(/^-+|-+$/g, '');
180
+ }
181
+
182
+ function specialtyCodeFromClass(className) {
183
+ const classList = (className || '').split(/\s+/).filter(Boolean);
184
+ const specialtyMap = {
185
+ augmerc: 'AUG',
186
+ proxy: 'PRX',
187
+ streetwarden: 'STW',
188
+ gutterdruid: 'GDR',
189
+ cybersurgeon: 'CBS',
190
+ wirephreak: 'WPH',
191
+ technosorcerer: 'TNS',
192
+ etherlock: 'ETH',
193
+ };
194
+
195
+ for (const cls of classList) {
196
+ if (specialtyMap[cls]) return specialtyMap[cls];
197
+ }
198
+
199
+ return 'PATH';
200
+ }
201
+
202
+ /**
203
+ * Skill card variants are now controlled by the .specialty.<name> parent
204
+ * container (CSS parent-selector model). The variant= attribute on @skill,
205
+ * @continue, and @learning-path has been removed. No SKILL_VARIANTS map.
206
+ */
207
+
208
+ function buildStickerChain(items) {
209
+ let html = '<div class="dc-stickers">';
210
+ items.forEach((name, i) => {
211
+ // The first sticker used to get .active treatment, rendering it in
212
+ // --blood while siblings rendered in --ink-dark. User feedback:
213
+ // "there's no reason for the first skill in the learning path list
214
+ // to be marked as active or a different color than the other skills.
215
+ // For example, under biting distance, punishing counter is red, and
216
+ // the rest are black. It should also be black." Removed.
217
+ const cls = 'dc-sticker';
218
+ html += '<span class="' + cls + '"><span class="dc-sticker-ref">' + esc(String(i + 1)) + '</span>' + esc(name.trim()) + '</span>';
219
+ if (i < items.length - 1) {
220
+ html += '<span class="dc-arrow">»</span>';
221
+ }
222
+ });
223
+ html += '</div>\n';
224
+ return html;
225
+ }
226
+
227
+ const ROLL_THE_DIE_TEXT = 'ROLL THE DIE!';
228
+
229
+ function isWordChar(char) {
230
+ return char !== '' && /[A-Za-z0-9_]/.test(char);
231
+ }
232
+
233
+ function copyTextToken(token, content, state) {
234
+ const copy = new state.Token(token.type, token.tag, token.nesting);
235
+ Object.assign(copy, token);
236
+ copy.content = content;
237
+ return copy;
238
+ }
239
+
240
+ function splitRollDieText(token, state) {
241
+ const content = token.content || '';
242
+ const replacement = [];
243
+ let cursor = 0;
244
+ let searchFrom = 0;
245
+
246
+ while (searchFrom < content.length) {
247
+ const index = content.indexOf(ROLL_THE_DIE_TEXT, searchFrom);
248
+ if (index === -1) break;
249
+
250
+ const before = index > 0 ? content[index - 1] : '';
251
+ const end = index + ROLL_THE_DIE_TEXT.length;
252
+ const after = end < content.length ? content[end] : '';
253
+ if (isWordChar(before) || isWordChar(after)) {
254
+ searchFrom = index + 1;
255
+ continue;
256
+ }
257
+
258
+ if (index > cursor) {
259
+ replacement.push(copyTextToken(token, content.slice(cursor, index), state));
260
+ }
261
+
262
+ const rollToken = copyTextToken(token, ROLL_THE_DIE_TEXT, state);
263
+ rollToken.type = 'dc_roll_the_die';
264
+ rollToken.tag = 'span';
265
+ rollToken.nesting = 0;
266
+ replacement.push(rollToken);
267
+
268
+ cursor = end;
269
+ searchFrom = end;
270
+ }
271
+
272
+ if (replacement.length === 0) return null;
273
+ if (cursor < content.length) {
274
+ replacement.push(copyTextToken(token, content.slice(cursor), state));
275
+ }
276
+ return replacement;
277
+ }
278
+
279
+ const VOID_HTML_TAGS = new Set([
280
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
281
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
282
+ ]);
283
+
284
+ function updateRawHtmlStack(html, stack) {
285
+ const match = html.match(/^<\s*(\/?)\s*([A-Za-z][A-Za-z0-9:-]*)\b/);
286
+ if (!match) return;
287
+
288
+ const tagName = match[2].toLowerCase();
289
+ if (match[1]) {
290
+ const openIndex = stack.lastIndexOf(tagName);
291
+ if (openIndex !== -1) stack.length = openIndex;
292
+ return;
293
+ }
294
+
295
+ if (!VOID_HTML_TAGS.has(tagName) && !/\/\s*>$/.test(html)) {
296
+ stack.push(tagName);
297
+ }
298
+ }
299
+
300
+ function dcRollDieTransform(state) {
301
+ state.tokens.forEach(token => {
302
+ if (token.type !== 'inline' || !Array.isArray(token.children)) return;
303
+
304
+ const children = [];
305
+ const rawHtmlStack = [];
306
+ token.children.forEach(child => {
307
+ if (child.type === 'html_inline') {
308
+ updateRawHtmlStack(child.content || '', rawHtmlStack);
309
+ children.push(child);
310
+ return;
311
+ }
312
+
313
+ const replacement = child.type === 'text' && rawHtmlStack.length === 0
314
+ ? splitRollDieText(child, state)
315
+ : null;
316
+ if (replacement) children.push(...replacement);
317
+ else children.push(child);
318
+ });
319
+ token.children = children;
320
+ });
321
+ }
322
+
323
+ function renderInlineChildren(inlineTok, md) {
324
+ if (!inlineTok || !inlineTok.children) return esc(inlineTok.content || '');
325
+ return md.renderer.render(inlineTok.children, md.options, {});
326
+ }
327
+
328
+ function collectTableTokens(tokens, start) {
329
+ const result = [];
330
+ let depth = 0;
331
+ for (let i = start; i < tokens.length; i++) {
332
+ result.push(tokens[i]);
333
+ if (tokens[i].type === 'table_open') depth++;
334
+ if (tokens[i].type === 'table_close') {
335
+ depth--;
336
+ if (depth === 0) break;
337
+ }
338
+ }
339
+ return result;
340
+ }
341
+
342
+ function skipToTableClose(tokens, start) {
343
+ for (let i = start; i < tokens.length; i++) {
344
+ if (tokens[i].type === 'table_close') return i;
345
+ }
346
+ return tokens.length - 1;
347
+ }
348
+
349
+ function getTableHeaders(tableTokens) {
350
+ const headers = [];
351
+ let inHead = false;
352
+ for (let i = 0; i < tableTokens.length; i++) {
353
+ if (tableTokens[i].type === 'thead_open') inHead = true;
354
+ if (tableTokens[i].type === 'thead_close') break;
355
+ if (inHead && tableTokens[i].type === 'inline') {
356
+ headers.push(tableTokens[i].content.toLowerCase().trim());
357
+ }
358
+ }
359
+ return headers;
360
+ }
361
+
362
+ function classifyTable(headers) {
363
+ if (headers.includes('roll') && headers.includes('outcome')) {
364
+ return 'outcomes';
365
+ }
366
+ if (headers.includes('distance')) {
367
+ return 'distance';
368
+ }
369
+ return '';
370
+ }
371
+
372
+ function getRollTier(text) {
373
+ const clean = text.replace(/[\u2013\u2014]/g, '-').trim();
374
+ if (clean === '20') return 'crit';
375
+ if (clean === '1') return 'fail';
376
+ // Handle ranges like "11 - 19" or "11-19"
377
+ const rangeMatch = clean.match(/^(\d+)/);
378
+ if (rangeMatch) {
379
+ const n = parseInt(rangeMatch[1], 10);
380
+ if (n >= 11) return 'hit';
381
+ if (n >= 6) return 'mixed';
382
+ if (n >= 2) return 'miss';
383
+ }
384
+ return 'hit';
385
+ }
386
+
387
+ function buildOutcomesBlock(rows, md, needsAvoid = true) {
388
+ // data-break-inside="avoid" is added when the parent card can be split (has
389
+ // allow-split) — the polyfill needs this to keep the
390
+ // outcomes table together when the card itself isn't protected. When the card
391
+ // already has data-break-inside="avoid" (non-splittable cards), adding a
392
+ // nested avoid creates a conflicting inner break that the polyfill resolves
393
+ // by splitting the card at the outcomes boundary, leaving a headless
394
+ // card-body on one page and the outcomes-only continuation on the next.
395
+ const avoidAttr = needsAvoid ? ' data-break-inside="avoid"' : '';
396
+ let html = '<div class="dc-outcomes"' + avoidAttr + '>\n';
397
+ html += ' <div class="dc-outcomes-label">Outcomes</div>\n';
398
+
399
+ rows.forEach(row => {
400
+ if (row.length < 2) return;
401
+ const rollVal = row[0].trim();
402
+ const outcomeText = row[1].trim();
403
+ const tier = getRollTier(rollVal);
404
+
405
+ // Render markdown inline content (e.g., **bold**, *italic*)
406
+ const renderedOutcome = md.renderInline(outcomeText);
407
+
408
+ const outcomeNameMap = {
409
+ crit: 'Crit',
410
+ hit: 'Hit',
411
+ mixed: 'Hard Choice',
412
+ miss: 'Miss',
413
+ fail: 'Catastrophe',
414
+ };
415
+
416
+ html += ' <div class="dc-outcome-row ' + tier + '">\n';
417
+ html += ' <span class="dc-outcome-key tier-' + tier + '"><span class="dc-outcome-name">' + outcomeNameMap[tier] + '</span><span class="dc-outcome-roll">' + esc(rollVal) + '</span></span>\n';
418
+ html += ' <span class="dc-outcome-text">' + renderedOutcome + '</span>\n';
419
+ html += ' </div>\n';
420
+ });
421
+
422
+ html += '</div>\n';
423
+ return html;
424
+ }
425
+
426
+ function buildDistanceTags(rows, md) {
427
+ let html = '<div class="dc-sub-header">AP Cost × Distance</div>\n';
428
+ html += '<div class="dc-distance-tags">\n';
429
+
430
+ rows.forEach(row => {
431
+ if (row.length < 2) return;
432
+ const distance = row[0].trim();
433
+ const cost = row[1].trim();
434
+
435
+ // Render markdown inline content
436
+ const renderedDistance = md.renderInline(distance);
437
+ const renderedCost = md.renderInline(cost);
438
+
439
+ html += ' <span class="dc-dist-tag">\n';
440
+ html += ' <span class="dc-dist-ap">' + renderedCost + '</span>\n';
441
+ html += ' <span class="dc-dist-name">' + renderedDistance + '</span>\n';
442
+ html += ' </span>\n';
443
+ });
444
+
445
+ html += '</div>\n';
446
+ return html;
447
+ }
448
+
449
+ function buildTable(tableTokens, tableClass, md, needsAvoid = true) {
450
+ const rows = [];
451
+ let currentRow = [];
452
+ let inBody = false;
453
+
454
+ for (let i = 0; i < tableTokens.length; i++) {
455
+ const t = tableTokens[i];
456
+ if (t.type === 'tbody_open') inBody = true;
457
+ if (t.type === 'tbody_close') inBody = false;
458
+ if (t.type === 'tr_open' && inBody) currentRow = [];
459
+ if (t.type === 'tr_close' && inBody) {
460
+ rows.push(currentRow);
461
+ }
462
+ if (t.type === 'td_open' && inBody) {
463
+ const contentTok = tableTokens[i + 1];
464
+ if (contentTok && contentTok.type === 'inline') {
465
+ currentRow.push(contentTok.content);
466
+ }
467
+ }
468
+ }
469
+
470
+ if (tableClass === 'outcomes') {
471
+ return buildOutcomesBlock(rows, md, needsAvoid);
472
+ }
473
+ return buildDistanceTags(rows, md);
474
+ }
475
+
476
+ // Parse ability from list item - handles rendered HTML: "<strong>0 AP</strong> <em>Name:</em> Description"
477
+ function parseAbilityFromListItem(html) {
478
+ // Match <strong>N AP</strong> or <strong>N-X AP</strong> at the start (rendered HTML)
479
+ const apMatch = html.match(/^\s*<strong>([^<]+)<\/strong>\s*/i);
480
+ if (!apMatch) return null;
481
+
482
+ let apVal = apMatch[1].trim();
483
+ // Normalize: ensure "AP" suffix
484
+ if (!apVal.toUpperCase().includes('AP')) {
485
+ apVal = apVal + ' AP';
486
+ }
487
+
488
+ const rest = html.slice(apMatch[0].length);
489
+
490
+ // Determine AP class
491
+ let apClass = 'dc-ap';
492
+ if (apVal === '0 AP' || apVal === '0AP') apClass += ' free';
493
+ else if (apVal.includes('-') || apVal.toUpperCase().includes('X')) apClass += ' variable';
494
+
495
+ return {
496
+ apVal: apVal,
497
+ apClass: apClass,
498
+ text: rest
499
+ };
500
+ }
501
+
502
+
503
+ // Parse key="value", key='value', key=value, bare .class / #id tokens,
504
+ // and {.class #id} attribute blocks from the body string following a marker keyword.
505
+ //
506
+ // Grammar matches parseMarkerLine() in Gutterpress's markers.js — inlined here
507
+ // rather than imported, because plugin code cannot resolve Gutterpress at runtime:
508
+ // - Quote-aware tokenization: key="a b" and key='a b' preserve spaces inside quotes
509
+ // - .classname — shorthand class (multiple allowed)
510
+ // - #id — shorthand id
511
+ // - key=value — arbitrary attribute; key="class" splits on whitespace/commas
512
+ // - bare token — treated as a class (DC markers have no positional "name" slot)
513
+ //
514
+ // Back-compat: brace blocks {.class #id} are stripped and injected as tokens
515
+ // before the main pass so @specialty {.augmerc} continues to work alongside
516
+ // the new bare-token form @specialty .augmerc.
517
+ function parseAttrs(str) {
518
+ // Pre-pass: extract brace blocks {.class1 .class2 #id} for back-compat,
519
+ // then remove them from the string so the main tokenizer doesn't see them.
520
+ const braceClasses = [];
521
+ const braceIds = [];
522
+ const strNoBraces = str.replace(/\{([^}]+)\}/g, (_, inside) => {
523
+ for (const part of inside.trim().split(/\s+/)) {
524
+ if (part.startsWith('.')) braceClasses.push(part.slice(1));
525
+ else if (part.startsWith('#')) braceIds.push(part.slice(1));
526
+ }
527
+ return ' ';
528
+ });
529
+
530
+ // Tokenize the remaining string using the same quote-aware tokenizer as
531
+ // markers.js's parseMarkerLine().
532
+ const tokens = [];
533
+ let buf = '';
534
+ let quote = null;
535
+ for (let i = 0; i < strNoBraces.length; i++) {
536
+ const ch = strNoBraces[i];
537
+ if (quote) {
538
+ if (ch === quote) quote = null;
539
+ else buf += ch;
540
+ continue;
541
+ }
542
+ if (ch === '"' || ch === "'") {
543
+ quote = ch;
544
+ continue;
545
+ }
546
+ if (/\s/.test(ch)) {
547
+ if (buf) tokens.push(buf);
548
+ buf = '';
549
+ continue;
550
+ }
551
+ buf += ch;
552
+ }
553
+ if (buf) tokens.push(buf);
554
+
555
+ // Process tokens: mirrors parseMarkerLine's attr-accumulation loop.
556
+ // DC markers have no positional "name" slot — bare tokens become classes.
557
+ const attrs = {};
558
+ const classes = [...braceClasses];
559
+ if (braceIds.length) attrs['id'] = braceIds[braceIds.length - 1];
560
+
561
+ for (const t of tokens) {
562
+ if (t.startsWith('.')) {
563
+ const c = t.slice(1).trim();
564
+ if (c) classes.push(c);
565
+ continue;
566
+ }
567
+ if (t.startsWith('#')) {
568
+ const id = t.slice(1).trim();
569
+ if (id) attrs['id'] = id;
570
+ continue;
571
+ }
572
+ const eq = t.indexOf('=');
573
+ if (eq > 0) {
574
+ const key = t.slice(0, eq).trim();
575
+ const val = t.slice(eq + 1).trim();
576
+ if (!key) continue;
577
+ if (key === 'class') {
578
+ val.split(/[,\s]+/).filter(Boolean).forEach((c) => classes.push(c));
579
+ } else if (key === 'id') {
580
+ if (val) attrs['id'] = val;
581
+ } else {
582
+ attrs[key] = val;
583
+ }
584
+ continue;
585
+ }
586
+ // Bare token (no . # =) — becomes a class for DC markers
587
+ if (t) classes.push(t);
588
+ }
589
+
590
+ if (classes.length) attrs['class'] = classes.join(' ');
591
+ return attrs;
592
+ }
593
+
594
+ // Check if a paragraph contains a marker and extract any attributes
595
+ // Returns { matched: true, attrs: {...} } or { matched: false }
596
+ function parseMarker(tok, tokens, i, marker) {
597
+ if (tok.type !== 'paragraph_open') return { matched: false };
598
+ const inline = tokens[i + 1];
599
+ if (!inline || inline.type !== 'inline') return { matched: false };
600
+
601
+ const content = inline.content.trim();
602
+
603
+ // Exact match (no attributes)
604
+ if (content === marker) {
605
+ return { matched: true, attrs: {} };
606
+ }
607
+
608
+ // Check if starts with marker followed by space
609
+ if (content.startsWith(marker + ' ')) {
610
+ const rest = content.slice(marker.length + 1).trim();
611
+ return { matched: true, attrs: parseAttrs(rest) };
612
+ }
613
+
614
+ return { matched: false };
615
+ }
616
+
617
+ // Legacy helper for simple marker checks (no attrs needed)
618
+ function isMarker(tok, tokens, i, marker) {
619
+ return parseMarker(tok, tokens, i, marker).matched;
620
+ }
621
+
622
+ // Gutterpress owns bare @continue as a generic section marker and parses it
623
+ // before custom core rules run. Claim the exact bare marker first, then decide
624
+ // before Gutterpress's layout transform whether it belongs to a skill card or
625
+ // should retain the generic section-continuation behavior.
626
+ function dcContinueCandidateBlock(state, startLine, endLine, silent) {
627
+ const pos = state.bMarks[startLine] + state.tShift[startLine];
628
+ const max = state.eMarks[startLine];
629
+ if (state.src.slice(pos, max).trim() !== '@continue') return false;
630
+ if (silent) return true;
631
+
632
+ state.env.__layoutMarkersUsed = true;
633
+ const token = state.push('dc_continue_candidate', '', 0);
634
+ token.block = true;
635
+ token.map = [startLine, startLine + 1];
636
+ token.meta = { kind: 'continue', attrs: {}, __line: startLine + 1 };
637
+ state.line = startLine + 1;
638
+ return true;
639
+ }
640
+
641
+ function dcContinueClassifier(state) {
642
+ let inSkillMode = false;
643
+ let inSkillCard = false;
644
+
645
+ for (let i = 0; i < state.tokens.length; i++) {
646
+ const token = state.tokens[i];
647
+ if (parseMarker(token, state.tokens, i, '@skill').matched) {
648
+ inSkillMode = true;
649
+ inSkillCard = false;
650
+ continue;
651
+ }
652
+ if (isMarker(token, state.tokens, i, '@end-skill') ||
653
+ isMarker(token, state.tokens, i, '@end-skills') ||
654
+ isMarker(token, state.tokens, i, '@learning-path') ||
655
+ isMarker(token, state.tokens, i, '@end-learning-path') ||
656
+ isMarker(token, state.tokens, i, '@specialty') ||
657
+ isMarker(token, state.tokens, i, '@end-specialty')) {
658
+ inSkillMode = false;
659
+ inSkillCard = false;
660
+ continue;
661
+ }
662
+ if (token.type === 'layout_marker' &&
663
+ ['chapter', 'spread', 'page', 'section'].includes(token.meta && token.meta.kind)) {
664
+ inSkillMode = false;
665
+ inSkillCard = false;
666
+ continue;
667
+ }
668
+ if (inSkillMode && token.type === 'heading_open' && token.tag === 'h4') {
669
+ inSkillCard = true;
670
+ continue;
671
+ }
672
+ if (token.type !== 'dc_continue_candidate') continue;
673
+
674
+ token.type = inSkillMode && inSkillCard ? 'dc_skill_continue' : 'layout_marker';
675
+ }
676
+ }
677
+
678
+ function registerSkillContinueBridge(md) {
679
+ try {
680
+ md.core.ruler.before('layout_transform', 'dc_continue_classifier', dcContinueClassifier);
681
+ } catch (error) {
682
+ if (/Parser rule not found/.test(String(error))) return;
683
+ throw error;
684
+ }
685
+ md.block.ruler.before('layout_marker', 'dc_continue_candidate', dcContinueCandidateBlock, {
686
+ alt: ['paragraph', 'reference', 'blockquote', 'list'],
687
+ });
688
+ }
689
+
690
+
691
+ // Collect bullet list items
692
+ function collectBulletListItems(tokens, startIndex) {
693
+ const items = [];
694
+ let i = startIndex;
695
+
696
+ // Find bullet_list_open
697
+ while (i < tokens.length && tokens[i].type !== 'bullet_list_open') {
698
+ i++;
699
+ }
700
+ if (i >= tokens.length) return { items: [], endIndex: startIndex };
701
+
702
+ i++; // Skip bullet_list_open
703
+
704
+ while (i < tokens.length && tokens[i].type !== 'bullet_list_close') {
705
+ if (tokens[i].type === 'list_item_open') {
706
+ // Find the inline content
707
+ let j = i + 1;
708
+ while (j < tokens.length && tokens[j].type !== 'list_item_close') {
709
+ if (tokens[j].type === 'inline') {
710
+ items.push(tokens[j].content);
711
+ }
712
+ j++;
713
+ }
714
+ i = j;
715
+ }
716
+ i++;
717
+ }
718
+
719
+ return { items: items, endIndex: i };
720
+ }
721
+
722
+ // Collect ordered list items for abilities
723
+ function collectOrderedListItems(tokens, startIndex, md) {
724
+ const items = [];
725
+ let i = startIndex;
726
+
727
+ // Find ordered_list_open
728
+ while (i < tokens.length && tokens[i].type !== 'ordered_list_open') {
729
+ i++;
730
+ }
731
+ if (i >= tokens.length) return { items: [], endIndex: startIndex };
732
+
733
+ i++; // Skip ordered_list_open
734
+
735
+ while (i < tokens.length && tokens[i].type !== 'ordered_list_close') {
736
+ if (tokens[i].type === 'list_item_open') {
737
+ // Find the inline content
738
+ let j = i + 1;
739
+ while (j < tokens.length && tokens[j].type !== 'list_item_close') {
740
+ if (tokens[j].type === 'inline') {
741
+ const html = renderInlineChildren(tokens[j], md);
742
+ items.push(html);
743
+ }
744
+ j++;
745
+ }
746
+ i = j;
747
+ }
748
+ i++;
749
+ }
750
+
751
+ return { items: items, endIndex: i };
752
+ }
753
+
754
+ // Attributes that should be emitted verbatim (real HTML attributes).
755
+ // Everything else gets a `data-` prefix to avoid colliding with HTML semantics
756
+ // (e.g. an author writing `title=foo` shouldn't produce a real `title` tooltip;
757
+ // they meant `data-title="foo"`). markers.js does the same for marker attributes.
758
+ const PASSTHROUGH_HTML_ATTRS = new Set(['class', 'id', 'lang', 'dir', 'role', 'tabindex']);
759
+
760
+ function buildAttrs(userAttrs, baseClass) {
761
+ let attrs = ' class="' + esc(baseClass + (userAttrs['class'] ? ' ' + userAttrs['class'] : '')) + '"';
762
+ for (const [key, val] of Object.entries(userAttrs)) {
763
+ if (key === 'class') continue;
764
+ if (key.startsWith('data-') || key.startsWith('aria-') || PASSTHROUGH_HTML_ATTRS.has(key)) {
765
+ attrs += ' ' + key + '="' + esc(val) + '"';
766
+ } else {
767
+ attrs += ' data-' + key + '="' + esc(val) + '"';
768
+ }
769
+ }
770
+ return attrs;
771
+ }
772
+
773
+ function buildProcedureList(items) {
774
+ let html = '<ol class="dc-steps">\n';
775
+ items.forEach((itemHtml, idx) => {
776
+ const stepNo = String(idx + 1).padStart(2, '0');
777
+ // Strip @end-procedure if markdown-it consumed it as continuation of the last item
778
+ const cleaned = itemHtml.replace(/\n?@end-procedure\s*$/g, '').trimEnd();
779
+ html += ' <li><span class="dc-step-no">' + esc(stepNo) + '</span><span>' + cleaned + '</span></li>\n';
780
+ });
781
+ html += '</ol>\n';
782
+ return html;
783
+ }
784
+
785
+ /**
786
+ * GFM-style blockquote alert types — Dimm City branded.
787
+ *
788
+ * Moved from print-md core (src/lib/markdown/alerts.ts) on 2026-05-17 because
789
+ * the classes and labels are DC-specific. Core no longer leaks DC identifiers.
790
+ */
791
+ const DC_ALERT_TYPES = {
792
+ NOTE: { classes: "dc-alert dc-note", label: "Note" },
793
+ WARNING: { classes: "dc-alert dc-note warning", label: "Warning" },
794
+ DM: { classes: "dc-alert dc-dm-note", label: "Dream Master Note" },
795
+ VIBE: { classes: "dc-alert dc-vibe-callout", label: "Vibe" },
796
+ ORIGIN: { classes: "dc-alert dc-origin-callout", label: "Origin" },
797
+ VISIT: { classes: "dc-alert dc-visit-callout", label: "Visit" },
798
+ GEAR: { classes: "dc-alert dc-gear-callout", label: "Gear" },
799
+ FLAVOR: { classes: "dc-flavor" },
800
+ PULLQUOTE: { classes: "dc-pullquote dc-flush" },
801
+ };
802
+
803
+ const DC_ALERT_PATTERN = /^\[!([A-Z_]+)\]/i;
804
+
805
+ /**
806
+ * Transform `> [!TYPE]` blockquotes into DC-branded styled divs.
807
+ *
808
+ * This is a markdown-it core rule that consumes the `[!TYPE]` marker and
809
+ * wraps the blockquote contents in a `<div>` carrying the corresponding
810
+ * DC alert classes and an optional label span.
811
+ */
812
+ function dcAlertsTransform(state) {
813
+ const tokens = state.tokens;
814
+ const newTokens = [];
815
+
816
+ for (let i = 0; i < tokens.length; i++) {
817
+ const tok = tokens[i];
818
+ if (!tok || tok.type !== 'blockquote_open') {
819
+ newTokens.push(tok);
820
+ continue;
821
+ }
822
+
823
+ let closeIdx = -1;
824
+ let firstInlineIdx = -1;
825
+ let depth = 0;
826
+ for (let j = i; j < tokens.length; j++) {
827
+ const jt = tokens[j];
828
+ if (!jt) continue;
829
+ if (jt.type === 'blockquote_open') depth++;
830
+ if (jt.type === 'blockquote_close') {
831
+ depth--;
832
+ if (depth === 0) { closeIdx = j; break; }
833
+ }
834
+ if (firstInlineIdx === -1 && jt.type === 'inline') firstInlineIdx = j;
835
+ }
836
+
837
+ if (closeIdx === -1 || firstInlineIdx === -1) { newTokens.push(tok); continue; }
838
+
839
+ const inlineTok = tokens[firstInlineIdx];
840
+ const match = inlineTok && inlineTok.content.match(DC_ALERT_PATTERN);
841
+ if (!match) { newTokens.push(tok); continue; }
842
+
843
+ const alertType = match[1].toUpperCase();
844
+ const config = DC_ALERT_TYPES[alertType];
845
+ if (!config) { newTokens.push(tok); continue; }
846
+
847
+ // Open div
848
+ const openTok = new state.Token('html_block', '', 0);
849
+ openTok.block = true;
850
+ let openHtml = `<div class="${config.classes}">`;
851
+ if (config.label) openHtml += `<span class="dc-alert-label">${config.label}</span>`;
852
+ openHtml += '\n';
853
+ openTok.content = openHtml;
854
+ newTokens.push(openTok);
855
+
856
+ // Strip [!TYPE] prefix from first inline; suppress its paragraph wrapper if empty.
857
+ const prefixMatch = inlineTok.content.match(/^\[![A-Z_]+\][ \t]*/i);
858
+ const stripped = prefixMatch
859
+ ? inlineTok.content.slice(prefixMatch[0].length).trim()
860
+ : inlineTok.content;
861
+
862
+ const paragraphOpenIdx = firstInlineIdx - 1;
863
+ const paragraphCloseIdx = firstInlineIdx + 1;
864
+ const wrapsInParagraph =
865
+ paragraphOpenIdx > i &&
866
+ tokens[paragraphOpenIdx] && tokens[paragraphOpenIdx].type === 'paragraph_open' &&
867
+ tokens[paragraphCloseIdx] && tokens[paragraphCloseIdx].type === 'paragraph_close';
868
+
869
+ for (let j = i + 1; j < closeIdx; j++) {
870
+ const t = tokens[j];
871
+ if (!t) continue;
872
+ if (j === firstInlineIdx) {
873
+ if (stripped === '') continue;
874
+ const strippedTok = new state.Token('inline', '', 0);
875
+ strippedTok.content = stripped;
876
+ strippedTok.children = [];
877
+ state.md.inline.parse(stripped, state.md, state.env, strippedTok.children);
878
+ newTokens.push(strippedTok);
879
+ continue;
880
+ }
881
+ if (stripped === '' && wrapsInParagraph) {
882
+ if (j === paragraphOpenIdx || j === paragraphCloseIdx) continue;
883
+ }
884
+ newTokens.push(t);
885
+ }
886
+
887
+ const closeTok = new state.Token('html_block', '', 0);
888
+ closeTok.block = true;
889
+ closeTok.content = '</div>\n';
890
+ newTokens.push(closeTok);
891
+
892
+ i = closeIdx;
893
+ }
894
+
895
+ state.tokens = newTokens;
896
+ }
897
+
898
+ /**
899
+ * Main plugin function - the default export Gutterpress loads
900
+ */
901
+ export default function dimmCityPlugin(md, options = {}) {
902
+ registerSkillContinueBridge(md);
903
+
904
+ // GFM-style `> [!NOTE]` alerts (moved from core 2026-05-17).
905
+ // Must run before markdown-it-attrs so attrs don't interfere with `[!TYPE]`
906
+ // detection inside blockquotes. We register on the core ruler since the
907
+ // transform operates on already-parsed token streams.
908
+ md.core.ruler.push('dc_alerts', dcAlertsTransform);
909
+
910
+ // Transform the canonical roll instruction while it is still structured
911
+ // inline content. This covers every markdown context without rewriting
912
+ // rendered HTML, code spans, raw HTML, or partial words.
913
+ md.renderer.rules.dc_roll_the_die = (tokens, idx) =>
914
+ '<span class="dc-roll-the-die">' + esc(tokens[idx].content) + '</span>';
915
+ md.core.ruler.push('dc_roll_the_die', dcRollDieTransform);
916
+
917
+ // NOTE: Chapter-opener composite behaviour is intentionally NOT handled
918
+ // by the plugin. The author markdown
919
+ //
920
+ // @chapter C.01
921
+ // @page intro
922
+ // @section
923
+ // # Who Do You Dream to Be?
924
+ //
925
+ // produces the standard marker DOM:
926
+ //
927
+ // .chapter[data-chapter-label="C.01"]
928
+ // .page[data-page="intro"][data-chapter-label="C.01"]
929
+ // .section
930
+ // h1
931
+ // ...
932
+ //
933
+ // All chapter-opener visual treatment (the C.NN badge, the section
934
+ // variant chrome, the chevron-styled h1) is provided by CSS attribute
935
+ // selectors in components/*.css matching this structure.
936
+
937
+ // Transform tokens after parsing
938
+ md.core.ruler.push('dimm_city_transform', function (state) {
939
+ const tokens = state.tokens;
940
+ const newTokens = [];
941
+
942
+ // State tracking
943
+ let inSpecialty = false;
944
+ let inLearningPath = false;
945
+ let inSkillMode = false;
946
+ let inSkillCard = false;
947
+ let inOutcomeBlock = false;
948
+ let outcomeBlockItems = [];
949
+ let outcomeBlockFlush = false;
950
+ let inLede = false;
951
+ let inSidebar = false;
952
+ let inSidebarBox = false;
953
+ let inDefinition = false;
954
+ let inProcedure = false;
955
+ let inCallout = false;
956
+ let inDmNote = false;
957
+ let inBlock = false;
958
+ let inCard = false;
959
+ let inGear = false;
960
+ let inToc = false;
961
+ let inGlossary = false;
962
+ let inSpecialtyCard = false;
963
+ let inSpecialtyIntro = false;
964
+ let inSpecialtyArt = false;
965
+ let cardHeadingDone = false;
966
+ let cardPullDone = false;
967
+ let cardBodyOpen = false;
968
+ /* dc#44: the real markdown-it blockquote_open token most recently seen
969
+ inside the current @card's body — the candidate for `.dc-card-footer`.
970
+ Re-pointed every time a new body blockquote appears (see addTokenClass/
971
+ removeTokenClass below), so whichever one is open when the card closes
972
+ is the one CSS's own `blockquote:last-of-type` would have picked. */
973
+ let lastCardBodyBlockquote = null;
974
+ let learningPathHasTitle = false;
975
+ let inLearningPathShell = false;
976
+ let currentSkillAttrs = {};
977
+ let currentSpecialtyCode = '';
978
+ let currentLearningPathIndex = 0;
979
+ let currentLearningPathRef = '';
980
+ let currentLearningPathName = '';
981
+ let currentSkillIndex = 0;
982
+ /* Whether the current skill card allows splitting (has .dc-allow-split).
983
+ Used by outcomes-table to decide if it needs its own break-inside:avoid. */
984
+ let currentCardCanSplit = false;
985
+ /* Last skill-card title (parsed name + tier). Used by @continue to render
986
+ a continuation card with a "{name} ▸" tab so the reader sees the link
987
+ between Part 1 and the continuation. */
988
+ let lastCardTitle = '';
989
+ let lastCardTier = '';
990
+ let specialtyCardCount = 0; // resets per-specialty; used to emit data-position="even/odd"
991
+
992
+ // Helper to close all open structures EXCEPT specialty (specialty
993
+ // wraps the entire chapter section and is closed separately).
994
+ function closeAll() {
995
+ if (inCard) {
996
+ if (cardBodyOpen) {
997
+ newTokens.push(makeToken('html_block', '</div>\n')); // close .dc-card-body
998
+ cardBodyOpen = false;
999
+ }
1000
+ newTokens.push(makeToken('html_block', '</div>\n')); // close .dc-card
1001
+ inCard = false;
1002
+ cardHeadingDone = false;
1003
+ cardPullDone = false;
1004
+ lastCardBodyBlockquote = null;
1005
+ }
1006
+ if (inLede) {
1007
+ newTokens.push(makeToken('html_block', '</div>\n'));
1008
+ inLede = false;
1009
+ }
1010
+ if (inSidebar) {
1011
+ newTokens.push(makeToken('html_block', '</div>\n'));
1012
+ inSidebar = false;
1013
+ }
1014
+ if (inSidebarBox) {
1015
+ newTokens.push(makeToken('html_block', '</div>\n'));
1016
+ inSidebarBox = false;
1017
+ }
1018
+ if (inDefinition) {
1019
+ newTokens.push(makeToken('html_block', '</div>\n'));
1020
+ inDefinition = false;
1021
+ }
1022
+ if (inSkillCard) {
1023
+ newTokens.push(makeToken('html_block', '</div></div></div>\n'));
1024
+ inSkillCard = false;
1025
+ }
1026
+ if (inLearningPathShell) {
1027
+ newTokens.push(makeToken('html_block', '</div>\n'));
1028
+ inLearningPathShell = false;
1029
+ }
1030
+ if (inLearningPath) {
1031
+ newTokens.push(makeToken('html_block', '</div>\n'));
1032
+ inLearningPath = false;
1033
+ }
1034
+ if (inCallout) {
1035
+ newTokens.push(makeToken('html_block', '</div>\n'));
1036
+ inCallout = false;
1037
+ }
1038
+ if (inDmNote) {
1039
+ newTokens.push(makeToken('html_block', '</div>\n'));
1040
+ inDmNote = false;
1041
+ }
1042
+ if (inBlock) {
1043
+ newTokens.push(makeToken('html_block', '</div>\n'));
1044
+ inBlock = false;
1045
+ }
1046
+ if (inGear) {
1047
+ newTokens.push(makeToken('html_block', '</div>\n'));
1048
+ inGear = false;
1049
+ }
1050
+ if (inSpecialtyCard) {
1051
+ newTokens.push(makeToken('html_block', '</div>\n'));
1052
+ inSpecialtyCard = false;
1053
+ }
1054
+ if (inSpecialtyIntro) {
1055
+ newTokens.push(makeToken('html_block', '</div>\n'));
1056
+ inSpecialtyIntro = false;
1057
+ }
1058
+ if (inSpecialtyArt) {
1059
+ newTokens.push(makeToken('html_block', '</div>\n'));
1060
+ inSpecialtyArt = false;
1061
+ }
1062
+ if (inToc) {
1063
+ newTokens.push(makeToken('html_block', '</div>\n'));
1064
+ inToc = false;
1065
+ }
1066
+ if (inGlossary) {
1067
+ newTokens.push(makeToken('html_block', '</div>\n'));
1068
+ inGlossary = false;
1069
+ }
1070
+ inProcedure = false;
1071
+ learningPathHasTitle = false;
1072
+ inSkillMode = false;
1073
+ currentSkillAttrs = {};
1074
+ currentCardCanSplit = false;
1075
+ currentLearningPathRef = '';
1076
+ currentLearningPathName = '';
1077
+ currentSkillIndex = 0;
1078
+ }
1079
+
1080
+ function closeSpecialty() {
1081
+ closeAll();
1082
+ if (inSpecialty) {
1083
+ newTokens.push(makeToken('html_block', '</div>\n'));
1084
+ inSpecialty = false;
1085
+ }
1086
+ currentSpecialtyCode = '';
1087
+ currentLearningPathIndex = 0;
1088
+ // specialtyCardCount intentionally NOT reset here — it counts
1089
+ // all cards emitted in the current section so even/odd alternation
1090
+ // works across multiple @specialty blocks in the same card-grid.
1091
+ }
1092
+
1093
+ for (let i = 0; i < tokens.length; i++) {
1094
+ const tok = tokens[i];
1095
+
1096
+ // --- @sidebar / @end-sidebar ---
1097
+ const sidebarMarker = parseMarker(tok, tokens, i, '@sidebar');
1098
+ if (sidebarMarker.matched) {
1099
+ if (inSidebar) {
1100
+ newTokens.push(makeToken('html_block', '</div>\n'));
1101
+ inSidebar = false;
1102
+ }
1103
+ if (inSidebarBox) {
1104
+ newTokens.push(makeToken('html_block', '</div>\n'));
1105
+ inSidebarBox = false;
1106
+ }
1107
+ if (inDefinition) {
1108
+ newTokens.push(makeToken('html_block', '</div>\n'));
1109
+ inDefinition = false;
1110
+ }
1111
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(sidebarMarker.attrs, 'dc-sidebar') + '>\n'));
1112
+ inSidebar = true;
1113
+ i += 2;
1114
+ continue;
1115
+ }
1116
+
1117
+ if (isMarker(tok, tokens, i, '@end-sidebar')) {
1118
+ if (inSidebar) {
1119
+ newTokens.push(makeToken('html_block', '</div>\n'));
1120
+ inSidebar = false;
1121
+ }
1122
+ i += 2;
1123
+ continue;
1124
+ }
1125
+
1126
+ // --- @roll-table / @options-table (DEPRECATED 17.3.0) ---
1127
+ // These macros emitted tier-colored classes (dc-roll-table*, dc-options-table*)
1128
+ // with ZERO backing CSS and had zero usage in the book. They are now no-ops:
1129
+ // the markers (and their @end- counterparts) are stripped so any stray
1130
+ // occurrence renders nothing. Use standard GFM pipe tables instead — they are
1131
+ // auto-styled by dc-core.
1132
+ if (isMarker(tok, tokens, i, '@roll-table') ||
1133
+ isMarker(tok, tokens, i, '@end-roll-table') ||
1134
+ isMarker(tok, tokens, i, '@options-table') ||
1135
+ isMarker(tok, tokens, i, '@end-options-table')) {
1136
+ i += 2;
1137
+ continue;
1138
+ }
1139
+
1140
+ // --- @outcome / @end-outcome ---
1141
+ // Collects pipe-separated rows and emits a styled dc-outcomes block.
1142
+ // Syntax:
1143
+ // @outcome [flush]
1144
+ // 20 | Crit | You flow. Automatic success — no roll needed.
1145
+ // 11–19 | Hit | You succeed cleanly.
1146
+ // @end-outcome
1147
+ // Row ordering determines tier class: crit, hit, mixed, miss, fail.
1148
+ // Also handles the compact form where @outcome, rows, and @end-outcome
1149
+ // are all in one paragraph block (no blank lines between them).
1150
+ //
1151
+ // The compact form arrives as ONE multi-line inline token, so it must
1152
+ // fall through to the compact handler below. Without the multi-line
1153
+ // guard `@outcome flush` + rows matches here (content starts with
1154
+ // "@outcome ") and `i += 2` skips the whole inline — swallowing every
1155
+ // row AND the inline @end-outcome, and leaving inOutcomeBlock stuck on.
1156
+ const ocInline = tokens[i + 1];
1157
+ const ocIsCompact = tok.type === 'paragraph_open' && ocInline
1158
+ && ocInline.type === 'inline' && ocInline.content.indexOf('\n') !== -1;
1159
+ if (!ocIsCompact && isMarker(tok, tokens, i, '@outcome')) {
1160
+ inOutcomeBlock = true;
1161
+ outcomeBlockItems = [];
1162
+ // Check for flush modifier
1163
+ const ocContent = ocInline ? ocInline.content.trim() : '';
1164
+ outcomeBlockFlush = /\bflush\b/.test(ocContent.replace('@outcome', ''));
1165
+ i += 2;
1166
+ continue;
1167
+ }
1168
+
1169
+ // Handle compact multiline @outcome block: all rows + @end-outcome in one paragraph
1170
+ if (tok.type === 'paragraph_open' && !inOutcomeBlock) {
1171
+ const inlineTok = tokens[i + 1];
1172
+ if (inlineTok && inlineTok.type === 'inline') {
1173
+ const firstLine = inlineTok.content.split('\n')[0].trim();
1174
+ if (firstLine === '@outcome' || firstLine.startsWith('@outcome ')) {
1175
+ // This is a compact outcome block — process all lines inline
1176
+ inOutcomeBlock = false; // We'll handle it fully here
1177
+ const isFlush = /\bflush\b/.test(firstLine.replace('@outcome', ''));
1178
+ const rows = [];
1179
+ const allLines = inlineTok.content.split('\n');
1180
+ let inBlock = false;
1181
+ for (const line of allLines) {
1182
+ const trimmed = line.trim();
1183
+ if (trimmed === '@outcome' || trimmed.startsWith('@outcome ')) { inBlock = true; continue; }
1184
+ if (trimmed === '@end-outcome') { inBlock = false; continue; }
1185
+ if (inBlock && trimmed && !trimmed.startsWith('#')) rows.push(trimmed);
1186
+ }
1187
+ if (rows.length > 0) {
1188
+ const tierClasses = ['crit', 'hit', 'mixed', 'miss', 'fail'];
1189
+ const wrapperClass = 'dc-outcomes' + (isFlush ? ' dc-flush' : '');
1190
+ let html = '<div class="' + wrapperClass + '">\n';
1191
+ html += ' <div class="dc-outcomes-label">Outcomes</div>\n';
1192
+ rows.forEach((line, idx) => {
1193
+ const parts = line.split('|').map(s => s.trim());
1194
+ const range = parts[0] || '';
1195
+ const name = parts[1] || '';
1196
+ const desc = parts[2] || '';
1197
+ const tier = tierClasses[idx] || 'hit';
1198
+ html += ' <div class="dc-outcome-row ' + tier + '">\n';
1199
+ html += ' <span class="dc-outcome-key tier-' + tier + '">';
1200
+ html += '<span class="dc-outcome-name">' + esc(name) + '</span>';
1201
+ html += '<span class="dc-outcome-roll">' + esc(range) + '</span>';
1202
+ html += '</span>\n';
1203
+ html += ' <span class="dc-outcome-text">' + md.renderInline(desc) + '</span>\n';
1204
+ html += ' </div>\n';
1205
+ });
1206
+ html += '</div>\n';
1207
+ newTokens.push(makeToken('html_block', html));
1208
+ i += 2; // skip inline + paragraph_close
1209
+ continue;
1210
+ }
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ if (inOutcomeBlock) {
1216
+ // Collect inline tokens (each inline contains one or more lines)
1217
+ if (tok.type === 'inline') {
1218
+ // Each inline token can have newlines — split into lines
1219
+ const lines = tok.content.split('\n');
1220
+ for (const line of lines) {
1221
+ const trimmed = line.trim();
1222
+ if (trimmed && !trimmed.startsWith('#')) {
1223
+ outcomeBlockItems.push(trimmed);
1224
+ }
1225
+ }
1226
+ continue;
1227
+ }
1228
+ // @end-outcome — emit the block
1229
+ if (isMarker(tok, tokens, i, '@end-outcome')) {
1230
+ inOutcomeBlock = false;
1231
+ const tierClasses = ['crit', 'hit', 'mixed', 'miss', 'fail'];
1232
+ const wrapperClass = 'dc-outcomes' + (outcomeBlockFlush ? ' dc-flush' : '');
1233
+ let html = '<div class="' + wrapperClass + '">\n';
1234
+ html += ' <div class="dc-outcomes-label">Outcomes</div>\n';
1235
+ outcomeBlockItems.forEach((line, idx) => {
1236
+ const parts = line.split('|').map(s => s.trim());
1237
+ const range = parts[0] || '';
1238
+ const name = parts[1] || '';
1239
+ const desc = parts[2] || '';
1240
+ const tier = tierClasses[idx] || 'hit';
1241
+ html += ' <div class="dc-outcome-row ' + tier + '">\n';
1242
+ html += ' <span class="dc-outcome-key tier-' + tier + '">';
1243
+ html += '<span class="dc-outcome-name">' + esc(name) + '</span>';
1244
+ html += '<span class="dc-outcome-roll">' + esc(range) + '</span>';
1245
+ html += '</span>\n';
1246
+ html += ' <span class="dc-outcome-text">' + md.renderInline(desc) + '</span>\n';
1247
+ html += ' </div>\n';
1248
+ });
1249
+ html += '</div>\n';
1250
+ newTokens.push(makeToken('html_block', html));
1251
+ outcomeBlockItems = [];
1252
+ i += 2;
1253
+ continue;
1254
+ }
1255
+ // Skip paragraph open/close wrapper tokens — we only want inline content
1256
+ if (tok.type === 'paragraph_open' || tok.type === 'paragraph_close') {
1257
+ continue;
1258
+ }
1259
+ // Pass through anything else (shouldn't normally occur)
1260
+ continue;
1261
+ }
1262
+
1263
+ // Check for @specialty marker (must come before @learning-path/@skill
1264
+ // since it auto-closes everything else)
1265
+ const sidebarBoxMarker = parseMarker(tok, tokens, i, '@sidebar-box');
1266
+ if (sidebarBoxMarker.matched) {
1267
+ if (inSidebarBox) {
1268
+ newTokens.push(makeToken('html_block', '</div>\n'));
1269
+ inSidebarBox = false;
1270
+ }
1271
+ if (inSidebar) {
1272
+ newTokens.push(makeToken('html_block', '</div>\n'));
1273
+ inSidebar = false;
1274
+ }
1275
+ if (inDefinition) {
1276
+ newTokens.push(makeToken('html_block', '</div>\n'));
1277
+ inDefinition = false;
1278
+ }
1279
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(sidebarBoxMarker.attrs, 'dc-prose-panel dc-sidebar-box') + '>\n'));
1280
+ inSidebarBox = true;
1281
+ i += 2;
1282
+ continue;
1283
+ }
1284
+
1285
+ if (isMarker(tok, tokens, i, '@end-sidebar-box')) {
1286
+ if (inSidebarBox) {
1287
+ newTokens.push(makeToken('html_block', '</div>\n'));
1288
+ inSidebarBox = false;
1289
+ }
1290
+ i += 2;
1291
+ continue;
1292
+ }
1293
+
1294
+ const definitionMarker = parseMarker(tok, tokens, i, '@definition');
1295
+ if (definitionMarker.matched) {
1296
+ if (inDefinition) {
1297
+ newTokens.push(makeToken('html_block', '</div>\n'));
1298
+ inDefinition = false;
1299
+ }
1300
+ if (inSidebar) {
1301
+ newTokens.push(makeToken('html_block', '</div>\n'));
1302
+ inSidebar = false;
1303
+ }
1304
+ if (inSidebarBox) {
1305
+ newTokens.push(makeToken('html_block', '</div>\n'));
1306
+ inSidebarBox = false;
1307
+ }
1308
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(definitionMarker.attrs, 'dc-prose-panel dc-definition-block') + '>\n'));
1309
+ inDefinition = true;
1310
+ i += 2;
1311
+ continue;
1312
+ }
1313
+
1314
+ if (isMarker(tok, tokens, i, '@end-definition')) {
1315
+ if (inDefinition) {
1316
+ newTokens.push(makeToken('html_block', '</div>\n'));
1317
+ inDefinition = false;
1318
+ }
1319
+ i += 2;
1320
+ continue;
1321
+ }
1322
+
1323
+ const procedureMarker = parseMarker(tok, tokens, i, '@procedure');
1324
+ if (procedureMarker.matched) {
1325
+ // @procedure auto-closes any prior open section so a forgotten
1326
+ // @end-procedure can't strand the previous transform open. closeAll()
1327
+ // also clears inProcedure, so set the flag AFTER closing.
1328
+ closeAll();
1329
+ inProcedure = true;
1330
+ i += 2;
1331
+ continue;
1332
+ }
1333
+
1334
+ if (isMarker(tok, tokens, i, '@end-procedure')) {
1335
+ inProcedure = false;
1336
+ i += 2;
1337
+ continue;
1338
+ }
1339
+
1340
+ // --- @callout / @end-callout ---
1341
+ // Block-level alert wrapper. variant="note|warning|dm|vibe|origin|visit|gear"
1342
+ const calloutMarker = parseMarker(tok, tokens, i, '@callout');
1343
+ if (calloutMarker.matched) {
1344
+ closeAll();
1345
+ const variant = (calloutMarker.attrs['variant'] || 'note').toLowerCase();
1346
+ const CALLOUT_VARIANTS = {
1347
+ 'note': { classes: 'dc-note', label: 'Note' },
1348
+ 'warning': { classes: 'dc-note warning', label: 'Warning' },
1349
+ 'dm': { classes: 'dc-dm-note', label: 'Dream Master Note' },
1350
+ 'vibe': { classes: 'dc-vibe-callout', label: 'Vibe' },
1351
+ 'origin': { classes: 'dc-origin-callout', label: 'Origin' },
1352
+ 'visit': { classes: 'dc-visit-callout', label: 'Visit' },
1353
+ 'gear': { classes: 'dc-gear-callout', label: 'Gear' },
1354
+ };
1355
+ const cfg = CALLOUT_VARIANTS[variant] || CALLOUT_VARIANTS['note'];
1356
+ const labelOverride = calloutMarker.attrs['label'];
1357
+ const labelText = labelOverride ? esc(labelOverride) : cfg.label;
1358
+ const calloutClass = 'dc-alert ' + cfg.classes +
1359
+ (calloutMarker.attrs['class'] ? ' ' + esc(calloutMarker.attrs['class']) : '');
1360
+ newTokens.push(makeToken('html_block',
1361
+ '<div class="' + calloutClass + '">\n' +
1362
+ '<span class="dc-alert-label">' + labelText + '</span>\n'
1363
+ ));
1364
+ inCallout = true;
1365
+ i += 2;
1366
+ continue;
1367
+ }
1368
+
1369
+ if (isMarker(tok, tokens, i, '@end-callout')) {
1370
+ if (inCallout) {
1371
+ newTokens.push(makeToken('html_block', '</div>\n'));
1372
+ inCallout = false;
1373
+ }
1374
+ i += 2;
1375
+ continue;
1376
+ }
1377
+
1378
+ // --- @dm-note / @end-dm-note ---
1379
+ // Block-level DM note wrapper. Equivalent to > [!DM] but supports multi-paragraph content.
1380
+ const dmNoteMarker = parseMarker(tok, tokens, i, '@dm-note');
1381
+ if (dmNoteMarker.matched) {
1382
+ closeAll();
1383
+ const labelOverride = dmNoteMarker.attrs['label'];
1384
+ const labelText = labelOverride ? esc(labelOverride) : 'Dream Master Note';
1385
+ newTokens.push(makeToken('html_block',
1386
+ '<div class="dc-alert dc-dm-note">\n' +
1387
+ '<span class="dc-alert-label">' + labelText + '</span>\n'
1388
+ ));
1389
+ inDmNote = true;
1390
+ i += 2;
1391
+ continue;
1392
+ }
1393
+
1394
+ if (isMarker(tok, tokens, i, '@end-dm-note')) {
1395
+ if (inDmNote) {
1396
+ newTokens.push(makeToken('html_block', '</div>\n'));
1397
+ inDmNote = false;
1398
+ }
1399
+ i += 2;
1400
+ continue;
1401
+ }
1402
+
1403
+ // --- @block (section enclosures) ---
1404
+ // Reusable card-like text section enclosures. Four variants with distinct
1405
+ // clip-path geometry, surface, and accent colors. Each emits a .dc-block
1406
+ // container with an optional titled header band (.dc-block-title).
1407
+ //
1408
+ // Syntax: @block .dc-panel|.dc-slate|.dc-shard|.dc-codex label="Title"
1409
+
1410
+ const blockUnifiedMarker = parseMarker(tok, tokens, i, '@block');
1411
+ if (blockUnifiedMarker.matched) {
1412
+ closeAll();
1413
+ const blockLabel = blockUnifiedMarker.attrs['label'] ? esc(blockUnifiedMarker.attrs['label']) : '';
1414
+ const blockTitleHtml = blockLabel ? '<div class="dc-block-title">' + blockLabel + '</div>\n' : '';
1415
+ let blockClass = 'dc-block';
1416
+ if (blockUnifiedMarker.attrs['class']) {
1417
+ blockClass += ' ' + blockUnifiedMarker.attrs['class'];
1418
+ } else if (blockUnifiedMarker.attrs['variant']) {
1419
+ blockClass += ' dc-' + blockUnifiedMarker.attrs['variant'].toLowerCase();
1420
+ }
1421
+ newTokens.push(makeToken('html_block', '<div class="' + blockClass + '">\n' + blockTitleHtml));
1422
+ inBlock = true;
1423
+ i += 2;
1424
+ continue;
1425
+ }
1426
+
1427
+
1428
+ if (isMarker(tok, tokens, i, '@end-block')) {
1429
+ if (inBlock) {
1430
+ newTokens.push(makeToken('html_block', '</div>\n'));
1431
+ inBlock = false;
1432
+ }
1433
+ i += 2;
1434
+ continue;
1435
+ }
1436
+
1437
+ // --- @card / @end-card ---
1438
+ // Generic card primitive. Emits .dc-card with optional sub-elements:
1439
+ // .dc-card-heading — first h4 after @card
1440
+ // .dc-card-pull — first blockquote after the heading (move outside body)
1441
+ // .dc-card-body — all remaining content
1442
+ // Author syntax:
1443
+ // @card .dc-flaws
1444
+ // #### Title
1445
+ // > Pull quote
1446
+ // Body paragraph.
1447
+ // > Footer blockquote
1448
+ //
1449
+ // @end-card
1450
+ //
1451
+ // IMPORTANT: @end-card must be preceded by a blank line when the last
1452
+ // content before it is a blockquote. Without the blank line markdown-it
1453
+ // lazily continues the blockquote and absorbs the @end-card marker.
1454
+ const cardMarker = parseMarker(tok, tokens, i, '@card');
1455
+ if (cardMarker.matched) {
1456
+ closeAll();
1457
+ const userAttrs = cardMarker.attrs;
1458
+ const extraClass = userAttrs['class'] ? ' ' + userAttrs['class'] : '';
1459
+ const extraId = userAttrs['id'] ? ' id="' + esc(userAttrs['id']) + '"' : '';
1460
+ newTokens.push(makeToken('html_block', '<div class="dc-card' + extraClass + '"' + extraId + '>\n'));
1461
+ inCard = true;
1462
+ cardHeadingDone = false;
1463
+ cardPullDone = false;
1464
+ cardBodyOpen = false;
1465
+ lastCardBodyBlockquote = null;
1466
+ i += 2;
1467
+ continue;
1468
+ }
1469
+
1470
+ if (isMarker(tok, tokens, i, '@end-card')) {
1471
+ if (inCard) {
1472
+ if (cardBodyOpen) {
1473
+ newTokens.push(makeToken('html_block', '</div>\n')); // close .dc-card-body
1474
+ cardBodyOpen = false;
1475
+ }
1476
+ newTokens.push(makeToken('html_block', '</div>\n')); // close .dc-card
1477
+ inCard = false;
1478
+ cardHeadingDone = false;
1479
+ cardPullDone = false;
1480
+ lastCardBodyBlockquote = null;
1481
+ }
1482
+ i += 2;
1483
+ continue;
1484
+ }
1485
+
1486
+ // --- @specialty-card / @end-specialty-card ---
1487
+ // Summary card used in the choose-specialty overview grid.
1488
+ // Shape and color inherited from .specialty.<name> parent container.
1489
+ const specialtyCardMarker = parseMarker(tok, tokens, i, '@specialty-card');
1490
+ if (specialtyCardMarker.matched) {
1491
+ closeAll();
1492
+ specialtyCardCount++;
1493
+ const positionAttr = ' data-position="' + (specialtyCardCount % 2 === 0 ? 'even' : 'odd') + '"';
1494
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(specialtyCardMarker.attrs, 'dc-specialty-card') + positionAttr + '>\n'));
1495
+ inSpecialtyCard = true;
1496
+ i += 2;
1497
+ continue;
1498
+ }
1499
+
1500
+ if (isMarker(tok, tokens, i, '@end-specialty-card')) {
1501
+ if (inSpecialtyCard) {
1502
+ newTokens.push(makeToken('html_block', '</div>\n'));
1503
+ inSpecialtyCard = false;
1504
+ }
1505
+ i += 2;
1506
+ continue;
1507
+ }
1508
+
1509
+ // --- @specialty-intro / @end-specialty-intro ---
1510
+ // Full-page specialty intro panel. Shape/color from .specialty.<name> parent.
1511
+ const specialtyIntroMarker = parseMarker(tok, tokens, i, '@specialty-intro');
1512
+ if (specialtyIntroMarker.matched) {
1513
+ closeAll();
1514
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(specialtyIntroMarker.attrs, 'dc-specialty-intro') + '>\n'));
1515
+ inSpecialtyIntro = true;
1516
+ i += 2;
1517
+ continue;
1518
+ }
1519
+
1520
+ if (isMarker(tok, tokens, i, '@end-specialty-intro')) {
1521
+ if (inSpecialtyIntro) {
1522
+ newTokens.push(makeToken('html_block', '</div>\n'));
1523
+ inSpecialtyIntro = false;
1524
+ }
1525
+ i += 2;
1526
+ continue;
1527
+ }
1528
+
1529
+ // --- @specialty-art / @end-specialty-art ---
1530
+ // Full-bleed art panel for a specialty profile. Emits .dc-specialty-art
1531
+ // (CSS: components/*.css + page-rules.css `.dc-specialty-art { page: full; }`).
1532
+ const specialtyArtMarker = parseMarker(tok, tokens, i, '@specialty-art');
1533
+ if (specialtyArtMarker.matched) {
1534
+ closeAll();
1535
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(specialtyArtMarker.attrs, 'dc-specialty-art') + '>\n'));
1536
+ inSpecialtyArt = true;
1537
+ i += 2;
1538
+ continue;
1539
+ }
1540
+
1541
+ if (isMarker(tok, tokens, i, '@end-specialty-art')) {
1542
+ if (inSpecialtyArt) {
1543
+ newTokens.push(makeToken('html_block', '</div>\n'));
1544
+ inSpecialtyArt = false;
1545
+ }
1546
+ i += 2;
1547
+ continue;
1548
+ }
1549
+
1550
+
1551
+ // --- @toc / @end-toc ---
1552
+ const tocMarker = parseMarker(tok, tokens, i, '@toc');
1553
+ if (tocMarker.matched) {
1554
+ closeAll();
1555
+ newTokens.push(makeToken('html_block', '<div class="dc-toc">\n'));
1556
+ inToc = true;
1557
+ i += 2; continue;
1558
+ }
1559
+ if (isMarker(tok, tokens, i, '@end-toc')) {
1560
+ if (inToc) {
1561
+ newTokens.push(makeToken('html_block', '</div>\n'));
1562
+ inToc = false;
1563
+ }
1564
+ i += 2; continue;
1565
+ }
1566
+
1567
+ // Removed 2026-05-17: @two-column / @three-column / @no-break.
1568
+ // Use @section .gp-columns-2 / @section .gp-columns-3 / @section .pmd-no-break
1569
+ // instead — markers.js emits identical layout semantics, with the
1570
+ // added `.section` class that picks up `break-inside: avoid` from MARKER_CSS.
1571
+ // See docs/migrations/2026-05-removing-container-syntax.md for the rationale.
1572
+
1573
+ // --- @gear / @end-gear ---
1574
+ // Shorthand for @card .dc-gear — emits a bare `.dc-card.dc-gear`.
1575
+ // NOTE: there is NO .section.dc-gear-list wrapper (an earlier comment
1576
+ // claimed one, and components/*.css carried ~40 lines of CSS keyed on
1577
+ // it that could never match). Style `.dc-card.dc-gear` directly.
1578
+ const gearMarker = parseMarker(tok, tokens, i, '@gear');
1579
+ if (gearMarker.matched) {
1580
+ closeAll();
1581
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(gearMarker.attrs, 'dc-card dc-gear') + '>\n'));
1582
+ inGear = true;
1583
+ i += 2; continue;
1584
+ }
1585
+ if (isMarker(tok, tokens, i, '@end-gear')) {
1586
+ if (inGear) {
1587
+ newTokens.push(makeToken('html_block', '</div>\n'));
1588
+ inGear = false;
1589
+ }
1590
+ i += 2; continue;
1591
+ }
1592
+
1593
+ // --- @tape (single-line tape divider) ---
1594
+ const tapeMarker = parseMarker(tok, tokens, i, '@tape');
1595
+ if (tapeMarker.matched) {
1596
+ const labelAttr = tapeMarker.attrs['label'] || '';
1597
+ const extraClass = tapeMarker.attrs['class'] ? ' ' + esc(tapeMarker.attrs['class']) : '';
1598
+ newTokens.push(makeToken('html_block', '<div class="dc-tape' + extraClass + '">' + esc(labelAttr) + '</div>\n'));
1599
+ i += 2; continue;
1600
+ }
1601
+
1602
+ // --- @lede / @end-lede ---
1603
+ // Emits .dc-intro (canonical). The legacy bare `lede` class was removed
1604
+ // 2026-05-17 — there were no CSS rules using it, only the dc-prefixed
1605
+ // form is styled.
1606
+ const ledeMarker = parseMarker(tok, tokens, i, '@lede');
1607
+ if (ledeMarker.matched) {
1608
+ closeAll();
1609
+ newTokens.push(makeToken('html_block', '<div class="dc-intro">\n'));
1610
+ inLede = true;
1611
+ i += 2; continue;
1612
+ }
1613
+ if (isMarker(tok, tokens, i, '@end-lede')) {
1614
+ if (inLede) {
1615
+ newTokens.push(makeToken('html_block', '</div>\n'));
1616
+ inLede = false;
1617
+ }
1618
+ i += 2; continue;
1619
+ }
1620
+
1621
+ // --- @glossary / @end-glossary ---
1622
+ const glossaryMarker = parseMarker(tok, tokens, i, '@glossary');
1623
+ if (glossaryMarker.matched) {
1624
+ closeAll();
1625
+ newTokens.push(makeToken('html_block', '<div class="dc-terms">\n'));
1626
+ inGlossary = true;
1627
+ i += 2; continue;
1628
+ }
1629
+ if (isMarker(tok, tokens, i, '@end-glossary')) {
1630
+ if (inGlossary) {
1631
+ newTokens.push(makeToken('html_block', '</div>\n'));
1632
+ inGlossary = false;
1633
+ }
1634
+ i += 2; continue;
1635
+ }
1636
+
1637
+ const specialtyMarker = parseMarker(tok, tokens, i, '@specialty');
1638
+ if (specialtyMarker.matched) {
1639
+ closeSpecialty();
1640
+ const userAttrs = specialtyMarker.attrs;
1641
+ const specClass = 'dc-specialty' + (userAttrs['class'] ? ' ' + userAttrs['class'] : '');
1642
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(userAttrs, 'dc-specialty') + '>\n'));
1643
+ inSpecialty = true;
1644
+ currentSpecialtyCode = specialtyCodeFromClass(specClass);
1645
+ currentLearningPathIndex = 0;
1646
+ i += 2;
1647
+ continue;
1648
+ }
1649
+
1650
+ // Check for @end-specialty marker
1651
+ if (isMarker(tok, tokens, i, '@end-specialty')) {
1652
+ closeSpecialty();
1653
+ i += 2;
1654
+ continue;
1655
+ }
1656
+
1657
+ // Check for @learning-path marker
1658
+ const learningPathMarker = parseMarker(tok, tokens, i, '@learning-path');
1659
+ if (learningPathMarker.matched) {
1660
+ closeAll();
1661
+ inLearningPath = true;
1662
+ inLearningPathShell = true;
1663
+ learningPathHasTitle = false;
1664
+ currentLearningPathIndex++;
1665
+ currentLearningPathRef = currentSpecialtyCode + currentLearningPathIndex;
1666
+ currentLearningPathName = '';
1667
+ currentSkillIndex = 0;
1668
+
1669
+ // Build opening tag with any custom attributes (no variant= — use .specialty.<name> parent)
1670
+ const lpUserAttrs = learningPathMarker.attrs;
1671
+ newTokens.push(makeToken('html_block', '<div' + buildAttrs(lpUserAttrs, 'dc-learning-path dc-path-block') + ' data-path-ref="' + esc(currentLearningPathRef) + '">\n<div class="dc-path-shell">\n'));
1672
+ i += 2; // Skip paragraph_open, inline, paragraph_close
1673
+ continue;
1674
+ }
1675
+
1676
+ // Check for @skill marker
1677
+ const skillMarker = parseMarker(tok, tokens, i, '@skill');
1678
+ if (skillMarker.matched) {
1679
+ if (inLearningPathShell) {
1680
+ newTokens.push(makeToken('html_block', '</div>\n'));
1681
+ inLearningPathShell = false;
1682
+ }
1683
+ // Close previous skill card if open
1684
+ if (inSkillCard) {
1685
+ newTokens.push(makeToken('html_block', '</div></div></div>\n'));
1686
+ inSkillCard = false;
1687
+ }
1688
+ inSkillMode = true;
1689
+ // Store skill attrs for use when building the card
1690
+ currentSkillAttrs = skillMarker.attrs;
1691
+ i += 2;
1692
+ continue;
1693
+ }
1694
+
1695
+ // Check for @end-skill marker
1696
+ if (isMarker(tok, tokens, i, '@end-skill') || isMarker(tok, tokens, i, '@end-skills')) {
1697
+ closeAll();
1698
+ inSkillMode = false;
1699
+ i += 2;
1700
+ continue;
1701
+ }
1702
+
1703
+ // The bridge above protects skill-card @continue from Gutterpress's
1704
+ // earlier generic section-marker transform. Bare markdown-it instances
1705
+ // still reach this branch as the original paragraph marker.
1706
+ const isBridgedSkillContinue = tok.type === 'dc_skill_continue';
1707
+ if (isBridgedSkillContinue || isMarker(tok, tokens, i, '@continue')) {
1708
+ if (inSkillMode && inSkillCard) {
1709
+ // Close current card
1710
+ newTokens.push(makeToken('html_block', '</div></div></div>\n'));
1711
+ inSkillCard = false;
1712
+
1713
+ // No variant class — shape inherited from .specialty.<name> parent container
1714
+
1715
+ // Honor allow-split on continuation cards too
1716
+ const userClassList = (currentSkillAttrs['class'] || '').split(/\s+/).filter(Boolean);
1717
+ const allowSplitCont = userClassList.includes('dc-allow-split');
1718
+ const breakInsideContAttr = allowSplitCont ? '' : ' data-break-inside="avoid"';
1719
+
1720
+ let cardHtml = '<div' + buildAttrs(currentSkillAttrs, 'dc-skill-card dc-skill-card-cont') + ' name="' + esc(slugify(lastCardTitle)) + '"' + breakInsideContAttr + '>\n';
1721
+ const fullTabClassCont = 'dc-card-tab dc-card-tab-cont';
1722
+ cardHtml += ' <div class="' + fullTabClassCont + '">\n';
1723
+ cardHtml += ' <span class="dc-tab-title">' + esc(lastCardTitle) + ' ▸</span>\n';
1724
+ if (lastCardTier) {
1725
+ cardHtml += ' <span class="dc-tab-tier">' + esc(lastCardTier) + '</span>\n';
1726
+ }
1727
+ cardHtml += ' </div>\n';
1728
+ const fullBodyClassCont = 'dc-card-body';
1729
+ cardHtml += ' <div class="' + fullBodyClassCont + '">\n';
1730
+ cardHtml += ' <div class="dc-card-inner">\n';
1731
+
1732
+ newTokens.push(makeToken('html_block', cardHtml));
1733
+ inSkillCard = true;
1734
+ if (!isBridgedSkillContinue) i += 2;
1735
+ continue;
1736
+ }
1737
+ }
1738
+
1739
+ // Check for @end-learning-path marker
1740
+ if (isMarker(tok, tokens, i, '@end-learning-path')) {
1741
+ if (inLearningPathShell) {
1742
+ newTokens.push(makeToken('html_block', '</div>\n'));
1743
+ inLearningPathShell = false;
1744
+ }
1745
+ if (inLearningPath) {
1746
+ newTokens.push(makeToken('html_block', '</div>\n'));
1747
+ inLearningPath = false;
1748
+ learningPathHasTitle = false;
1749
+ }
1750
+ i += 2;
1751
+ continue;
1752
+ }
1753
+
1754
+ // Inside @learning-path section
1755
+ if (inLearningPath && !inSkillMode) {
1756
+ // H3 = Learning path title (banner)
1757
+ if (tok.type === 'heading_open' && tok.tag === 'h3') {
1758
+ const inlineTok = tokens[i + 1];
1759
+ const titleText = inlineTok && inlineTok.content ? inlineTok.content : '';
1760
+ currentLearningPathName = titleText;
1761
+ newTokens.push(makeToken('html_block', '<h2 class="dc-spray"><span class="dc-path-sticker">' + esc(currentLearningPathRef) + '</span>' + esc(titleText) + '</h2>\n'));
1762
+ learningPathHasTitle = true;
1763
+ i += 2; // Skip heading_open, inline, heading_close
1764
+ continue;
1765
+ }
1766
+
1767
+ // Blockquote = Subtitle/description (after title)
1768
+ if (tok.type === 'blockquote_open' && learningPathHasTitle) {
1769
+ let bqContent = '';
1770
+ let j = i + 1;
1771
+ while (j < tokens.length && tokens[j].type !== 'blockquote_close') {
1772
+ if (tokens[j].type === 'inline') {
1773
+ bqContent = renderInlineChildren(tokens[j], md);
1774
+ }
1775
+ j++;
1776
+ }
1777
+ newTokens.push(makeToken('html_block', '<div class="dc-intro">' + bqContent + '</div>\n'));
1778
+ i = j;
1779
+ continue;
1780
+ }
1781
+
1782
+ // Bullet list = Sticker chain
1783
+ if (tok.type === 'bullet_list_open') {
1784
+ const { items, endIndex } = collectBulletListItems(tokens, i);
1785
+ if (items.length > 0) {
1786
+ newTokens.push(makeToken('html_block', buildStickerChain(items)));
1787
+ }
1788
+ i = endIndex;
1789
+ continue;
1790
+ }
1791
+
1792
+ // Paragraphs = body prose within the learning path block
1793
+ if (tok.type === 'paragraph_open') {
1794
+ const inlineTok = tokens[i + 1];
1795
+ const closeTok = tokens[i + 2];
1796
+ if (inlineTok && inlineTok.type === 'inline' && closeTok && closeTok.type === 'paragraph_close') {
1797
+ const bodyHtml = renderInlineChildren(inlineTok, md);
1798
+ newTokens.push(makeToken('html_block', '<p>' + bodyHtml + '</p>\n'));
1799
+ i += 2;
1800
+ continue;
1801
+ }
1802
+ }
1803
+
1804
+ // Pass through other content in learning-path
1805
+ newTokens.push(tok);
1806
+ continue;
1807
+ }
1808
+
1809
+ // Inside @card section
1810
+ // (@end-card is handled by the top-level dispatch above, which runs
1811
+ // unconditionally before this block is ever reached.)
1812
+ if (inCard) {
1813
+ // h4 = card heading (first h4 only)
1814
+ if (tok.type === 'heading_open' && tok.tag === 'h4' && !cardHeadingDone) {
1815
+ const inlineTok = tokens[i + 1];
1816
+ const headingHtml = inlineTok && inlineTok.children
1817
+ ? md.renderer.render(inlineTok.children, md.options, {})
1818
+ : esc(inlineTok ? inlineTok.content || '' : '');
1819
+ newTokens.push(makeToken('html_block', '<div class="dc-card-heading">' + headingHtml + '</div>\n'));
1820
+ cardHeadingDone = true;
1821
+ i += 2; // skip inline + heading_close
1822
+ continue;
1823
+ }
1824
+
1825
+ // First blockquote after heading (before body opens) = pull quote
1826
+ if (tok.type === 'blockquote_open' && cardHeadingDone && !cardPullDone && !cardBodyOpen) {
1827
+ let bqContent = '';
1828
+ let j = i + 1;
1829
+ let blockquoteCloseIdx = -1;
1830
+ while (j < tokens.length && tokens[j].type !== 'blockquote_close') {
1831
+ if (tokens[j].type === 'inline') {
1832
+ // The inline content may have @end-card appended as a lazy continuation.
1833
+ // Extract text, strip the trailing marker, render the inline children.
1834
+ let rawText = tokens[j].content || '';
1835
+ // Remove lazy continuation @end-card from the end
1836
+ rawText = rawText.replace(/\n@end-card\s*$/, '').replace(/\s+@end-card\s*$/, '').trim();
1837
+
1838
+ // Render children, but strip @end-card from the rendered HTML if it appears
1839
+ bqContent = tokens[j].children
1840
+ ? md.renderer.render(tokens[j].children, md.options, {})
1841
+ : esc(rawText);
1842
+ // Strip @end-card from rendered output as a fallback
1843
+ bqContent = bqContent.replace(/\s*@end-card\s*$/, '').replace(/@end-card\s*<\/p>/, '</p>');
1844
+ }
1845
+ j++;
1846
+ }
1847
+ blockquoteCloseIdx = j;
1848
+ newTokens.push(makeToken('html_block', '<div class="dc-card-pull">' + bqContent + '</div>\n'));
1849
+ newTokens.push(makeToken('html_block', '<div class="dc-card-body">\n'));
1850
+ cardPullDone = true;
1851
+ cardBodyOpen = true;
1852
+ i = blockquoteCloseIdx; // for-loop i++ lands at token after blockquote_close
1853
+ continue;
1854
+ }
1855
+
1856
+ // Open body for any non-heading, non-pull content that arrived before body was opened
1857
+ if (!cardBodyOpen) {
1858
+ newTokens.push(makeToken('html_block', '<div class="dc-card-body">\n'));
1859
+ cardBodyOpen = true;
1860
+ // Do NOT skip tok — fall through to passthrough below
1861
+ }
1862
+
1863
+ // Inside body: strip @end-card from blockquotes (lazy continuation artifact)
1864
+ if (tok.type === 'blockquote_open') {
1865
+ let j = i + 1;
1866
+ let blockquoteCloseIdx = -1;
1867
+ while (j < tokens.length && tokens[j].type !== 'blockquote_close') {
1868
+ if (tokens[j].type === 'inline') {
1869
+ // Strip @end-card from inline content
1870
+ let rawText = tokens[j].content || '';
1871
+ rawText = rawText.replace(/\n@end-card\s*$/, '').replace(/\s+@end-card\s*$/, '').trim();
1872
+ tokens[j].content = rawText;
1873
+ }
1874
+ j++;
1875
+ }
1876
+
1877
+ // dc#44: tag the current blockquote as the card-footer candidate and
1878
+ // untag whichever one held that title before. The plugin streams
1879
+ // tokens once with no lookahead, so it can't know in advance which
1880
+ // body blockquote will turn out to be last — instead it keeps the
1881
+ // tag on exactly one token at a time, moving it forward on every
1882
+ // new body blockquote, so whichever one is tagged when the card
1883
+ // closes is the one `blockquote:last-of-type` would also match.
1884
+ // The CSS selector stays as the fallback for one release (dc#44).
1885
+ if (lastCardBodyBlockquote) {
1886
+ removeTokenClass(lastCardBodyBlockquote, 'dc-card-footer');
1887
+ }
1888
+ addTokenClass(tok, 'dc-card-footer');
1889
+ lastCardBodyBlockquote = tok;
1890
+ }
1891
+
1892
+ // Pass through card body content unchanged
1893
+ newTokens.push(tok);
1894
+ continue;
1895
+ }
1896
+
1897
+ // Mark image-only paragraphs so layout rules can target them without
1898
+ // relying on p:has(img). The base CSS rule
1899
+ // (p.dc-img-wrapper { padding:0; margin:0 }) lives in components.css.
1900
+ // Per-page rules can further refine position via .page.my-class p.dc-img-wrapper.
1901
+ if (tok.type === 'paragraph_open') {
1902
+ const inlineTok = tokens[i + 1];
1903
+ const closeTok = tokens[i + 2];
1904
+ if (inlineTok && inlineTok.type === 'inline' && closeTok && closeTok.type === 'paragraph_close') {
1905
+ const hasOnlyImage = Array.isArray(inlineTok.children)
1906
+ && inlineTok.children.length === 1
1907
+ && inlineTok.children[0].type === 'image';
1908
+ if (hasOnlyImage) {
1909
+ tok.attrSet('class', 'dc-img-wrapper');
1910
+ }
1911
+ }
1912
+ }
1913
+
1914
+ // Inside @skill section
1915
+ if (inSkillMode) {
1916
+ // Auto-close skill mode before any layout marker.
1917
+ // Example: chapter-02's inline example skill is followed directly by
1918
+ // `@page` with no explicit `@end-skill`. If we pass the layout marker
1919
+ // through while the card is still open, the next page wrapper and all
1920
+ // following content are emitted inside the current .dc-skill-card,
1921
+ // producing malformed HTML.
1922
+ //
1923
+ // This single check covers `@page`, `@section`, `@chapter` and
1924
+ // `@spread`: markers.js tokenizes those lines as `layout_marker`
1925
+ // block tokens and rewrites them into `layout_*` tokens in a core rule
1926
+ // registered with `md.core.ruler.after('block', ...)`, whereas this
1927
+ // plugin registers with `md.core.ruler.push(...)` — so the marker
1928
+ // transform ALWAYS runs first and the raw marker paragraphs no longer
1929
+ // exist by the time we get here.
1930
+ if (tok.type && tok.type.startsWith('layout_')) {
1931
+ closeAll();
1932
+ newTokens.push(tok);
1933
+ continue;
1934
+ }
1935
+
1936
+ // H4 = Skill card title
1937
+ if (tok.type === 'heading_open' && tok.tag === 'h4') {
1938
+ // Close previous card if open
1939
+ if (inSkillCard) {
1940
+ newTokens.push(makeToken('html_block', '</div></div></div>\n'));
1941
+ }
1942
+
1943
+ const inlineTok = tokens[i + 1];
1944
+ const h4Text = inlineTok && inlineTok.content ? inlineTok.content : '';
1945
+ const parsed = parseSkillTitle(h4Text);
1946
+ currentSkillIndex++;
1947
+
1948
+ // No variant class — shape is inherited from .specialty.<name> parent container
1949
+
1950
+ // Card splitting strategy:
1951
+ // - `.dc-allow-split`: omit `data-break-inside="avoid"` so the card
1952
+ // can split across pages (for cards taller than a page).
1953
+ // - Default: `data-break-inside="avoid"` keeps card intact.
1954
+ const userClasses = (currentSkillAttrs['class'] || '').split(/\s+/);
1955
+ const allowSplit = userClasses.includes('dc-allow-split');
1956
+ const breakInsideAttr = allowSplit ? '' : ' data-break-inside="avoid"';
1957
+
1958
+ // Track allowSplit for outcomes-table (needs avoid only when card can split)
1959
+ currentCardCanSplit = allowSplit;
1960
+
1961
+ // Track for @continue continuation cards.
1962
+ lastCardTitle = parsed.name;
1963
+ lastCardTier = parsed.tier || '';
1964
+
1965
+ // Build card structure
1966
+ let cardHtml = '<div' + buildAttrs(currentSkillAttrs, 'dc-skill-card') + ' name="' + esc(slugify(parsed.name)) + '"' + breakInsideAttr + '>\n';
1967
+ const tabBaseClass = 'dc-card-tab' + (parsed.highlight ? ' dc-highlight' : '');
1968
+ cardHtml += ' <div class="' + tabBaseClass + '">\n';
1969
+ const autoTier = currentLearningPathRef ? currentLearningPathRef + '.' + currentSkillIndex : '';
1970
+ cardHtml += ' <span class="dc-tab-title">' + esc(parsed.name) + '</span>\n';
1971
+ cardHtml += ' <span class="dc-tab-tier">' + esc(parsed.tier || autoTier) + '</span>\n';
1972
+ cardHtml += ' </div>\n';
1973
+
1974
+ const bodyBaseClass = 'dc-card-body' + (parsed.highlight ? ' dc-highlight-body' : '');
1975
+ cardHtml += ' <div class="' + bodyBaseClass + '">\n';
1976
+ cardHtml += ' <div class="dc-card-inner">\n';
1977
+
1978
+ newTokens.push(makeToken('html_block', cardHtml));
1979
+ inSkillCard = true;
1980
+ i += 2;
1981
+ continue;
1982
+ }
1983
+
1984
+ // H5 = Sub-header (like "Outcomes")
1985
+ if (tok.type === 'heading_open' && tok.tag === 'h5' && inSkillCard) {
1986
+ const inlineTok = tokens[i + 1];
1987
+ const h5Text = inlineTok && inlineTok.content ? inlineTok.content : '';
1988
+ // Skip "Outcomes" header since the table will have its own label
1989
+ if (h5Text.toLowerCase() !== 'outcomes') {
1990
+ newTokens.push(makeToken('html_block', '<div class="dc-sub-header">' + esc(h5Text) + '</div>\n'));
1991
+ }
1992
+ i += 2;
1993
+ continue;
1994
+ }
1995
+
1996
+ // Blockquote = Flavor text (inside skill card)
1997
+ if (tok.type === 'blockquote_open' && inSkillCard) {
1998
+ let bqContent = '';
1999
+ let j = i + 1;
2000
+ while (j < tokens.length && tokens[j].type !== 'blockquote_close') {
2001
+ if (tokens[j].type === 'inline') {
2002
+ bqContent = renderInlineChildren(tokens[j], md);
2003
+ }
2004
+ j++;
2005
+ }
2006
+ newTokens.push(makeToken('html_block', '<p class="dc-flavor">' + bqContent + '</p>\n'));
2007
+ i = j;
2008
+ continue;
2009
+ }
2010
+
2011
+ // Ordered list = Abilities
2012
+ if (tok.type === 'ordered_list_open' && inSkillCard) {
2013
+ const { items, endIndex } = collectOrderedListItems(tokens, i, md);
2014
+
2015
+ items.forEach((itemHtml, idx) => {
2016
+ const ability = parseAbilityFromListItem(itemHtml);
2017
+ if (ability) {
2018
+ let posAttrs = '';
2019
+ if (items.length > 1) {
2020
+ if (idx === items.length - 1) posAttrs = ' data-ability-last="true"';
2021
+ if (idx === items.length - 2) posAttrs = ' data-ability-penultimate="true"';
2022
+ }
2023
+ let output = '<div class="dc-ability"' + posAttrs + '>\n';
2024
+ output += ' <span class="' + ability.apClass + '">' + esc(ability.apVal) + '</span>\n';
2025
+ output += ' <p class="dc-ability-text">' + ability.text + '</p>\n';
2026
+ output += '</div>\n';
2027
+ newTokens.push(makeToken('html_block', output));
2028
+ } else {
2029
+ // Fallback: render as paragraph
2030
+ newTokens.push(makeToken('html_block', '<p>' + itemHtml + '</p>\n'));
2031
+ }
2032
+ });
2033
+
2034
+ i = endIndex;
2035
+ continue;
2036
+ }
2037
+
2038
+ // Bullet list inside skill card (e.g., sub-points)
2039
+ if (tok.type === 'bullet_list_open' && inSkillCard) {
2040
+ // When the card can split (allow-split → no card-level avoid), keep
2041
+ // short sub-lists whole so a break can't strand e.g. the "Arm:" bullet
2042
+ // on one page and "Leg:" on the next. Mirrors the table logic below
2043
+ // (buildTable receives currentCardCanSplit as needsAvoid). When the card
2044
+ // is NOT splittable it already carries card-level avoid, so adding a
2045
+ // nested list avoid would create a conflicting inner break boundary.
2046
+ if (currentCardCanSplit) {
2047
+ tok.attrSet('data-break-inside', 'avoid');
2048
+ }
2049
+ newTokens.push(tok);
2050
+ continue;
2051
+ }
2052
+
2053
+ // Table = Outcomes or Distance
2054
+ if (tok.type === 'table_open' && inSkillCard) {
2055
+ const tableTokens = collectTableTokens(tokens, i);
2056
+ const headers = getTableHeaders(tableTokens);
2057
+ const tableClass = classifyTable(headers);
2058
+ // Tables this plugin does not transform pass through untouched.
2059
+ // They used to fall into buildTable()'s generic branch, which
2060
+ // rebuilt markup from tbody rows only and dropped the <thead> core
2061
+ // had already emitted correctly — every ordinary table in a skill
2062
+ // card printed headerless (86 tables, only 22 keeping a thead).
2063
+ if (!tableClass) {
2064
+ newTokens.push(tok);
2065
+ continue;
2066
+ }
2067
+ // Pass currentCardCanSplit: outcomes-block needs data-break-inside="avoid"
2068
+ // only when the parent card can be split (no card-level avoid). When the
2069
+ // card already has card-level avoid, a nested outcomes-block avoid creates
2070
+ // a conflicting inner break that splits the card at the outcomes boundary.
2071
+ const tableHtml = buildTable(tableTokens, tableClass, md, currentCardCanSplit);
2072
+
2073
+ newTokens.push(makeToken('html_block', tableHtml));
2074
+ i = skipToTableClose(tokens, i);
2075
+ continue;
2076
+ }
2077
+
2078
+ // Pass through other tokens in skill mode
2079
+ newTokens.push(tok);
2080
+ continue;
2081
+ }
2082
+
2083
+ if (inProcedure && tok.type === 'ordered_list_open') {
2084
+ const { items, endIndex } = collectOrderedListItems(tokens, i, md);
2085
+ if (items.length > 0) {
2086
+ newTokens.push(makeToken('html_block', buildProcedureList(items)));
2087
+ }
2088
+ i = endIndex;
2089
+ continue;
2090
+ }
2091
+
2092
+ if (inProcedure && tok.type === 'ordered_list_close') {
2093
+ continue;
2094
+ }
2095
+
2096
+ // Outside any special section - pass through unchanged
2097
+ newTokens.push(tok);
2098
+ }
2099
+
2100
+ // Close any open structures at EOF
2101
+
2102
+ // Warn if @procedure was opened without a matching @end-procedure.
2103
+ // The transform is stateless (no open <div> to close) but every ordered
2104
+ // list after the marker was being silently hijacked into step-list mode.
2105
+ if (inProcedure) {
2106
+ if (!state.env.layoutWarnings) state.env.layoutWarnings = [];
2107
+ state.env.layoutWarnings.push({
2108
+ type: 'unclosed_procedure',
2109
+ message: '@procedure was opened but never closed with @end-procedure. ' +
2110
+ 'Ordered lists after the marker may have been transformed into ' +
2111
+ 'step-lists unintentionally.',
2112
+ });
2113
+ inProcedure = false;
2114
+ }
2115
+
2116
+ closeSpecialty();
2117
+
2118
+ state.tokens = newTokens;
2119
+ });
2120
+
2121
+ }
2122
+
2123
+ /**
2124
+ * Plugin metadata (the package version in package.json is the only version).
2125
+ */
2126
+ export const metadata = {
2127
+ name: 'Dimm City',
2128
+ description: 'Dimm City TTRPG macros: specialties, learning paths, skill cards, callouts, page templates',
2129
+ author: 'Dimm City',
2130
+ keywords: ['ttrpg', 'rpg', 'skills', 'dimm-city'],
2131
+ };