@heroiclands/package-build 22.21.0 → 22.23.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 +223 -0
- package/assets/styles/content-base.css +162 -0
- package/bin/content-commands.mjs +77 -17
- package/bin/package-build.mjs +15 -2
- package/ci/content-format-reference.mjs +68 -1
- package/config.mjs +26 -0
- package/content-config.mjs +153 -6
- package/docs/api.md +120 -89
- package/docs/authoring/assets.md +14 -0
- package/docs/authoring/dates-and-calendars.md +41 -3
- package/docs/authoring/frontmatter.md +2 -2
- package/docs/authoring/links-and-markup.md +325 -35
- package/docs/commands.md +12 -10
- package/docs/configuration.md +117 -14
- package/docs/content-format.md +4 -2
- package/docs/guides/book.md +5 -4
- package/docs/guides/reference-pages.md +15 -0
- package/docs/reference/format-details.md +545 -104
- package/docs/reference/note-types.md +30 -13
- package/engine/actor-compiler.mjs +23 -0
- package/engine/anchored-sections.mjs +35 -4
- package/engine/anchors.mjs +26 -30
- package/engine/art-fields.mjs +118 -0
- package/engine/art-slots.mjs +7 -1
- package/engine/asset-index.mjs +160 -10
- package/engine/asset-replacement.mjs +93 -0
- package/engine/asset-types.mjs +240 -0
- package/engine/base-compiler.mjs +139 -22
- package/engine/base-styles.mjs +149 -0
- package/engine/bundles.mjs +13 -2
- package/engine/compendiums.mjs +8 -1
- package/engine/content-blocks.mjs +410 -0
- package/engine/content-embeds.mjs +19 -0
- package/engine/content-figures.mjs +468 -0
- package/engine/content-footnotes.mjs +197 -0
- package/engine/content-format-check.mjs +15 -12
- package/engine/content-format.yaml +1 -1
- package/engine/content-images.mjs +126 -25
- package/engine/content-index.mjs +38 -12
- 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/field-reference.mjs +31 -10
- package/engine/field-spec.mjs +10 -8
- package/engine/foreign-catalog.mjs +143 -18
- package/engine/frontmatter-lint.mjs +120 -65
- package/engine/frontmatter.mjs +3 -3
- package/engine/heading-attributes.mjs +321 -0
- package/engine/helpers.mjs +109 -41
- package/engine/homepage.mjs +8 -8
- package/engine/index.mjs +3 -0
- package/engine/infobox-registry.mjs +4 -1
- package/engine/infobox-render.mjs +15 -3
- package/engine/infobox.mjs +129 -15
- package/engine/itinerary-scenes.mjs +4 -10
- package/engine/journals.mjs +294 -50
- package/engine/lore-events.mjs +197 -0
- package/engine/macros.mjs +1 -1
- package/engine/markdown-expressions.mjs +434 -59
- package/engine/note-dates.mjs +354 -12
- package/engine/note-frontmatter.mjs +7 -16
- package/engine/note-vocabulary.mjs +183 -42
- package/engine/page-lists.mjs +358 -0
- package/engine/pathnames.mjs +36 -0
- package/engine/pdf-build.mjs +431 -74
- package/engine/pdf-images.mjs +64 -0
- package/engine/pdf-render.mjs +563 -66
- 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 +26 -7
- package/engine/site-build.mjs +166 -23
- package/engine/site-config.mjs +10 -1
- package/engine/site-index.mjs +90 -3
- package/engine/site-preview.mjs +20 -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 +56 -30
- package/engine/web-wikilinks.mjs +48 -1
- package/hm3/actors.mjs +8 -5
- package/manifest.mjs +43 -24
- package/package.json +1 -1
- package/packagebuild-metadata.jsonl +13 -13
- package/sohl/actors.mjs +22 -99
- package/sohl/item-fields.mjs +5 -5
- package/sohl/items.mjs +1 -1
- package/sohl/note-schemas.mjs +11 -21
- package/types/config.d.mts +9 -0
- package/types/content-config.d.mts +48 -1
- package/types/engine/actor-compiler.d.mts +11 -0
- package/types/engine/anchored-sections.d.mts +24 -0
- package/types/engine/anchors.d.mts +9 -4
- package/types/engine/art-fields.d.mts +64 -0
- package/types/engine/asset-index.d.mts +43 -0
- package/types/engine/asset-replacement.d.mts +22 -0
- package/types/engine/asset-types.d.mts +18 -0
- package/types/engine/base-compiler.d.mts +19 -17
- package/types/engine/base-styles.d.mts +93 -0
- package/types/engine/content-blocks.d.mts +87 -0
- package/types/engine/content-embeds.d.mts +16 -0
- package/types/engine/content-figures.d.mts +101 -0
- package/types/engine/content-footnotes.d.mts +80 -0
- package/types/engine/content-format-check.d.mts +8 -6
- package/types/engine/content-images.d.mts +78 -12
- package/types/engine/content-index.d.mts +8 -2
- 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/field-reference.d.mts +21 -0
- package/types/engine/field-spec.d.mts +20 -16
- package/types/engine/foreign-catalog.d.mts +39 -0
- package/types/engine/frontmatter.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +179 -0
- package/types/engine/helpers.d.mts +29 -10
- package/types/engine/homepage.d.mts +7 -7
- package/types/engine/index.d.mts +1 -0
- package/types/engine/infobox-registry.d.mts +4 -1
- package/types/engine/infobox-render.d.mts +6 -1
- package/types/engine/infobox.d.mts +6 -0
- package/types/engine/journals.d.mts +41 -12
- package/types/engine/lore-events.d.mts +14 -0
- package/types/engine/markdown-expressions.d.mts +46 -2
- package/types/engine/note-dates.d.mts +64 -1
- 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/pathnames.d.mts +26 -0
- package/types/engine/pdf-build.d.mts +28 -6
- package/types/engine/pdf-images.d.mts +52 -0
- package/types/engine/pdf-render.d.mts +84 -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 +27 -4
- package/types/engine/site-config.d.mts +9 -0
- package/types/engine/site-index.d.mts +67 -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 +37 -14
- package/types/engine/web-wikilinks.d.mts +3 -1
- package/types/hm3/actors.d.mts +21 -3
- package/types/manifest.d.mts +19 -23
- package/types/sohl/actors.d.mts +6 -7
- package/engine/content-admonitions.mjs +0 -114
- package/engine/content-captions.mjs +0 -181
- package/engine/content-secrets.mjs +0 -112
- package/types/engine/content-admonitions.d.mts +0 -24
- package/types/engine/content-captions.d.mts +0 -45
- package/types/engine/content-secrets.d.mts +0 -17
package/engine/pdf-render.mjs
CHANGED
|
@@ -67,7 +67,14 @@ import deflistPlugin from "markdown-it-deflist";
|
|
|
67
67
|
|
|
68
68
|
import { bookDraftNoticePreamble } from "./draft-notice.mjs";
|
|
69
69
|
import { iconPlugin, ICON_PATTERN, ICON_SIZES } from "./content-icons.mjs";
|
|
70
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
IMAGE_CLASSES,
|
|
72
|
+
IMAGE_FLOATS,
|
|
73
|
+
IMAGE_PATTERN,
|
|
74
|
+
checkImages,
|
|
75
|
+
imagePlugin,
|
|
76
|
+
} from "./content-images.mjs";
|
|
77
|
+
import { bookImageWidthIn } from "./pdf-images.mjs";
|
|
71
78
|
|
|
72
79
|
/** Requested print width for every named image size. */
|
|
73
80
|
export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
@@ -79,9 +86,38 @@ export const BOOK_IMAGE_WIDTHS = Object.freeze({
|
|
|
79
86
|
"full-width": '"full-width"',
|
|
80
87
|
});
|
|
81
88
|
import { slugify } from "./content-slug.mjs";
|
|
82
|
-
import {
|
|
83
|
-
import {
|
|
84
|
-
import {
|
|
89
|
+
import { scanFigures } from "./content-figures.mjs";
|
|
90
|
+
import { BLOCK_CONTAINERS, scanBlocks, BLOCK_NAMES } from "./content-blocks.mjs";
|
|
91
|
+
import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
|
|
92
|
+
import { HTML_TAG, htmlMessage } from "./content-html.mjs";
|
|
93
|
+
import { EXPRESSION } from "./markdown-expressions.mjs";
|
|
94
|
+
import { WIKILINK } from "./wikilink-syntax.mjs";
|
|
95
|
+
import { EMBED_PATTERN } from "./content-embeds.mjs";
|
|
96
|
+
import { positionInBody } from "./diagnostics.mjs";
|
|
97
|
+
import {
|
|
98
|
+
WITHHELD_CLASS,
|
|
99
|
+
scanHeadingAttributes,
|
|
100
|
+
splitHeadingAttributes,
|
|
101
|
+
withheldSections,
|
|
102
|
+
} from "./heading-attributes.mjs";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How each named block prints. Typst has no stylesheet to defer to, so the
|
|
106
|
+
* colours live here rather than in the emitted markup; the HTML surfaces carry
|
|
107
|
+
* a class and let CSS decide.
|
|
108
|
+
*/
|
|
109
|
+
const BLOCK_PRINT = Object.freeze({
|
|
110
|
+
info: Object.freeze({ color: "#2f6f9f", background: "#eef6fb", symbol: "i" }),
|
|
111
|
+
secret: Object.freeze({ color: "#5c4b8a", background: "#f2eefb", symbol: "!" }),
|
|
112
|
+
warn: Object.freeze({ color: "#9a6700", background: "#fff5db", symbol: "!" }),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
import {
|
|
116
|
+
misplacedFootnoteDefinitions,
|
|
117
|
+
separateFootnotes,
|
|
118
|
+
unresolvedFootnoteReferences,
|
|
119
|
+
unusedFootnoteDefinitions,
|
|
120
|
+
} from "./content-footnotes.mjs";
|
|
85
121
|
|
|
86
122
|
/**
|
|
87
123
|
* Characters that mean something to Typst's markup parser.
|
|
@@ -168,6 +204,210 @@ export function createParser(registry) {
|
|
|
168
204
|
return md;
|
|
169
205
|
}
|
|
170
206
|
|
|
207
|
+
/**
|
|
208
|
+
* A closed HTML comment, however many lines it spans.
|
|
209
|
+
*
|
|
210
|
+
* Closed only: an unclosed `<!--` is text by CommonMark's reading, and a
|
|
211
|
+
* pattern that ran to the end of the file for want of a closer would delete
|
|
212
|
+
* every line after the mistake. {@link reportUnrenderable} names it instead.
|
|
213
|
+
*
|
|
214
|
+
* @type {RegExp}
|
|
215
|
+
*/
|
|
216
|
+
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A markdown body with its HTML comments taken out.
|
|
220
|
+
*
|
|
221
|
+
* A comment is an author's aside to the next author — a `markdownlint` pragma, a
|
|
222
|
+
* note recording why a notice is quoted verbatim — and no surface shows one: the
|
|
223
|
+
* packs and the website emit it into HTML, where it is a comment still. Typst
|
|
224
|
+
* has nowhere to put it, so markdown-it hands the delimiters over as prose and
|
|
225
|
+
* the aside is typeset. It comes out here instead, and a comment inside a fence
|
|
226
|
+
* or a code span stays, because there it is an example of a comment.
|
|
227
|
+
*
|
|
228
|
+
* **The line count is preserved.** Every finding this module reports is located
|
|
229
|
+
* in the markdown it was handed, so a pass that removed a line would move every
|
|
230
|
+
* position after it.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} markdown - The body, as authored.
|
|
233
|
+
* @returns {string} The same body, its comments replaced by their own newlines.
|
|
234
|
+
*/
|
|
235
|
+
export function stripHtmlComments(markdown) {
|
|
236
|
+
return replaceOutsideCode(String(markdown ?? ""), HTML_COMMENT, (comment) =>
|
|
237
|
+
"\n".repeat((comment.match(/\n/g) ?? []).length),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Where a position in the markdown handed in falls in the file that holds it.
|
|
243
|
+
*
|
|
244
|
+
* The three corrections {@link module:engine/diagnostics.positionInBody} makes,
|
|
245
|
+
* applied to a line and column rather than to an offset: the frontmatter above
|
|
246
|
+
* the body, the indentation a trimmed first line lost, and a line that a
|
|
247
|
+
* content table generated and no author wrote — which has no column to point at.
|
|
248
|
+
*
|
|
249
|
+
* @param {number} line - 1-based line within the markdown.
|
|
250
|
+
* @param {number} column - 1-based column within that line.
|
|
251
|
+
* @param {object} [opts] - As {@link markdownToTypst} takes.
|
|
252
|
+
* @returns {{line: number, column?: number}} The position, in the file.
|
|
253
|
+
*/
|
|
254
|
+
function linePosition(line, column, { bodyLine = 1, bodyColumn = 1, lineMap } = {}) {
|
|
255
|
+
const mapped = lineMap?.[line - 1];
|
|
256
|
+
const sourceLine = mapped ? mapped.line : line - 1;
|
|
257
|
+
if (mapped?.generated) return { line: bodyLine + sourceLine };
|
|
258
|
+
return {
|
|
259
|
+
line: bodyLine + sourceLine,
|
|
260
|
+
column: sourceLine === 0 ? bodyColumn + column - 1 : column,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Where a character offset within the markdown handed in falls in its file.
|
|
266
|
+
*
|
|
267
|
+
* @param {string} source - The markdown the offset indexes into.
|
|
268
|
+
* @param {number} offset - 0-based character offset.
|
|
269
|
+
* @param {object} [opts] - As {@link markdownToTypst} takes.
|
|
270
|
+
* @returns {{line: number, column?: number}} The position, in the file.
|
|
271
|
+
*/
|
|
272
|
+
function offsetPosition(source, offset, opts = {}) {
|
|
273
|
+
const { line, column, generated } = positionInBody(source, offset, opts);
|
|
274
|
+
return generated ? { line } : { line, column };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Everything in one body that the book cannot set, reported.
|
|
279
|
+
*
|
|
280
|
+
* **The renderer owes a finding for every construct it meets and cannot
|
|
281
|
+
* render.** It emits text and reads nothing, so there is no state it can refuse
|
|
282
|
+
* from; what it can do is say what it met, at the line it met it, and let the
|
|
283
|
+
* caller decide. Without that a body carrying a construct this pass does not
|
|
284
|
+
* handle is typeset as its own markup — delimiters, braces and all — and the
|
|
285
|
+
* build exits 0 having printed the mistake into the book.
|
|
286
|
+
*
|
|
287
|
+
* Two families are named here, and they fail for opposite reasons:
|
|
288
|
+
*
|
|
289
|
+
* - **A construct no book renderer reads.** Raw HTML, and an HTML comment that
|
|
290
|
+
* is never closed. Both reach Typst as prose, and the first is reported in
|
|
291
|
+
* {@link module:engine/content-html}'s own words so one authored tag does not
|
|
292
|
+
* get two explanations.
|
|
293
|
+
* - **A pass's output that never arrived.** A wikilink, an embed and an inline
|
|
294
|
+
* `{{…}}` expression are each resolved before the book sees them; one that is
|
|
295
|
+
* still in the markdown means the pass that owns it did not run over this
|
|
296
|
+
* body, and the markup is about to be typeset verbatim. Reported only where
|
|
297
|
+
* those passes did not run — `opts.prepared` — because each of them leaves the
|
|
298
|
+
* markup as written when it fails and has already said so in its own words.
|
|
299
|
+
*
|
|
300
|
+
* A `:::` block and a `:::figure` are the pair this pass does handle, and their
|
|
301
|
+
* own scanners decide what is well formed; their findings are reported here so
|
|
302
|
+
* that a front-matter file, a prose file and a note body all get them.
|
|
303
|
+
*
|
|
304
|
+
* @param {string} source - The body, comments already stripped.
|
|
305
|
+
* @param {string} definitions - The footnote definitions separated from it.
|
|
306
|
+
* @param {object} opts - As {@link markdownToTypst} takes.
|
|
307
|
+
* @param {string} original - `source` before its definitions were blanked —
|
|
308
|
+
* {@link unusedFootnoteDefinitions} locates a definition by searching for
|
|
309
|
+
* its own text, which `source` no longer carries.
|
|
310
|
+
* @returns {void}
|
|
311
|
+
*/
|
|
312
|
+
function reportUnrenderable(source, definitions, opts, original) {
|
|
313
|
+
const findings = opts.findings;
|
|
314
|
+
if (!findings) return;
|
|
315
|
+
const file = opts.file ? { file: opts.file } : {};
|
|
316
|
+
/**
|
|
317
|
+
* @param {{line: number, column?: number}} position - Where it is.
|
|
318
|
+
* @param {"warning"|"error"} severity - Which level.
|
|
319
|
+
* @param {string} message - What is wrong.
|
|
320
|
+
* @returns {void}
|
|
321
|
+
*/
|
|
322
|
+
const report = (position, severity, message) => {
|
|
323
|
+
findings.push({ ...file, ...position, severity, message });
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const blocks = scanBlocks(source);
|
|
327
|
+
for (const error of blocks.errors)
|
|
328
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
329
|
+
// A container's body, read for faults of its own — an empty box, an
|
|
330
|
+
// attribute that does not parse — which the scan above passes over, because
|
|
331
|
+
// there an inner opener is a counted line rather than a block.
|
|
332
|
+
//
|
|
333
|
+
// Only where that reading was sound. One mistake draws several findings from
|
|
334
|
+
// a single scan already, and a body read from a misread outer block draws
|
|
335
|
+
// more that say the same thing in other words. One level down and no
|
|
336
|
+
// further: a box inside a GM-only section is the only nesting the format
|
|
337
|
+
// admits.
|
|
338
|
+
const lines = source.split("\n");
|
|
339
|
+
if (!blocks.errors.length) {
|
|
340
|
+
for (const block of blocks.blocks) {
|
|
341
|
+
if (!BLOCK_CONTAINERS.includes(block.name)) continue;
|
|
342
|
+
const body = lines.slice(block.start + 1, block.end).join("\n");
|
|
343
|
+
for (const error of scanBlocks(body).errors)
|
|
344
|
+
report(
|
|
345
|
+
linePosition(block.start + 1 + error.line, error.column, opts),
|
|
346
|
+
"error",
|
|
347
|
+
error.message,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const error of scanFigures(source).errors)
|
|
352
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
353
|
+
// An image sharing its paragraph with other text, or an address or title
|
|
354
|
+
// the website's own lint already refuses — asked here too, so the book
|
|
355
|
+
// fails on the same input rather than typesetting the directive as
|
|
356
|
+
// though it were absent. `checkImages` resolves `opts.bodyLine` and
|
|
357
|
+
// `opts.bodyColumn` itself.
|
|
358
|
+
for (const error of checkImages(source, opts.file ?? "", opts))
|
|
359
|
+
report({ line: error.line, column: error.column }, error.severity, error.message);
|
|
360
|
+
for (const error of scanHeadingAttributes(source).errors)
|
|
361
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
362
|
+
for (const error of withheldSections(source).errors)
|
|
363
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
364
|
+
|
|
365
|
+
// Every closed comment is gone by the time this runs, so an opener still
|
|
366
|
+
// here is one that was never closed — on the website that swallows the rest
|
|
367
|
+
// of the page, and here it is typeset.
|
|
368
|
+
for (const match of matchAllOutsideCode(source, /<!--/g))
|
|
369
|
+
report(
|
|
370
|
+
offsetPosition(source, match.index, opts),
|
|
371
|
+
"error",
|
|
372
|
+
"an HTML comment is opened and never closed — close it with `-->`",
|
|
373
|
+
);
|
|
374
|
+
for (const match of matchAllOutsideCode(source, HTML_TAG))
|
|
375
|
+
report(offsetPosition(source, match.index, opts), "warning", htmlMessage(match[0]));
|
|
376
|
+
if (!opts.prepared) {
|
|
377
|
+
for (const match of matchAllOutsideCode(source, EMBED_PATTERN))
|
|
378
|
+
report(
|
|
379
|
+
offsetPosition(source, match.index, opts),
|
|
380
|
+
"error",
|
|
381
|
+
`\`${match[0]}\` reaches the book as written — an embed is resolved to a ` +
|
|
382
|
+
"picture before the book is set, and nothing resolved this one",
|
|
383
|
+
);
|
|
384
|
+
for (const match of matchAllOutsideCode(source, WIKILINK))
|
|
385
|
+
report(
|
|
386
|
+
offsetPosition(source, match.index, opts),
|
|
387
|
+
"error",
|
|
388
|
+
`\`${match[0]}\` reaches the book as written — a link is resolved to an ` +
|
|
389
|
+
"address before the book is set, and nothing resolved this one",
|
|
390
|
+
);
|
|
391
|
+
for (const match of matchAllOutsideCode(source, EXPRESSION))
|
|
392
|
+
report(
|
|
393
|
+
offsetPosition(source, match.index, opts),
|
|
394
|
+
"error",
|
|
395
|
+
`\`${match[0]}\` reaches the book as written — an expression is evaluated ` +
|
|
396
|
+
"before the book is set, and nothing evaluated this one",
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
// Derived from the one place that already finds definitions, rather than
|
|
400
|
+
// a second scanner that could drift from {@link separateFootnotes} — the
|
|
401
|
+
// web and Foundry report the identical three findings from the identical
|
|
402
|
+
// functions.
|
|
403
|
+
for (const error of misplacedFootnoteDefinitions(source))
|
|
404
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
405
|
+
for (const error of unresolvedFootnoteReferences(source, definitions))
|
|
406
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
407
|
+
for (const error of unusedFootnoteDefinitions(original))
|
|
408
|
+
report(linePosition(error.line, error.column, opts), "error", error.message);
|
|
409
|
+
}
|
|
410
|
+
|
|
171
411
|
/**
|
|
172
412
|
* Render markdown as Typst content.
|
|
173
413
|
*
|
|
@@ -182,12 +422,36 @@ export function createParser(registry) {
|
|
|
182
422
|
* the staged file's path, relative to the `.typ`. An address this does not
|
|
183
423
|
* carry has no file the compiler can open, so the figure prints its caption
|
|
184
424
|
* alone — see {@link renderImage}.
|
|
425
|
+
* @param {Map<string, {type: string, role?: string, width: number|"", height: number|""}>}
|
|
426
|
+
* [opts.assets] - An image's address as authored → what
|
|
427
|
+
* {@link module:engine/art-fields.assetImageInfoByPathname} records for it,
|
|
428
|
+
* which is what sizes a picture carrying no named `size=` — see
|
|
429
|
+
* {@link module:engine/pdf-images.bookImageWidthIn}. An address missing from
|
|
430
|
+
* this map draws at the medium's ordinary size, the same as today.
|
|
185
431
|
* @param {number} [opts.headingOffset] - Added to every heading level, so a
|
|
186
432
|
* note's own `##` nests beneath the entry heading the book gave it.
|
|
187
433
|
* @param {string} [opts.anchorPrefix] - The entry's anchor, which namespaces
|
|
188
434
|
* every `{#slug}` the body declares.
|
|
189
|
-
*
|
|
190
|
-
*
|
|
435
|
+
* @param {object[]} [opts.findings] - Collected here rather than thrown. Every
|
|
436
|
+
* construct this pass cannot render is reported into it — see
|
|
437
|
+
* {@link reportUnrenderable} — and a caller that passes none renders the same
|
|
438
|
+
* Typst and is told nothing.
|
|
439
|
+
* @param {string} [opts.file] - The file the markdown came from, named in every
|
|
440
|
+
* finding.
|
|
441
|
+
* @param {number} [opts.bodyLine] - The 1-based file line the body starts on, so
|
|
442
|
+
* a finding's line is the file's rather than the body's.
|
|
443
|
+
* @param {number} [opts.bodyColumn] - The 1-based column the body starts at.
|
|
444
|
+
* @param {Array<{line: number, generated: boolean}>} [opts.lineMap] - From
|
|
445
|
+
* table expansion, mapping each line handed in back to the line an author
|
|
446
|
+
* wrote.
|
|
447
|
+
* @param {boolean} [opts.prepared] - Whether this body has been through the
|
|
448
|
+
* passes that resolve a wikilink, an embed and an inline expression. A note's
|
|
449
|
+
* has; a front-matter file's and a prose file's have not, and markup of theirs
|
|
450
|
+
* left in one is reported rather than typeset in silence.
|
|
451
|
+
* @param {string} [opts.url] - The absolute address this book's pages are served
|
|
452
|
+
* at. Every link the book sets as a URL is resolved against it, because a
|
|
453
|
+
* reader holding a PDF has no page to resolve a path against — see
|
|
454
|
+
* {@link renderLink}.
|
|
191
455
|
* @returns {string} Typst markup.
|
|
192
456
|
*/
|
|
193
457
|
export function markdownToTypst(markdown, opts = {}) {
|
|
@@ -196,17 +460,73 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
196
460
|
links = new Map(),
|
|
197
461
|
glyphs = new Map(),
|
|
198
462
|
images = new Map(),
|
|
463
|
+
assets = new Map(),
|
|
199
464
|
headingOffset = 0,
|
|
200
465
|
anchorPrefix = "",
|
|
201
466
|
} = opts;
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
const source = separated?.markdown ??
|
|
467
|
+
const given = opts.nested ? String(markdown ?? "") : stripHtmlComments(markdown);
|
|
468
|
+
const separated = opts.footnoteDefinitions === undefined ? separateFootnotes(given) : null;
|
|
469
|
+
const source = separated?.markdown ?? given;
|
|
205
470
|
const definitions = opts.footnoteDefinitions ?? separated?.definitions ?? "";
|
|
206
471
|
const footnoteState = opts.footnoteState ?? { seen: new Set() };
|
|
207
|
-
const sharedOptions = {
|
|
472
|
+
const sharedOptions = {
|
|
473
|
+
...opts,
|
|
474
|
+
footnoteDefinitions: definitions,
|
|
475
|
+
footnoteState,
|
|
476
|
+
nested: true,
|
|
477
|
+
};
|
|
478
|
+
// Read once, over the body as it arrived: every position a finding carries
|
|
479
|
+
// is an offset into *this* string, and the renderer below walks slices of it.
|
|
480
|
+
if (!opts.nested) reportUnrenderable(source, definitions, opts, given);
|
|
481
|
+
// One map for the whole body, not one per block: a heading inside a
|
|
482
|
+
// blockquote or a list item shares the entry's anchor namespace with every
|
|
483
|
+
// other heading in the same body, because `sectionLabel` scopes by entry
|
|
484
|
+
// rather than by container.
|
|
485
|
+
const ctx = {
|
|
486
|
+
md,
|
|
487
|
+
links,
|
|
488
|
+
glyphs,
|
|
489
|
+
images,
|
|
490
|
+
assets,
|
|
491
|
+
headingOffset,
|
|
492
|
+
anchorPrefix,
|
|
493
|
+
url: opts.url,
|
|
494
|
+
seen: new Map(),
|
|
495
|
+
footnoteState,
|
|
496
|
+
footnotePrefix: opts.footnotePrefix ?? `footnote-${slugify(anchorPrefix || "body")}`,
|
|
497
|
+
};
|
|
498
|
+
// A withheld section is set in the box a `:::secret` is set in, and its
|
|
499
|
+
// heading is set inside it: a printed page honours no reader permission, so
|
|
500
|
+
// the labelled box is what tells a referee the section is for them. Read
|
|
501
|
+
// outermost, before the named blocks, because a section holds blocks of its
|
|
502
|
+
// own and they are rendered by the recursion into its body.
|
|
503
|
+
if (!opts.insideAdmonition && !opts.insideWithheld) {
|
|
504
|
+
const { sections } = withheldSections(source);
|
|
505
|
+
if (sections.length) {
|
|
506
|
+
const lines = source.split("\n");
|
|
507
|
+
const output = [];
|
|
508
|
+
let cursor = 0;
|
|
509
|
+
for (const section of sections) {
|
|
510
|
+
output.push(
|
|
511
|
+
markdownToTypst(lines.slice(cursor, section.start).join("\n"), sharedOptions),
|
|
512
|
+
);
|
|
513
|
+
output.push(
|
|
514
|
+
withheldBox(
|
|
515
|
+
markdownToTypst(lines.slice(section.start, section.end).join("\n"), {
|
|
516
|
+
...sharedOptions,
|
|
517
|
+
insideWithheld: true,
|
|
518
|
+
}),
|
|
519
|
+
ctx,
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
cursor = section.end;
|
|
523
|
+
}
|
|
524
|
+
output.push(markdownToTypst(lines.slice(cursor).join("\n"), sharedOptions));
|
|
525
|
+
return output.join("\n");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
208
528
|
if (!opts.insideAdmonition) {
|
|
209
|
-
const { blocks } =
|
|
529
|
+
const { blocks } = scanBlocks(source);
|
|
210
530
|
if (blocks.length) {
|
|
211
531
|
const lines = source.split("\n");
|
|
212
532
|
const output = [];
|
|
@@ -218,13 +538,18 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
218
538
|
insideAdmonition: true,
|
|
219
539
|
}),
|
|
220
540
|
);
|
|
221
|
-
const color = block.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
541
|
+
const { color, background, symbol } = BLOCK_PRINT[block.name];
|
|
542
|
+
// The title is inline markdown, rendered the way every other
|
|
543
|
+
// phrase in the book is. Set as it was written it would be read
|
|
544
|
+
// by Typst instead: `*word*` is bold there and emphasis here, a
|
|
545
|
+
// `#` opens a function call, and one unbalanced `]` ends the box
|
|
546
|
+
// and takes the rest of the document with it.
|
|
547
|
+
const label = inlineMarkup(block.title, ctx);
|
|
548
|
+
// A GM-only section holds a box, so its body is read for blocks
|
|
549
|
+
// of its own; a box holds none, and the scan has already said so.
|
|
225
550
|
const content = markdownToTypst(block.body, {
|
|
226
551
|
...sharedOptions,
|
|
227
|
-
insideAdmonition:
|
|
552
|
+
insideAdmonition: !BLOCK_CONTAINERS.includes(block.name),
|
|
228
553
|
});
|
|
229
554
|
output.push(
|
|
230
555
|
`\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 +565,20 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
240
565
|
return output.join("\n");
|
|
241
566
|
}
|
|
242
567
|
}
|
|
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
568
|
const lines = source.split("\n");
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
569
|
+
const localFigures = scanFigures(source, { resolveRole: opts.resolveRole }).figures;
|
|
570
|
+
// Numbered by the caller, which counts across the whole book, and placed by
|
|
571
|
+
// the scan above, which is the only reading of *this* body. Paired by
|
|
572
|
+
// position: two figures may share an id — a finding, and the book is built
|
|
573
|
+
// anyway — and keying on one would give the second figure's lines to the
|
|
574
|
+
// first, printing its contents twice and losing its label.
|
|
575
|
+
const provided = opts.captions ?? localFigures;
|
|
576
|
+
const numbered = new Map(provided.map((figure) => [figure.id, figure]));
|
|
577
|
+
const captions = localFigures.map((figure, at) => {
|
|
578
|
+
const counted =
|
|
579
|
+
provided[at]?.id === figure.id ? provided[at] : (numbered.get(figure.id) ?? figure);
|
|
580
|
+
return { ...figure, label: counted.label, number: counted.number };
|
|
581
|
+
});
|
|
264
582
|
if (!captions.length) return renderMarkdownSegment(source, md, ctx, definitions);
|
|
265
583
|
const out = [];
|
|
266
584
|
let cursor = 0;
|
|
@@ -273,14 +591,29 @@ export function markdownToTypst(markdown, opts = {}) {
|
|
|
273
591
|
definitions,
|
|
274
592
|
),
|
|
275
593
|
);
|
|
276
|
-
const block = lines.slice(caption.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
594
|
+
const block = lines.slice(caption.bodyStart, caption.bodyEnd).join("\n");
|
|
595
|
+
// A grouped figure's images are rendered through the same call that
|
|
596
|
+
// reads `ctx.caption`, so `renderImage` needs to know which one is
|
|
597
|
+
// last — see the counter it decrements, set only for a `figure`-kind
|
|
598
|
+
// fence, where more than one image can share the one caption.
|
|
599
|
+
const captioned =
|
|
600
|
+
caption.kind === "figure" || caption.kind === "map" ?
|
|
601
|
+
{ ...caption, imagesRemaining: { n: countImages(block) } }
|
|
602
|
+
: caption;
|
|
603
|
+
const segments = [
|
|
604
|
+
renderMarkdownSegment(block, md, { ...ctx, caption: captioned }, definitions),
|
|
605
|
+
];
|
|
606
|
+
if (caption.kind !== "table" && caption.kind !== "figure" && caption.kind !== "map") {
|
|
607
|
+
segments.push(
|
|
608
|
+
`\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]]${typstAnchor(anchorPrefix, caption)}\n\n`,
|
|
281
609
|
);
|
|
282
610
|
}
|
|
283
|
-
|
|
611
|
+
out.push(
|
|
612
|
+
caption.classes.includes("border") ?
|
|
613
|
+
figureBorder(segments.join(""))
|
|
614
|
+
: segments.join(""),
|
|
615
|
+
);
|
|
616
|
+
cursor = caption.close + 1;
|
|
284
617
|
}
|
|
285
618
|
out.push(renderMarkdownSegment(lines.slice(cursor).join("\n"), md, ctx, definitions));
|
|
286
619
|
return out.filter(Boolean).join("\n\n");
|
|
@@ -428,12 +761,29 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
428
761
|
}
|
|
429
762
|
|
|
430
763
|
/**
|
|
431
|
-
* A
|
|
764
|
+
* A withheld section, set in the box a `secret` block is set in.
|
|
765
|
+
*
|
|
766
|
+
* @param {string} content - The section's rendered Typst, heading included.
|
|
767
|
+
* @param {object} ctx - Render context, for the label's inline markup.
|
|
768
|
+
* @returns {string} Typst markup.
|
|
769
|
+
*/
|
|
770
|
+
function withheldBox(content, ctx) {
|
|
771
|
+
const { color, background, symbol } = BLOCK_PRINT[WITHHELD_CLASS];
|
|
772
|
+
const label = inlineMarkup(BLOCK_NAMES[WITHHELD_CLASS], ctx);
|
|
773
|
+
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`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* A heading's text, and the attribute block it may end with.
|
|
432
778
|
*
|
|
433
779
|
* The suffix is removed from the *rendered* children rather than from the raw
|
|
434
780
|
* source, so an anchor written inside emphasis or after a link still comes off
|
|
435
781
|
* cleanly and the text either side of it survives.
|
|
436
782
|
*
|
|
783
|
+
* A class and an attribute have nowhere to go in Typst, which has no
|
|
784
|
+
* stylesheet, so the id is what the book takes and the rest is dropped — the
|
|
785
|
+
* reference says so per surface.
|
|
786
|
+
*
|
|
437
787
|
* @param {object} inline - The heading's `inline` token.
|
|
438
788
|
* @param {object} ctx - Render context.
|
|
439
789
|
* @returns {{text: string, anchor: string}} The heading, and its anchor or "".
|
|
@@ -441,31 +791,36 @@ function renderBlock(tokens, i, out, ctx) {
|
|
|
441
791
|
function splitHeadingAnchor(inline, ctx) {
|
|
442
792
|
const last = inline?.children?.[(inline.children?.length ?? 0) - 1];
|
|
443
793
|
const raw = last?.type === "text" ? String(last.content ?? "") : "";
|
|
444
|
-
const
|
|
445
|
-
if (!
|
|
794
|
+
const parsed = splitHeadingAttributes(raw);
|
|
795
|
+
if (!parsed.braces || parsed.problems.length) {
|
|
796
|
+
return { text: renderInline(inline, ctx), anchor: "" };
|
|
797
|
+
}
|
|
446
798
|
// Rendered with the suffix removed from a copy, so the token stream the
|
|
447
799
|
// caller owns is not mutated — the same tokens are walked again by the
|
|
448
800
|
// journals and the index.
|
|
449
801
|
const children = [...inline.children];
|
|
450
|
-
children[children.length - 1] = { ...last, content:
|
|
451
|
-
return { text: renderInline({ ...inline, children }, ctx), anchor:
|
|
802
|
+
children[children.length - 1] = { ...last, content: parsed.text };
|
|
803
|
+
return { text: renderInline({ ...inline, children }, ctx), anchor: parsed.id };
|
|
452
804
|
}
|
|
453
805
|
|
|
454
806
|
/**
|
|
455
|
-
* A heading's
|
|
807
|
+
* A heading's words, unescaped and without its attribute block.
|
|
456
808
|
*
|
|
457
809
|
* 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
|
-
*
|
|
810
|
+
* words as typed rather than the Typst-escaped text {@link splitHeadingAnchor}
|
|
811
|
+
* renders — {@link module:engine/content-slug.slugify} normalises punctuation
|
|
812
|
+
* and case itself and has no use for an escape backslash. The block comes off
|
|
813
|
+
* because a heading that declares classes alone would otherwise be addressed by
|
|
814
|
+
* an anchor naming them.
|
|
462
815
|
*
|
|
463
816
|
* @param {object} inline - The heading's `inline` token.
|
|
464
817
|
* @returns {string} The heading's raw text.
|
|
465
818
|
*/
|
|
466
819
|
function plainHeadingText(inline) {
|
|
467
820
|
const children = inline?.children ?? [];
|
|
468
|
-
|
|
821
|
+
const raw = children.map((child) => child.content ?? "").join("");
|
|
822
|
+
const parsed = splitHeadingAttributes(raw);
|
|
823
|
+
return parsed.problems.length ? raw : parsed.text;
|
|
469
824
|
}
|
|
470
825
|
|
|
471
826
|
/**
|
|
@@ -550,10 +905,80 @@ function listItems(tokens, start, end, ctx) {
|
|
|
550
905
|
return items;
|
|
551
906
|
}
|
|
552
907
|
|
|
553
|
-
/**
|
|
908
|
+
/**
|
|
909
|
+
* A phrase of inline Markdown, set the way the prose around it is.
|
|
910
|
+
*
|
|
911
|
+
* A caption, a named block's title: text an author writes outside a paragraph
|
|
912
|
+
* and still expects emphasis in. Rendering it through the same walk is also what
|
|
913
|
+
* makes it inert — Typst's own markup characters are escaped on the way, which
|
|
914
|
+
* handing the string over raw does not do.
|
|
915
|
+
*
|
|
916
|
+
* @param {string} text - The phrase, as authored.
|
|
917
|
+
* @param {object} ctx - Render context.
|
|
918
|
+
* @returns {string} Typst markup.
|
|
919
|
+
*/
|
|
920
|
+
function inlineMarkup(text, ctx) {
|
|
921
|
+
const inline = ctx.md.parseInline(String(text ?? ""), {})[0];
|
|
922
|
+
return inline ? renderInline(inline, ctx) : "";
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* A figure's label, with its caption when it carries one.
|
|
927
|
+
*
|
|
928
|
+
* The caption's inline Markdown is handled the way the surrounding prose is. A
|
|
929
|
+
* figure with no caption draws its label alone.
|
|
930
|
+
*
|
|
931
|
+
* @param {object} caption - A figure record.
|
|
932
|
+
* @param {object} ctx - Render context.
|
|
933
|
+
* @returns {string} Typst markup.
|
|
934
|
+
*/
|
|
554
935
|
function captionMarkup(caption, ctx) {
|
|
555
|
-
const
|
|
556
|
-
return `${
|
|
936
|
+
const label = escapeTypst(caption.label);
|
|
937
|
+
return caption.hasCaption ? `${label}: ${inlineMarkup(caption.caption, ctx)}` : label;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* How many markdown images a figure's contents carry.
|
|
942
|
+
*
|
|
943
|
+
* A grouped figure's `///` caption describes the whole plate, not any one
|
|
944
|
+
* picture in it, so {@link renderImage} needs to know which image is the
|
|
945
|
+
* last — the one that carries the group's single label and anchor — and this
|
|
946
|
+
* is the count it counts down from.
|
|
947
|
+
*
|
|
948
|
+
* @param {string} contents - The figure's captioned contents, as authored.
|
|
949
|
+
* @returns {number} How many images the contents hold.
|
|
950
|
+
*/
|
|
951
|
+
function countImages(contents) {
|
|
952
|
+
return (String(contents ?? "").match(IMAGE_PATTERN) ?? []).length;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* `.border` draws the whole figure — its content and its label together —
|
|
957
|
+
* inside a hairline box. A thin stroke is print's equivalent of a border and
|
|
958
|
+
* padding on the web and in a Foundry journal: a figure already sits inside
|
|
959
|
+
* its own column or float, so the book's version of a border is the lightest
|
|
960
|
+
* mark that still reads as one rather than a second, heavier frame around a
|
|
961
|
+
* frame.
|
|
962
|
+
*
|
|
963
|
+
* @param {string} body - The figure's own rendered Typst, content and label.
|
|
964
|
+
* @returns {string} Typst markup.
|
|
965
|
+
*/
|
|
966
|
+
function figureBorder(body) {
|
|
967
|
+
return `\n#block(width: 100%, stroke: 0.4pt + luma(60%), inset: 8pt, above: 0.6em, below: 0.6em)[\n${body}\n]\n\n`;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The Typst label a figure is referenced by, or `""` when it declares no id.
|
|
972
|
+
*
|
|
973
|
+
* A figure with no id is numbered and drawn and nothing addresses it, so it is
|
|
974
|
+
* given no label: an empty one would collide with every other idless figure.
|
|
975
|
+
*
|
|
976
|
+
* @param {string} prefix - The entry's anchor prefix.
|
|
977
|
+
* @param {object} caption - A figure record.
|
|
978
|
+
* @returns {string} A ` <label>` to append, or `""`.
|
|
979
|
+
*/
|
|
980
|
+
function typstAnchor(prefix, caption) {
|
|
981
|
+
return caption.id ? ` <${sectionLabel(prefix, slugify(caption.id))}>` : "";
|
|
557
982
|
}
|
|
558
983
|
|
|
559
984
|
/**
|
|
@@ -632,7 +1057,7 @@ function renderTable(tokens, ctx) {
|
|
|
632
1057
|
const caption = ctx.caption;
|
|
633
1058
|
const labelled =
|
|
634
1059
|
caption ?
|
|
635
|
-
`#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]
|
|
1060
|
+
`#text(size: 7.6pt, style: "italic")[${captionMarkup(caption, ctx)}]${typstAnchor(ctx.anchorPrefix, caption)}\n${drawn}`
|
|
636
1061
|
: drawn;
|
|
637
1062
|
// Wide content is given an explicit span rather than left to overflow the
|
|
638
1063
|
// measure: past three columns a table is set across the page, and
|
|
@@ -783,6 +1208,39 @@ function inlineRaw(content) {
|
|
|
783
1208
|
return `#raw("${escapeTypstString(content)}", block: false)`;
|
|
784
1209
|
}
|
|
785
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* A page address as a reader of the book can follow it.
|
|
1213
|
+
*
|
|
1214
|
+
* **Every link the book sets as a URL is absolute.** A page's address is written
|
|
1215
|
+
* from the root of the site that serves it — `/sohl/skill-guil/` — because on the
|
|
1216
|
+
* website that is the shortest form that is right wherever the page is read
|
|
1217
|
+
* from. A PDF is read from nowhere: a viewer handed a path has no document to
|
|
1218
|
+
* resolve it against, so it either does nothing or looks for a file on the
|
|
1219
|
+
* reader's own disk, and a book of a thousand pages carries a thousand links
|
|
1220
|
+
* that do neither.
|
|
1221
|
+
*
|
|
1222
|
+
* So a root-relative address is resolved against the site's own, which is the
|
|
1223
|
+
* one absolute address a build already holds. A **dependency's** page keeps its
|
|
1224
|
+
* own package prefix, from the registry in
|
|
1225
|
+
* {@link module:engine/content-address.PACKAGE_BASE}, so it resolves to the host
|
|
1226
|
+
* serving that package — and where the registry names a host of its own the
|
|
1227
|
+
* address is already absolute and is left exactly as it is.
|
|
1228
|
+
*
|
|
1229
|
+
* @param {string} url - The address, as the link pass resolved it.
|
|
1230
|
+
* @param {string} [site] - The absolute address this book's pages are served at.
|
|
1231
|
+
* @returns {string} An absolute URL, or the address unchanged when there is no
|
|
1232
|
+
* site to resolve it against or it is already absolute.
|
|
1233
|
+
*/
|
|
1234
|
+
export function absolutePageUrl(url, site) {
|
|
1235
|
+
const address = String(url ?? "");
|
|
1236
|
+
if (!site || !address.startsWith("/") || address.startsWith("//")) return address;
|
|
1237
|
+
try {
|
|
1238
|
+
return new URL(address, site).href;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return address;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
786
1244
|
/**
|
|
787
1245
|
* A link, internal when the book prints its destination and external otherwise.
|
|
788
1246
|
*
|
|
@@ -816,7 +1274,17 @@ function renderLink(href, inner, ctx) {
|
|
|
816
1274
|
return `#link(<${target}>)[${inner}]`;
|
|
817
1275
|
}
|
|
818
1276
|
if (!url) return inner;
|
|
819
|
-
return `#link("${escapeTypstString(url)}")[${inner}]`;
|
|
1277
|
+
return `#link("${escapeTypstString(absolutePageUrl(url, ctx.url))}")[${inner}]`;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* A width in inches, as a Typst length literal.
|
|
1282
|
+
*
|
|
1283
|
+
* @param {number} inches - The width.
|
|
1284
|
+
* @returns {string} A Typst length, rounded to a thousandth of an inch.
|
|
1285
|
+
*/
|
|
1286
|
+
function typstInches(inches) {
|
|
1287
|
+
return `${Math.round(inches * 1000) / 1000}in`;
|
|
820
1288
|
}
|
|
821
1289
|
|
|
822
1290
|
/**
|
|
@@ -854,26 +1322,55 @@ function renderLink(href, inner, ctx) {
|
|
|
854
1322
|
* `#image` on a path Typst cannot open is a compile error, and a compile error
|
|
855
1323
|
* in a 2,500-entry book is fatal at the very end of a run that otherwise
|
|
856
1324
|
* succeeded — over an illustration, which is the least important thing on the
|
|
857
|
-
* page. An address the build could not stage prints its caption alone
|
|
858
|
-
* and
|
|
1325
|
+
* page. An address the build could not stage prints its authored caption alone
|
|
1326
|
+
* instead, if it has one, and nothing at all if it does not; the build reports
|
|
1327
|
+
* the address it could not find either way.
|
|
1328
|
+
*
|
|
1329
|
+
* ## A grouped figure's one label belongs to the last picture
|
|
1330
|
+
*
|
|
1331
|
+
* Only a figure-kind fence labels an image inline — a fence holding anything
|
|
1332
|
+
* besides pictures is prose, and gets its label from the generic block-level
|
|
1333
|
+
* print in {@link markdownToTypst} instead, the same as a code or a table
|
|
1334
|
+
* fence. Within a figure-kind fence, `ctx.caption.imagesRemaining` counts down
|
|
1335
|
+
* one picture at a time; the picture that brings it to zero is the last one in
|
|
1336
|
+
* document order, and only that one carries the caption and the anchor. Every
|
|
1337
|
+
* other picture in the group draws with no caption at all. Attaching the label
|
|
1338
|
+
* to each of them would print the same text under every picture and emit the
|
|
1339
|
+
* same Typst label more than once, which Typst refuses to compile.
|
|
859
1340
|
*
|
|
860
1341
|
* @param {object} token - An `image` token.
|
|
861
1342
|
* @param {object} ctx - Render context.
|
|
862
1343
|
* @returns {string} Typst markup.
|
|
863
1344
|
*/
|
|
864
1345
|
function renderImage(token, ctx) {
|
|
865
|
-
const
|
|
866
|
-
|
|
1346
|
+
const figureCaption =
|
|
1347
|
+
ctx.caption?.kind === "figure" || ctx.caption?.kind === "map" ? ctx.caption : null;
|
|
1348
|
+
const remaining = figureCaption?.imagesRemaining;
|
|
1349
|
+
const isLastImage = !remaining || --remaining.n <= 0;
|
|
1350
|
+
const labelled = Boolean(figureCaption) && isLastImage;
|
|
1351
|
+
const captionText = labelled ? captionMarkup(figureCaption, ctx) : "";
|
|
867
1352
|
const caption = captionText ? `[${captionText}]` : "none";
|
|
868
|
-
const anchor =
|
|
869
|
-
|
|
870
|
-
const staged = ctx.images.get(
|
|
1353
|
+
const anchor = labelled ? typstAnchor(ctx.anchorPrefix, figureCaption) : "";
|
|
1354
|
+
const src = token.attrGet?.("src") ?? "";
|
|
1355
|
+
const staged = ctx.images.get(src);
|
|
871
1356
|
if (!staged)
|
|
872
1357
|
return captionText ?
|
|
873
1358
|
`\n#block(below: 0.6em)[#text(size: 7.6pt, style: "italic", fill: luma(45%))${caption}]${anchor}\n\n`
|
|
874
1359
|
: "";
|
|
875
1360
|
|
|
876
|
-
|
|
1361
|
+
// A named `size=` is the author's own statement and wins outright. Left
|
|
1362
|
+
// unstated — `auto`, written or implied — the picture's role or its icon
|
|
1363
|
+
// type decides instead, and only a picture declaring neither falls back to
|
|
1364
|
+
// the natural-pixel `auto` Typst resolves for itself.
|
|
1365
|
+
const requestedSize = token.meta?.size;
|
|
1366
|
+
const roleWidthIn =
|
|
1367
|
+
requestedSize && requestedSize !== "auto" ?
|
|
1368
|
+
undefined
|
|
1369
|
+
: bookImageWidthIn(ctx.assets?.get(src));
|
|
1370
|
+
const size =
|
|
1371
|
+
roleWidthIn !== undefined ?
|
|
1372
|
+
typstInches(roleWidthIn)
|
|
1373
|
+
: (BOOK_IMAGE_WIDTHS[requestedSize] ?? BOOK_IMAGE_WIDTHS.auto);
|
|
877
1374
|
const figure = `#book-image("${escapeTypstString(staged)}", requested: ${size}, caption: ${caption})${anchor}`;
|
|
878
1375
|
|
|
879
1376
|
const width = token.meta?.classes?.[0];
|