@heroiclands/package-build 22.21.0 → 22.22.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 +191 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +26 -3
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +73 -4
- package/docs/api.md +36 -18
- package/docs/authoring/dates-and-calendars.md +4 -2
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +237 -28
- package/docs/configuration.md +27 -1
- package/docs/content-format.md +3 -1
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +224 -46
- package/docs/reference/note-types.md +21 -4
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +10 -20
- package/engine/base-compiler.mjs +86 -21
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +0 -1
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-captions.mjs +57 -8
- package/engine/content-footnotes.mjs +189 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-index.mjs +2 -2
- package/engine/content-links.mjs +14 -6
- package/engine/content-tables.mjs +45 -4
- package/engine/content-tasklists.mjs +189 -0
- package/engine/docs-checks.mjs +4 -4
- package/engine/extension-attributes.mjs +48 -2
- package/engine/frontmatter-lint.mjs +47 -64
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +308 -0
- package/engine/helpers.mjs +14 -13
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +83 -8
- package/engine/journals.mjs +36 -17
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +178 -53
- package/engine/note-dates.mjs +49 -0
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +172 -37
- package/engine/page-lists.mjs +358 -0
- package/engine/pdf-build.mjs +76 -42
- package/engine/pdf-render.mjs +422 -49
- package/engine/rank-ladder.mjs +198 -0
- package/engine/reckoning-markers.mjs +1 -1
- package/engine/retired-fields.mjs +8 -13
- package/engine/scenes.mjs +10 -3
- package/engine/site-build.mjs +41 -12
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +18 -2
- package/engine/site-preview.mjs +18 -7
- package/engine/social-ties.mjs +52 -14
- package/engine/sql-tables.mjs +66 -9
- package/engine/standings.mjs +89 -6
- package/engine/system-block.mjs +12 -14
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +6 -3
- package/manifest.mjs +13 -0
- package/package.json +1 -1
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +4 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/base-compiler.d.mts +19 -0
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-captions.d.mts +14 -1
- package/types/engine/content-footnotes.d.mts +73 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-tables.d.mts +12 -4
- package/types/engine/content-tasklists.d.mts +72 -0
- package/types/engine/extension-attributes.d.mts +22 -1
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +174 -0
- package/types/engine/helpers.d.mts +7 -4
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/journals.d.mts +14 -7
- package/types/engine/markdown-expressions.d.mts +23 -0
- package/types/engine/note-frontmatter.d.mts +1 -4
- package/types/engine/note-vocabulary.d.mts +82 -6
- package/types/engine/page-lists.d.mts +126 -0
- package/types/engine/pdf-render.d.mts +72 -2
- package/types/engine/rank-ladder.d.mts +32 -0
- package/types/engine/retired-fields.d.mts +4 -6
- package/types/engine/site-build.d.mts +13 -1
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +13 -1
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/sql-tables.d.mts +31 -7
- package/types/engine/standings.d.mts +21 -3
- package/types/engine/system-block.d.mts +11 -12
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +3 -3
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-secrets.d.mts +0 -17
package/engine/pdf-render.mjs
CHANGED
|
@@ -80,8 +80,37 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
|
80
80
|
});
|
|
81
81
|
import { slugify } from "./content-slug.mjs";
|
|
82
82
|
import { scanCaptions } from "./content-captions.mjs";
|
|
83
|
-
import {
|
|
84
|
-
import {
|
|
83
|
+
import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
|
|
84
|
+
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
85
|
+
import { HTML_TAG, htmlMessage } from "./content-html.mjs";
|
|
86
|
+
import { EXPRESSION } from "./markdown-expressions.mjs";
|
|
87
|
+
import { WIKILINK } from "./wikilink-syntax.mjs";
|
|
88
|
+
import { EMBED_PATTERN } from "./content-embeds.mjs";
|
|
89
|
+
import { positionInBody } from "./diagnostics.mjs";
|
|
90
|
+
import {
|
|
91
|
+
WITHHELD_CLASS,
|
|
92
|
+
scanHeadingAttributes,
|
|
93
|
+
splitHeadingAttributes,
|
|
94
|
+
withheldSections,
|
|
95
|
+
} from "./heading-attributes.mjs";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How each named block prints. Typst has no stylesheet to defer to, so the
|
|
99
|
+
* colours live here rather than in the emitted markup; the HTML surfaces carry
|
|
100
|
+
* a class and let CSS decide.
|
|
101
|
+
*/
|
|
102
|
+
const BLOCK_PRINT = Object.freeze({
|
|
103
|
+
info: Object.freeze({ color: "#2f6f9f", background: "#eef6fb", symbol: "i" }),
|
|
104
|
+
secret: Object.freeze({ color: "#5c4b8a", background: "#f2eefb", symbol: "!" }),
|
|
105
|
+
warn: Object.freeze({ color: "#9a6700", background: "#fff5db", symbol: "!" }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
import {
|
|
109
|
+
misplacedFootnoteDefinitions,
|
|
110
|
+
separateFootnotes,
|
|
111
|
+
unresolvedFootnoteReferences,
|
|
112
|
+
unusedFootnoteDefinitions,
|
|
113
|
+
} from "./content-footnotes.mjs";
|
|
85
114
|
|
|
86
115
|
/**
|
|
87
116
|
* Characters that mean something to Typst's markup parser.
|
|
@@ -168,6 +197,203 @@ export function createParser(registry) {
|
|
|
168
197
|
return md;
|
|
169
198
|
}
|
|
170
199
|
|
|
200
|
+
/**
|
|
201
|
+
* A closed HTML comment, however many lines it spans.
|
|
202
|
+
*
|
|
203
|
+
* Closed only: an unclosed `<!--` is text by CommonMark's reading, and a
|
|
204
|
+
* pattern that ran to the end of the file for want of a closer would delete
|
|
205
|
+
* every line after the mistake. {@link reportUnrenderable} names it instead.
|
|
206
|
+
*
|
|
207
|
+
* @type {RegExp}
|
|
208
|
+
*/
|
|
209
|
+
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A markdown body with its HTML comments taken out.
|
|
213
|
+
*
|
|
214
|
+
* A comment is an author's aside to the next author — a `markdownlint` pragma, a
|
|
215
|
+
* note recording why a notice is quoted verbatim — and no surface shows one: the
|
|
216
|
+
* packs and the website emit it into HTML, where it is a comment still. Typst
|
|
217
|
+
* has nowhere to put it, so markdown-it hands the delimiters over as prose and
|
|
218
|
+
* the aside is typeset. It comes out here instead, and a comment inside a fence
|
|
219
|
+
* or a code span stays, because there it is an example of a comment.
|
|
220
|
+
*
|
|
221
|
+
* **The line count is preserved.** Every finding this module reports is located
|
|
222
|
+
* in the markdown it was handed, so a pass that removed a line would move every
|
|
223
|
+
* position after it.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} markdown - The body, as authored.
|
|
226
|
+
* @returns {string} The same body, its comments replaced by their own newlines.
|
|
227
|
+
*/
|
|
228
|
+
export function stripHtmlComments(markdown) {
|
|
229
|
+
return replaceOutsideCode(String(markdown ?? ""), HTML_COMMENT, (comment) =>
|
|
230
|
+
"\n".repeat((comment.match(/\n/g) ?? []).length),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where a position in the markdown handed in falls in the file that holds it.
|
|
236
|
+
*
|
|
237
|
+
* The three corrections {@link module:engine/diagnostics.positionInBody} makes,
|
|
238
|
+
* applied to a line and column rather than to an offset: the frontmatter above
|
|
239
|
+
* the body, the indentation a trimmed first line lost, and a line that a
|
|
240
|
+
* content table generated and no author wrote — which has no column to point at.
|
|
241
|
+
*
|
|
242
|
+
* @param {number} line - 1-based line within the markdown.
|
|
243
|
+
* @param {number} column - 1-based column within that line.
|
|
244
|
+
* @param {object} [opts] - As {@link markdownToTypst} takes.
|
|
245
|
+
* @returns {{line: number, column?: number}} The position, in the file.
|
|
246
|
+
*/
|
|
247
|
+
function linePosition(line, column, { bodyLine = 1, bodyColumn = 1, lineMap } = {}) {
|
|
248
|
+
const mapped = lineMap?.[line - 1];
|
|
249
|
+
const sourceLine = mapped ? mapped.line : line - 1;
|
|
250
|
+
if (mapped?.generated) return { line: bodyLine + sourceLine };
|
|
251
|
+
return {
|
|
252
|
+
line: bodyLine + sourceLine,
|
|
253
|
+
column: sourceLine === 0 ? bodyColumn + column - 1 : column,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Where a character offset within the markdown handed in falls in its file.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} source - The markdown the offset indexes into.
|
|
261
|
+
* @param {number} offset - 0-based character offset.
|
|
262
|
+
* @param {object} [opts] - As {@link markdownToTypst} takes.
|
|
263
|
+
* @returns {{line: number, column?: number}} The position, in the file.
|
|
264
|
+
*/
|
|
265
|
+
function offsetPosition(source, offset, opts = {}) {
|
|
266
|
+
const { line, column, generated } = positionInBody(source, offset, opts);
|
|
267
|
+
return generated ? { line } : { line, column };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Everything in one body that the book cannot set, reported.
|
|
272
|
+
*
|
|
273
|
+
* **The renderer owes a finding for every construct it meets and cannot
|
|
274
|
+
* render.** It emits text and reads nothing, so there is no state it can refuse
|
|
275
|
+
* from; what it can do is say what it met, at the line it met it, and let the
|
|
276
|
+
* caller decide. Without that a body carrying a construct this pass does not
|
|
277
|
+
* handle is typeset as its own markup — delimiters, braces and all — and the
|
|
278
|
+
* build exits 0 having printed the mistake into the book.
|
|
279
|
+
*
|
|
280
|
+
* Two families are named here, and they fail for opposite reasons:
|
|
281
|
+
*
|
|
282
|
+
* - **A construct no book renderer reads.** Raw HTML, and an HTML comment that
|
|
283
|
+
* is never closed. Both reach Typst as prose, and the first is reported in
|
|
284
|
+
* {@link module:engine/content-html}'s own words so one authored tag does not
|
|
285
|
+
* get two explanations.
|
|
286
|
+
* - **A pass's output that never arrived.** A wikilink, an embed and an inline
|
|
287
|
+
* `{{…}}` expression are each resolved before the book sees them; one that is
|
|
288
|
+
* still in the markdown means the pass that owns it did not run over this
|
|
289
|
+
* body, and the markup is about to be typeset verbatim. Reported only where
|
|
290
|
+
* those passes did not run — `opts.prepared` — because each of them leaves the
|
|
291
|
+
* markup as written when it fails and has already said so in its own words.
|
|
292
|
+
*
|
|
293
|
+
* A `:::` block and a `:::caption` are the pair this pass does handle, and their
|
|
294
|
+
* own scanners decide what is well formed; their findings are reported here so
|
|
295
|
+
* that a front-matter file, a prose file and a note body all get them.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} source - The body, comments already stripped.
|
|
298
|
+
* @param {string} definitions - The footnote definitions separated from it.
|
|
299
|
+
* @param {object} opts - As {@link markdownToTypst} takes.
|
|
300
|
+
* @param {string} original - `source` before its definitions were blanked —
|
|
301
|
+
* {@link unusedFootnoteDefinitions} locates a definition by searching for
|
|
302
|
+
* its own text, which `source` no longer carries.
|
|
303
|
+
* @returns {void}
|
|
304
|
+
*/
|
|
305
|
+
function reportUnrenderable(source, definitions, opts, original) {
|
|
306
|
+
const findings = opts.findings;
|
|
307
|
+
if (!findings) return;
|
|
308
|
+
const file = opts.file ? { file: opts.file } : {};
|
|
309
|
+
/**
|
|
310
|
+
* @param {{line: number, column?: number}} position - Where it is.
|
|
311
|
+
* @param {"warning"|"error"} severity - Which level.
|
|
312
|
+
* @param {string} message - What is wrong.
|
|
313
|
+
* @returns {void}
|
|
314
|
+
*/
|
|
315
|
+
const report = (position, severity, message) => {
|
|
316
|
+
findings.push({ ...file, ...position, severity, message });
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const blocks = scanBlocks(source);
|
|
320
|
+
for (const error of blocks.errors)
|
|
321
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
322
|
+
// A container's body, read for faults of its own — an empty box, an
|
|
323
|
+
// attribute that does not parse — which the scan above passes over, because
|
|
324
|
+
// there an inner opener is a counted line rather than a block.
|
|
325
|
+
//
|
|
326
|
+
// Only where that reading was sound. One mistake draws several findings from
|
|
327
|
+
// a single scan already, and a body read from a misread outer block draws
|
|
328
|
+
// more that say the same thing in other words. One level down and no
|
|
329
|
+
// further: a box inside a GM-only section is the only nesting the format
|
|
330
|
+
// admits.
|
|
331
|
+
const lines = source.split("\n");
|
|
332
|
+
if (!blocks.errors.length) {
|
|
333
|
+
for (const block of blocks.blocks) {
|
|
334
|
+
if (!BLOCK_CONTAINERS.includes(block.name)) continue;
|
|
335
|
+
const body = lines.slice(block.start + 1, block.end).join("\n");
|
|
336
|
+
for (const error of scanBlocks(body).errors)
|
|
337
|
+
report(
|
|
338
|
+
linePosition(block.start + 1 + error.line, error.column, opts),
|
|
339
|
+
"error",
|
|
340
|
+
error.message,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const error of scanCaptions(source).errors)
|
|
345
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
346
|
+
for (const error of scanHeadingAttributes(source).errors)
|
|
347
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
348
|
+
for (const error of withheldSections(source).errors)
|
|
349
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
350
|
+
|
|
351
|
+
// Every closed comment is gone by the time this runs, so an opener still
|
|
352
|
+
// here is one that was never closed — on the website that swallows the rest
|
|
353
|
+
// of the page, and here it is typeset.
|
|
354
|
+
for (const match of matchAllOutsideCode(source, /<!--/g))
|
|
355
|
+
report(
|
|
356
|
+
offsetPosition(source, match.index, opts),
|
|
357
|
+
"error",
|
|
358
|
+
"an HTML comment is opened and never closed — close it with `-->`",
|
|
359
|
+
);
|
|
360
|
+
for (const match of matchAllOutsideCode(source, HTML_TAG))
|
|
361
|
+
report(offsetPosition(source, match.index, opts), "warning", htmlMessage(match[0]));
|
|
362
|
+
if (!opts.prepared) {
|
|
363
|
+
for (const match of matchAllOutsideCode(source, EMBED_PATTERN))
|
|
364
|
+
report(
|
|
365
|
+
offsetPosition(source, match.index, opts),
|
|
366
|
+
"error",
|
|
367
|
+
`\`${match[0]}\` reaches the book as written — an embed is resolved to a ` +
|
|
368
|
+
"picture before the book is set, and nothing resolved this one",
|
|
369
|
+
);
|
|
370
|
+
for (const match of matchAllOutsideCode(source, WIKILINK))
|
|
371
|
+
report(
|
|
372
|
+
offsetPosition(source, match.index, opts),
|
|
373
|
+
"error",
|
|
374
|
+
`\`${match[0]}\` reaches the book as written — a link is resolved to an ` +
|
|
375
|
+
"address before the book is set, and nothing resolved this one",
|
|
376
|
+
);
|
|
377
|
+
for (const match of matchAllOutsideCode(source, EXPRESSION))
|
|
378
|
+
report(
|
|
379
|
+
offsetPosition(source, match.index, opts),
|
|
380
|
+
"error",
|
|
381
|
+
`\`${match[0]}\` reaches the book as written — an expression is evaluated ` +
|
|
382
|
+
"before the book is set, and nothing evaluated this one",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
// Derived from the one place that already finds definitions, rather than
|
|
386
|
+
// a second scanner that could drift from {@link separateFootnotes} — the
|
|
387
|
+
// web and Foundry report the identical three findings from the identical
|
|
388
|
+
// functions.
|
|
389
|
+
for (const error of misplacedFootnoteDefinitions(source))
|
|
390
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
391
|
+
for (const error of unresolvedFootnoteReferences(source, definitions))
|
|
392
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
393
|
+
for (const error of unusedFootnoteDefinitions(original))
|
|
394
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
395
|
+
}
|
|
396
|
+
|
|
171
397
|
/**
|
|
172
398
|
* Render markdown as Typst content.
|
|
173
399
|
*
|
|
@@ -186,8 +412,26 @@ export function createParser(registry) {
|
|
|
186
412
|
* note's own `##` nests beneath the entry heading the book gave it.
|
|
187
413
|
* @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
|
|
188
414
|
* every `{#slug}` the body declares.
|
|
189
|
-
*
|
|
190
|
-
*
|
|
415
|
+
* @param {object[]} [opts.findings] - Collected here rather than thrown. Every
|
|
416
|
+
* construct this pass cannot render is reported into it — see
|
|
417
|
+
* {@link reportUnrenderable} — and a caller that passes none renders the same
|
|
418
|
+
* Typst and is told nothing.
|
|
419
|
+
* @param {string} [opts.file] - The file the markdown came from, named in every
|
|
420
|
+
* finding.
|
|
421
|
+
* @param {number} [opts.bodyLine] - The 1-based file line the body starts on, so
|
|
422
|
+
* a finding's line is the file's rather than the body's.
|
|
423
|
+
* @param {number} [opts.bodyColumn] - The 1-based column the body starts at.
|
|
424
|
+
* @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
|
|
425
|
+
* table expansion, mapping each line handed in back to the line an author
|
|
426
|
+
* wrote.
|
|
427
|
+
* @param {boolean} [opts.prepared] - Whether this body has been through the
|
|
428
|
+
* passes that resolve a wikilink, an embed and an inline expression. A note's
|
|
429
|
+
* has; a front-matter file's and a prose file's have not, and markup of theirs
|
|
430
|
+
* left in one is reported rather than typeset in silence.
|
|
431
|
+
* @param {string} [opts.url] - The absolute address this book's pages are served
|
|
432
|
+
* at. Every link the book sets as a URL is resolved against it, because a
|
|
433
|
+
* reader holding a PDF has no page to resolve a path against — see
|
|
434
|
+
* {@link renderLink}.
|
|
191
435
|
* @returns {string} Typst markup.
|
|
192
436
|
*/
|
|
193
437
|
export function markdownToTypst(markdown, opts = {}) {
|
|
@@ -199,14 +443,68 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
199
443
|
headingOffset = 0,
|
|
200
444
|
anchorPrefix = "",
|
|
201
445
|
} = opts;
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
const source = separated?.markdown ??
|
|
446
|
+
const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
|
|
447
|
+
const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
|
|
448
|
+
const source = separated?.markdown ?? given;
|
|
205
449
|
const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
|
|
206
450
|
const footnoteState = opts.footnoteState ?? { seen: new Set() };
|
|
207
|
-
const sharedOptions = {
|
|
451
|
+
const sharedOptions = {
|
|
452
|
+
...opts,
|
|
453
|
+
footnoteDefinitions: definitions,
|
|
454
|
+
footnoteState,
|
|
455
|
+
nested: true,
|
|
456
|
+
};
|
|
457
|
+
// Read once, over the body as it arrived: every position a finding carries
|
|
458
|
+
// is an offset into *this* string, and the renderer below walks slices of it.
|
|
459
|
+
if (!opts.nested) reportUnrenderable(source, definitions, opts, given);
|
|
460
|
+
// One map for the whole body, not one per block: a heading inside a
|
|
461
|
+
// blockquote or a list item shares the entry's anchor namespace with every
|
|
462
|
+
// other heading in the same body, because `sectionLabel` scopes by entry
|
|
463
|
+
// rather than by container.
|
|
464
|
+
const ctx = {
|
|
465
|
+
md,
|
|
466
|
+
links,
|
|
467
|
+
glyphs,
|
|
468
|
+
images,
|
|
469
|
+
headingOffset,
|
|
470
|
+
anchorPrefix,
|
|
471
|
+
url: opts.url,
|
|
472
|
+
seen: new Map(),
|
|
473
|
+
footnoteState,
|
|
474
|
+
footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
|
|
475
|
+
};
|
|
476
|
+
// A withheld section is set in the box a `:::secret` is set in, and its
|
|
477
|
+
// heading is set inside it: a printed page honours no reader permission, so
|
|
478
|
+
// the labelled box is what tells a referee the section is for them. Read
|
|
479
|
+
// outermost, before the named blocks, because a section holds blocks of its
|
|
480
|
+
// own and they are rendered by the recursion into its body.
|
|
481
|
+
if (!opts.insideAdmonition && !opts.insideWithheld) {
|
|
482
|
+
const { sections } = withheldSections(source);
|
|
483
|
+
if (sections.length) {
|
|
484
|
+
const lines = source.split("\n");
|
|
485
|
+
const output = [];
|
|
486
|
+
let cursor = 0;
|
|
487
|
+
for (const section of sections) {
|
|
488
|
+
output.push(
|
|
489
|
+
markdownToTypst(lines.slice(cursor, section.start).join("\n"), sharedOptions),
|
|
490
|
+
);
|
|
491
|
+
output.push(
|
|
492
|
+
withheldBox(
|
|
493
|
+
markdownToTypst(lines.slice(section.start, section.end).join("\n"), {
|
|
494
|
+
...sharedOptions,
|
|
495
|
+
insideWithheld: true,
|
|
496
|
+
}),
|
|
497
|
+
ctx,
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
cursor = section.end;
|
|
501
|
+
}
|
|
502
|
+
output.push(markdownToTypst(lines.slice(cursor).join("\n"), sharedOptions));
|
|
503
|
+
return output.join("\n");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
208
506
|
if (!opts.insideAdmonition) {
|
|
209
|
-
const { blocks } =
|
|
507
|
+
const { blocks } = scanBlocks(source);
|
|
210
508
|
if (blocks.length) {
|
|
211
509
|
const lines = source.split("\n");
|
|
212
510
|
const output = [];
|
|
@@ -218,13 +516,18 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
218
516
|
insideAdmonition: true,
|
|
219
517
|
}),
|
|
220
518
|
);
|
|
221
|
-
const color = block.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
519
|
+
const { color, background, symbol } = BLOCK_PRINT[block.name];
|
|
520
|
+
// The title is inline markdown, rendered the way every other
|
|
521
|
+
// phrase in the book is. Set as it was written it would be read
|
|
522
|
+
// by Typst instead: `*word*` is bold there and emphasis here, a
|
|
523
|
+
// `#` opens a function call, and one unbalanced `]` ends the box
|
|
524
|
+
// and takes the rest of the document with it.
|
|
525
|
+
const label = inlineMarkup(block.title, ctx);
|
|
526
|
+
// A GM-only section holds a box, so its body is read for blocks
|
|
527
|
+
// of its own; a box holds none, and the scan has already said so.
|
|
225
528
|
const content = markdownToTypst(block.body, {
|
|
226
529
|
...sharedOptions,
|
|
227
|
-
insideAdmonition:
|
|
530
|
+
insideAdmonition: !BLOCK_CONTAINERS.includes(block.name),
|
|
228
531
|
});
|
|
229
532
|
output.push(
|
|
230
533
|
`\n#block(width: 100%, fill: rgb("${background}"), stroke: (left: 2pt + rgb("${color}")), inset: 8pt, above: 0.7em, below: 0.7em)[#text(fill: rgb("${color}"), weight: "bold")[${symbol} ${label}]\n\n${content}]\n`,
|
|
@@ -240,27 +543,20 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
240
543
|
return output.join("\n");
|
|
241
544
|
}
|
|
242
545
|
}
|
|
243
|
-
// One map for the whole body, not one per block: a heading inside a
|
|
244
|
-
// blockquote or a list item shares the entry's anchor namespace with every
|
|
245
|
-
// other heading in the same body, because `sectionLabel` scopes by entry
|
|
246
|
-
// rather than by container.
|
|
247
|
-
const ctx = {
|
|
248
|
-
md,
|
|
249
|
-
links,
|
|
250
|
-
glyphs,
|
|
251
|
-
images,
|
|
252
|
-
headingOffset,
|
|
253
|
-
anchorPrefix,
|
|
254
|
-
seen: new Map(),
|
|
255
|
-
footnoteState,
|
|
256
|
-
footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
|
|
257
|
-
};
|
|
258
546
|
const lines = source.split("\n");
|
|
259
547
|
const localCaptions = scanCaptions(source).captions;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
548
|
+
// Numbered by the caller, which counts across the whole book, and placed by
|
|
549
|
+
// the scan above, which is the only reading of *this* body. Paired by
|
|
550
|
+
// position: two captions may share an id — a finding, and the book is built
|
|
551
|
+
// anyway — and keying on one would give the second caption's lines to the
|
|
552
|
+
// first, printing its block twice and losing its label.
|
|
553
|
+
const provided = opts.captions ?? localCaptions;
|
|
554
|
+
const numbered = new Map(provided.map((caption) => [caption.id, caption]));
|
|
555
|
+
const captions = localCaptions.map((caption, at) => {
|
|
556
|
+
const counted =
|
|
557
|
+
provided[at]?.id === caption.id ? provided[at] : (numbered.get(caption.id) ?? caption);
|
|
558
|
+
return { ...caption, label: counted.label, number: counted.number };
|
|
559
|
+
});
|
|
264
560
|
if (!captions.length) return renderMarkdownSegment(source, md, ctx, definitions);
|
|
265
561
|
const out = [];
|
|
266
562
|
let cursor = 0;
|
|
@@ -428,12 +724,29 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
428
724
|
}
|
|
429
725
|
|
|
430
726
|
/**
|
|
431
|
-
* A
|
|
727
|
+
* A withheld section, set in the box a `secret` block is set in.
|
|
728
|
+
*
|
|
729
|
+
* @param {string} content - The section's rendered Typst, heading included.
|
|
730
|
+
* @param {object} ctx - Render context, for the label's inline markup.
|
|
731
|
+
* @returns {string} Typst markup.
|
|
732
|
+
*/
|
|
733
|
+
function withheldBox(content, ctx) {
|
|
734
|
+
const { color, background, symbol } = BLOCK_PRINT[WITHHELD_CLASS];
|
|
735
|
+
const label = inlineMarkup(BLOCK_NAMES[WITHHELD_CLASS], ctx);
|
|
736
|
+
return `\n#block(width: 100%, fill: rgb("${background}"), stroke: (left: 2pt + rgb("${color}")), inset: 8pt, above: 0.7em, below: 0.7em)[#text(fill: rgb("${color}"), weight: "bold")[${symbol} ${label}]\n\n${content}]\n`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A heading's text, and the attribute block it may end with.
|
|
432
741
|
*
|
|
433
742
|
* The suffix is removed from the *rendered* children rather than from the raw
|
|
434
743
|
* source, so an anchor written inside emphasis or after a link still comes off
|
|
435
744
|
* cleanly and the text either side of it survives.
|
|
436
745
|
*
|
|
746
|
+
* A class and an attribute have nowhere to go in Typst, which has no
|
|
747
|
+
* stylesheet, so the id is what the book takes and the rest is dropped — the
|
|
748
|
+
* reference says so per surface.
|
|
749
|
+
*
|
|
437
750
|
* @param {object} inline - The heading's `inline` token.
|
|
438
751
|
* @param {object} ctx - Render context.
|
|
439
752
|
* @returns {{text: string, anchor: string}} The heading, and its anchor or "".
|
|
@@ -441,31 +754,36 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
441
754
|
function splitHeadingAnchor(inline, ctx) {
|
|
442
755
|
const last = inline?.children?.[(inline.children?.length ?? 0) - 1];
|
|
443
756
|
const raw = last?.type === "text" ? String(last.content ?? "") : "";
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
757
|
+
const parsed = splitHeadingAttributes(raw);
|
|
758
|
+
if (!parsed.braces || parsed.problems.length) {
|
|
759
|
+
return { text: renderInline(inline, ctx), anchor: "" };
|
|
760
|
+
}
|
|
446
761
|
// Rendered with the suffix removed from a copy, so the token stream the
|
|
447
762
|
// caller owns is not mutated — the same tokens are walked again by the
|
|
448
763
|
// journals and the index.
|
|
449
764
|
const children = [...inline.children];
|
|
450
|
-
children[children.length - 1] = { ...last, content:
|
|
451
|
-
return { text: renderInline({ ...inline, children }, ctx), anchor:
|
|
765
|
+
children[children.length - 1] = { ...last, content: parsed.text };
|
|
766
|
+
return { text: renderInline({ ...inline, children }, ctx), anchor: parsed.id };
|
|
452
767
|
}
|
|
453
768
|
|
|
454
769
|
/**
|
|
455
|
-
* A heading's
|
|
770
|
+
* A heading's words, unescaped and without its attribute block.
|
|
456
771
|
*
|
|
457
772
|
* Used only to derive an anchor when the author wrote none, so it wants the
|
|
458
|
-
* words as typed rather than the Typst-escaped
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
773
|
+
* words as typed rather than the Typst-escaped text {@link splitHeadingAnchor}
|
|
774
|
+
* renders — {@link module:engine/content-slug.slugify} normalises punctuation
|
|
775
|
+
* and case itself and has no use for an escape backslash. The block comes off
|
|
776
|
+
* because a heading that declares classes alone would otherwise be addressed by
|
|
777
|
+
* an anchor naming them.
|
|
462
778
|
*
|
|
463
779
|
* @param {object} inline - The heading's `inline` token.
|
|
464
780
|
* @returns {string} The heading's raw text.
|
|
465
781
|
*/
|
|
466
782
|
function plainHeadingText(inline) {
|
|
467
783
|
const children = inline?.children ?? [];
|
|
468
|
-
|
|
784
|
+
const raw = children.map((child) => child.content ?? "").join("");
|
|
785
|
+
const parsed = splitHeadingAttributes(raw);
|
|
786
|
+
return parsed.problems.length ? raw : parsed.text;
|
|
469
787
|
}
|
|
470
788
|
|
|
471
789
|
/**
|
|
@@ -550,10 +868,26 @@ function listItems(tokens, start, end, ctx) {
|
|
|
550
868
|
return items;
|
|
551
869
|
}
|
|
552
870
|
|
|
871
|
+
/**
|
|
872
|
+
* A phrase of inline Markdown, set the way the prose around it is.
|
|
873
|
+
*
|
|
874
|
+
* A caption, a named block's title: text an author writes outside a paragraph
|
|
875
|
+
* and still expects emphasis in. Rendering it through the same walk is also what
|
|
876
|
+
* makes it inert — Typst's own markup characters are escaped on the way, which
|
|
877
|
+
* handing the string over raw does not do.
|
|
878
|
+
*
|
|
879
|
+
* @param {string} text - The phrase, as authored.
|
|
880
|
+
* @param {object} ctx - Render context.
|
|
881
|
+
* @returns {string} Typst markup.
|
|
882
|
+
*/
|
|
883
|
+
function inlineMarkup(text, ctx) {
|
|
884
|
+
const inline = ctx.md.parseInline(String(text ?? ""), {})[0];
|
|
885
|
+
return inline ? renderInline(inline, ctx) : "";
|
|
886
|
+
}
|
|
887
|
+
|
|
553
888
|
/** Caption text with the same inline Markdown handling as the surrounding prose. */
|
|
554
889
|
function captionMarkup(caption, ctx) {
|
|
555
|
-
|
|
556
|
-
return `${escapeTypst(caption.label)}: ${renderInline(inline, ctx)}`;
|
|
890
|
+
return `${escapeTypst(caption.label)}: ${inlineMarkup(caption.text, ctx)}`;
|
|
557
891
|
}
|
|
558
892
|
|
|
559
893
|
/**
|
|
@@ -783,6 +1117,39 @@ function inlineRaw(content) {
|
|
|
783
1117
|
return `#raw("${escapeTypstString(content)}", block: false)`;
|
|
784
1118
|
}
|
|
785
1119
|
|
|
1120
|
+
/**
|
|
1121
|
+
* A page address as a reader of the book can follow it.
|
|
1122
|
+
*
|
|
1123
|
+
* **Every link the book sets as a URL is absolute.** A page's address is written
|
|
1124
|
+
* from the root of the site that serves it — `/sohl/skill-guil/` — because on the
|
|
1125
|
+
* website that is the shortest form that is right wherever the page is read
|
|
1126
|
+
* from. A PDF is read from nowhere: a viewer handed a path has no document to
|
|
1127
|
+
* resolve it against, so it either does nothing or looks for a file on the
|
|
1128
|
+
* reader's own disk, and a book of a thousand pages carries a thousand links
|
|
1129
|
+
* that do neither.
|
|
1130
|
+
*
|
|
1131
|
+
* So a root-relative address is resolved against the site's own, which is the
|
|
1132
|
+
* one absolute address a build already holds. A **dependency's** page keeps its
|
|
1133
|
+
* own package prefix, from the registry in
|
|
1134
|
+
* {@link module:engine/content-address.PACKAGE_BASE}, so it resolves to the host
|
|
1135
|
+
* serving that package — and where the registry names a host of its own the
|
|
1136
|
+
* address is already absolute and is left exactly as it is.
|
|
1137
|
+
*
|
|
1138
|
+
* @param {string} url - The address, as the link pass resolved it.
|
|
1139
|
+
* @param {string} [site] - The absolute address this book's pages are served at.
|
|
1140
|
+
* @returns {string} An absolute URL, or the address unchanged when there is no
|
|
1141
|
+
* site to resolve it against or it is already absolute.
|
|
1142
|
+
*/
|
|
1143
|
+
export function absolutePageUrl(url, site) {
|
|
1144
|
+
const address = String(url ?? "");
|
|
1145
|
+
if (!site || !address.startsWith("/") || address.startsWith("//")) return address;
|
|
1146
|
+
try {
|
|
1147
|
+
return new URL(address, site).href;
|
|
1148
|
+
} catch {
|
|
1149
|
+
return address;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
786
1153
|
/**
|
|
787
1154
|
* A link, internal when the book prints its destination and external otherwise.
|
|
788
1155
|
*
|
|
@@ -816,7 +1183,7 @@ function renderLink(href, inner, ctx) {
|
|
|
816
1183
|
return `#link(<${target}>)[${inner}]`;
|
|
817
1184
|
}
|
|
818
1185
|
if (!url) return inner;
|
|
819
|
-
return `#link("${escapeTypstString(url)}")[${inner}]`;
|
|
1186
|
+
return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
|
|
820
1187
|
}
|
|
821
1188
|
|
|
822
1189
|
/**
|
|
@@ -863,10 +1230,16 @@ function renderLink(href, inner, ctx) {
|
|
|
863
1230
|
*/
|
|
864
1231
|
function renderImage(token, ctx) {
|
|
865
1232
|
const alt = token.content || token.attrGet?.("alt") || "";
|
|
866
|
-
|
|
1233
|
+
// Only a genuine figure caption labels the image inline — a caption
|
|
1234
|
+
// reclassified as prose, because something besides the image shares its
|
|
1235
|
+
// block, gets its label from the generic block-level caption print in
|
|
1236
|
+
// {@link markdownToTypst} instead, the same as a code or prose caption
|
|
1237
|
+
// always has. Reading it here regardless of kind printed the label twice.
|
|
1238
|
+
const figureCaption = ctx.caption?.kind === "figure" ? ctx.caption : null;
|
|
1239
|
+
const captionText = figureCaption ? captionMarkup(figureCaption, ctx) : escapeTypst(alt);
|
|
867
1240
|
const caption = captionText ? `[${captionText}]` : "none";
|
|
868
1241
|
const anchor =
|
|
869
|
-
|
|
1242
|
+
figureCaption ? ` <${sectionLabel(ctx.anchorPrefix, slugify(figureCaption.id))}>` : "";
|
|
870
1243
|
const staged = ctx.images.get(token.attrGet?.("src") ?? "");
|
|
871
1244
|
if (!staged)
|
|
872
1245
|
return captionText ?
|