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.
- package/CHANGELOG.md +86 -0
- package/LICENSE +32 -0
- package/README.md +165 -0
- package/components.yaml +832 -0
- package/fonts/lixdu/LICENSE.txt +34 -0
- package/fonts/lixdu/lixdu.woff +0 -0
- package/fonts/titillium-web/OFL.txt +93 -0
- package/fonts/titillium-web/titillium-web-400-italic.ttf +0 -0
- package/fonts/titillium-web/titillium-web-400.ttf +0 -0
- package/fonts/titillium-web/titillium-web-600-italic.ttf +0 -0
- package/fonts/titillium-web/titillium-web-600.ttf +0 -0
- package/fonts/titillium-web/titillium-web-700-italic.ttf +0 -0
- package/fonts/titillium-web/titillium-web-700.ttf +0 -0
- package/fonts/tomorrow/OFL.txt +93 -0
- package/fonts/tomorrow/tomorrow-400-italic.ttf +0 -0
- package/fonts/tomorrow/tomorrow-400.ttf +0 -0
- package/fonts/tomorrow/tomorrow-500-italic.ttf +0 -0
- package/fonts/tomorrow/tomorrow-500.ttf +0 -0
- package/fonts/tomorrow/tomorrow-700-italic.ttf +0 -0
- package/fonts/tomorrow/tomorrow-700.ttf +0 -0
- package/images/brick-bg-01.png +0 -0
- package/package.json +78 -0
- package/plugin.js +2131 -0
- package/snippets/alert.md +2 -0
- package/snippets/block.md +5 -0
- package/snippets/callout.md +5 -0
- package/snippets/card.md +11 -0
- package/snippets/chapter-start-page.md +11 -0
- package/snippets/credits-page.md +13 -0
- package/snippets/definition.md +7 -0
- package/snippets/dm-note.md +5 -0
- package/snippets/gear.md +9 -0
- package/snippets/learning-path.md +10 -0
- package/snippets/lede.md +5 -0
- package/snippets/outcome.md +8 -0
- package/snippets/procedure.md +7 -0
- package/snippets/sidebar.md +7 -0
- package/snippets/skill.md +9 -0
- package/snippets/specialty-art.md +5 -0
- package/snippets/specialty-card.md +5 -0
- package/snippets/specialty.md +11 -0
- package/snippets/toc-page.md +15 -0
- package/styles/components/callouts.css +227 -0
- package/styles/components/cards.css +487 -0
- package/styles/components/chrome.css +483 -0
- package/styles/components/data.css +1026 -0
- package/styles/components/images.css +215 -0
- package/styles/components/section.css +1074 -0
- package/styles/components/specialty-identity.css +315 -0
- package/styles/components/specialty.css +273 -0
- package/styles/dc-component-defaults.css +214 -0
- package/styles/dc-core.css +268 -0
- package/styles/dc-fonts.css +107 -0
- package/styles/dc-identity.css +66 -0
- package/styles/dc-native.css +330 -0
- package/styles/dc-palette.css +198 -0
- package/styles/page-rules.css +207 -0
- 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, '&').replace(/</g, '<')
|
|
101
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
};
|