@heroiclands/package-build 24.0.0 → 24.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/assets/preview/gitea.ini +1 -1
  3. package/assets/styles/content-base.css +33 -0
  4. package/bin/content-commands.mjs +9 -7
  5. package/docs/api.md +6 -5
  6. package/docs/authoring/links-and-markup.md +164 -16
  7. package/docs/commands.md +23 -19
  8. package/docs/configuration.md +16 -0
  9. package/docs/content-format.md +4 -2
  10. package/docs/guides/site.md +43 -0
  11. package/docs/guides/writing-in-invented-scripts.md +68 -6
  12. package/docs/reference/format-details.md +143 -49
  13. package/docs/reference/note-types.md +47 -28
  14. package/docs/source-preview.md +2 -2
  15. package/engine/content-blocks.mjs +6 -2
  16. package/engine/content-format.yaml +2 -0
  17. package/engine/content-index.mjs +5 -1
  18. package/engine/content-render.mjs +2 -1
  19. package/engine/content-scripts.mjs +268 -21
  20. package/engine/content-scroll.mjs +389 -0
  21. package/engine/font-outlines.mjs +394 -0
  22. package/engine/language-data.mjs +154 -63
  23. package/engine/language-rows.mjs +32 -6
  24. package/engine/language-terms.mjs +32 -8
  25. package/engine/map-lexicon.mjs +31 -6
  26. package/engine/note-vocabulary.mjs +58 -6
  27. package/engine/pdf-fonts.mjs +1 -1
  28. package/engine/pdf-render.mjs +52 -11
  29. package/engine/run-rows.mjs +45 -3
  30. package/engine/script-direction.mjs +67 -0
  31. package/engine/script-emphasis-terms.mjs +11 -3
  32. package/engine/script-font-delivery.mjs +42 -9
  33. package/engine/script-fonts.mjs +7 -0
  34. package/engine/site-build.mjs +8 -1
  35. package/engine/site-reach.mjs +213 -0
  36. package/engine/site-render.mjs +120 -45
  37. package/engine/site-root.mjs +68 -26
  38. package/engine/site-serve.mjs +2 -1
  39. package/engine/site-theme.mjs +19 -6
  40. package/engine/sql-tables.mjs +9 -6
  41. package/engine/verse-metre.mjs +1 -0
  42. package/engine/written-in.mjs +208 -0
  43. package/package.json +1 -1
  44. package/site-theme/static/css/style.css +7 -0
  45. package/types/engine/content-scripts.d.mts +31 -6
  46. package/types/engine/content-scroll.d.mts +179 -0
  47. package/types/engine/font-outlines.d.mts +67 -0
  48. package/types/engine/language-data.d.mts +13 -0
  49. package/types/engine/language-rows.d.mts +4 -3
  50. package/types/engine/language-terms.d.mts +7 -0
  51. package/types/engine/map-lexicon.d.mts +4 -3
  52. package/types/engine/pdf-fonts.d.mts +10 -0
  53. package/types/engine/run-rows.d.mts +28 -6
  54. package/types/engine/script-direction.d.mts +10 -0
  55. package/types/engine/script-emphasis-terms.d.mts +10 -3
  56. package/types/engine/script-font-delivery.d.mts +5 -3
  57. package/types/engine/script-fonts.d.mts +9 -0
  58. package/types/engine/site-reach.d.mts +76 -0
  59. package/types/engine/site-render.d.mts +4 -2
  60. package/types/engine/site-root.d.mts +2 -0
  61. package/types/engine/written-in.d.mts +76 -0
@@ -13,8 +13,8 @@
13
13
 
14
14
  /**
15
15
  * **Text marked with a language, a font and a mapping.** Three keys on the
16
- * attribute grammar spans, divs and verse fences already share, each optional
17
- * and independent of the others:
16
+ * attribute grammar spans, divs, verse fences and scroll fences already share,
17
+ * each optional and independent of the others:
18
18
  *
19
19
  * ```markdown
20
20
  * [Daimela, helthos.]{lang=vyl}
@@ -27,9 +27,11 @@
27
27
  * ```verse {form=epic lang=vyl font=semaron-kuriale map=semaron}
28
28
  * ```
29
29
  *
30
- * - **`lang=`** gives the language tag and changes nothing else. It is a name
31
- * declared in the package's `languages`, which gives its tag, or otherwise a
32
- * well-formed BCP 47 tag, checked by form alone.
30
+ * - **`lang=`** gives the language tag. It is a name declared in the
31
+ * package's `languages`, which gives its tag, or otherwise a well-formed BCP
32
+ * 47 tag, checked by form alone. A declared language whose note names its
33
+ * script in `data.writtenIn` gives a run the map and face it leaves
34
+ * unstated; see {@link module:engine/written-in}.
33
35
  * - **`font=`** is one of the installed faces, by its class without `hl-`
34
36
  * (`semaron-kuriale`), or `mono`, `serif` or `sans`.
35
37
  * - **`map=`** names a mapping note's `data.map`, which rewrites the text
@@ -50,7 +52,7 @@
50
52
  * word is a finding at its first character, once per run; see
51
53
  * {@link module:engine/run-words}.
52
54
  * - **A run inside a div or a verse fence inherits its keys**, and its own
53
- * keys override them.
55
+ * keys override them. The language's script fills what is still unstated.
54
56
  * - **Text with a font, a map or a declared language is plain.** A span or a
55
57
  * verse holds no emphasis, link, image, footnote, wikilink, code or HTML,
56
58
  * and no span but a marked one; a div holds paragraphs and line breaks only.
@@ -65,9 +67,15 @@
65
67
  * neither whitespace nor in the face's `codepoints` is a finding, at the
66
68
  * source character it came from. `mono`, `serif` and `sans` are not checked.
67
69
  *
70
+ * - **A scroll fence is set in a face whose script note states
71
+ * `direction: vertical-rl`.** One that names no `font=` takes its
72
+ * language's script, else the face of a map drawn in one; one that names no
73
+ * face, or whose script is written in lines, is a finding. See {@link module:engine/content-scroll}.
74
+ *
68
75
  * {@link scanRuns} reads the markup through {@link module:engine/content-spans.scanSpans},
69
- * {@link module:engine/content-blocks.scanBlocks} and
70
- * {@link module:engine/content-verse.scanVerse}, so what counts as a span, a
76
+ * {@link module:engine/content-blocks.scanBlocks},
77
+ * {@link module:engine/content-verse.scanVerse} and
78
+ * {@link module:engine/content-scroll.scanScrolls}, so what counts as a span, a
71
79
  * div or a fence is theirs to say.
72
80
  *
73
81
  * @module
@@ -85,6 +93,8 @@ import {
85
93
  sourcePosition,
86
94
  } from "./content-verse.mjs";
87
95
  import { scanSpans } from "./content-spans.mjs";
96
+ import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
97
+ import { directionsFromRecords } from "./script-direction.mjs";
88
98
  import { isLanguageTag } from "./language-tag.mjs";
89
99
  import { distance, nearest } from "./near-miss.mjs";
90
100
  import { emphasesFromRecords, languageEmphasesOf } from "./script-emphasis.mjs";
@@ -101,6 +111,7 @@ import {
101
111
  import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
102
112
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
103
113
  import { applyMap, mapsFromRecords } from "./text-maps.mjs";
114
+ import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
104
115
 
105
116
  /** The keys this module reads, which no construct passes through as written. */
106
117
  export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
@@ -127,6 +138,9 @@ const CONFIG = "package-build.config.yaml";
127
138
  * @property {ReadonlyMap<string, import("./text-maps.mjs").TextMap>} [maps] -
128
139
  * The mapping notes' maps by name, from
129
140
  * {@link module:engine/text-maps.mapsFromRecords}.
141
+ * @property {ReadonlyMap<string, import("./written-in.mjs").WrittenIn>} [writtenIn] -
142
+ * The script each configured language's note names, by language name, from
143
+ * {@link module:engine/written-in.writtenInOf}.
130
144
  * @property {ReadonlyMap<string, import("./map-lexicon.mjs").Lexicon>} [lexicons] -
131
145
  * Each configured language's lexicon by name, from
132
146
  * {@link module:engine/map-lexicon.lexiconsFromRecords}.
@@ -144,15 +158,20 @@ const CONFIG = "package-build.config.yaml";
144
158
  * @property {ReadonlyMap<string, import("./script-emphasis.mjs").LanguageEmphasis>} [languageEmphases] -
145
159
  * The convention each configured language's note states, by language name,
146
160
  * from {@link module:engine/script-emphasis.languageEmphasesOf}.
161
+ * @property {ReadonlyMap<string, "vertical-rl">} [directions] - Each face whose
162
+ * script is written in columns, from the script notes, by
163
+ * {@link module:engine/script-direction.directionsFromRecords}.
147
164
  */
148
165
 
149
166
  /**
150
- * One span, div or verse fence carrying `lang=`, `font=` or `map=`. Its
151
- * `lang`, `font` and `map` are the ones it is set with: its own, else the
152
- * innermost div or fence around it that states one.
167
+ * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
168
+ * `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
169
+ * else the innermost div or fence around it that states one, else its
170
+ * language's script. A scroll fence that still names no face takes the face of
171
+ * its map where the map is drawn in one.
153
172
  *
154
173
  * @typedef {object} Run
155
- * @property {"span"|"block"|"verse"} kind - Which construct.
174
+ * @property {"span"|"block"|"verse"|"scroll"} kind - Which construct.
156
175
  * @property {string|undefined} lang - The language as written.
157
176
  * @property {string|undefined} tag - The BCP 47 tag it resolves to; `undefined`
158
177
  * when it resolves to none.
@@ -207,15 +226,19 @@ export function runContext(config, records) {
207
226
  if (!byRecords) withMaps.set(config, (byRecords = new WeakMap()));
208
227
  const known = byRecords.get(records);
209
228
  if (known) return known;
229
+ const maps = mapsFromRecords(records);
230
+ const languageRecords = languageRecordsByName(records, config);
210
231
  const context = Object.freeze({
211
232
  ...base,
212
- maps: mapsFromRecords(records),
233
+ maps,
234
+ writtenIn: writtenInOf(languageRecords, maps),
213
235
  lexicons: lexiconsFromRecords(records, config),
214
236
  wordBooks: wordBooksFromRecords(records, config),
215
237
  metres: metresFromRecords(records),
216
238
  metreBooks: metreBooksFromRecords(records, config),
217
239
  emphases: emphasesFromRecords(records),
218
- languageEmphases: languageEmphasesOf(languageRecordsByName(records, config)),
240
+ languageEmphases: languageEmphasesOf(languageRecords),
241
+ directions: directionsFromRecords(records),
219
242
  });
220
243
  byRecords.set(records, context);
221
244
  return context;
@@ -643,6 +666,26 @@ function setText(raw, mapping, lineStart) {
643
666
  return { text: out.join("\n"), pieces };
644
667
  }
645
668
 
669
+ /**
670
+ * Set a stretch of a scroll fence's text: the author's escapes removed and the
671
+ * map applied to all of it, its spaces included, and nothing escaped, since
672
+ * the fence's body is raw.
673
+ *
674
+ * @param {string} raw - The text, on one line.
675
+ * @param {import("./map-lexicon.mjs").Mapping|undefined} mapping - The run's
676
+ * map, face and lexicon.
677
+ * @returns {{text: string, pieces: Array<{from: string, to: string}>}} The set
678
+ * text, and each piece of it.
679
+ */
680
+ function setScrollText(raw, mapping) {
681
+ const plain = unescapeRoman(raw);
682
+ return applyMap(mapping?.map, plain.text, {
683
+ font: mapping?.font,
684
+ native: mapping?.native,
685
+ writing: mapping?.writing,
686
+ });
687
+ }
688
+
646
689
  /**
647
690
  * The parts of a range of text that no hole covers.
648
691
  *
@@ -664,6 +707,29 @@ function outsideHoles(from, to, holes) {
664
707
  return parts;
665
708
  }
666
709
 
710
+ /**
711
+ * The paragraphs of a fence's body: each run of nonblank lines.
712
+ *
713
+ * @param {string[]} lines - The source, by line.
714
+ * @param {number} first - The body's first line, 0-based.
715
+ * @param {number} last - The line past it: the closing fence.
716
+ * @returns {Array<[number, number]>} Each paragraph's first line and the line
717
+ * past its last.
718
+ */
719
+ function paragraphRanges(lines, first, last) {
720
+ const ranges = [];
721
+ let top;
722
+ for (let line = first; line <= last; line++) {
723
+ const blank = line === last || !lines[line].trim();
724
+ if (!blank && top === undefined) top = line;
725
+ else if (blank && top !== undefined) {
726
+ ranges.push([top, line]);
727
+ top = undefined;
728
+ }
729
+ }
730
+ return ranges;
731
+ }
732
+
667
733
  /** `U+XXXX` for a code point. */
668
734
  function codeName(point) {
669
735
  return `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
@@ -708,6 +774,8 @@ const BLOCK_WHAT = Object.freeze({
708
774
  * What its emphasis is set by: its language's convention, else its face's
709
775
  * script's; `undefined` sets it plain.
710
776
  * @property {Delimiter[]} emphasis - The emphasis it holds, by absolute offset.
777
+ * @property {boolean} [unrolled] - A scroll fence whose opening has a finding,
778
+ * which is left as written.
711
779
  */
712
780
 
713
781
  /**
@@ -871,6 +939,36 @@ function analyseRuns(source, ctx, syntax) {
871
939
  });
872
940
  }
873
941
 
942
+ // Every scroll fence is a construct, keys or none: its text is set in a
943
+ // face, so one that names none is a finding.
944
+ for (const scroll of scanScrolls(text).blocks) {
945
+ const opening = lines[scroll.start];
946
+ constructs.push({
947
+ kind: "scroll",
948
+ keys: runKeys(scroll.attributes),
949
+ node: scroll,
950
+ line0: scroll.start,
951
+ last: scroll.end,
952
+ at: (key, part) => ({
953
+ line: scroll.start + 1,
954
+ column: attributeColumns(opening, key)[part],
955
+ }),
956
+ where: {
957
+ line: scroll.start + 1,
958
+ column: scroll.indent.length + 1,
959
+ start: scroll.start,
960
+ end: scroll.end,
961
+ },
962
+ own: {
963
+ id: "",
964
+ classes: scroll.classes ?? [],
965
+ values: Object.entries(scroll.attributes ?? {}).filter(
966
+ ([key]) => !RUN_KEYS.includes(key),
967
+ ),
968
+ },
969
+ });
970
+ }
971
+
874
972
  const containers = constructs.filter((construct) => construct.kind !== "span");
875
973
  /** The containers around a construct, outermost first. */
876
974
  const around = (construct) =>
@@ -1122,6 +1220,47 @@ function analyseRuns(source, ctx, syntax) {
1122
1220
  }
1123
1221
  };
1124
1222
 
1223
+ /**
1224
+ * Give a scroll fence the face it is set in, and report a fence that names
1225
+ * none or whose script is written in lines. A fence that names no face
1226
+ * takes its map's, where the map is drawn in one.
1227
+ *
1228
+ * @param {object} construct - The scroll fence.
1229
+ * @param {Record<string, string>} keys - Its run keys, given the face.
1230
+ */
1231
+ const scrollFace = (construct, keys) => {
1232
+ const textMap = keys.map === undefined ? undefined : maps.get(keys.map);
1233
+ if (keys.font === undefined) {
1234
+ if (textMap && textMap.fonts.size === 1) keys.font = [...textMap.fonts.keys()][0];
1235
+ else if (textMap && textMap.fonts.size > 1) {
1236
+ report(
1237
+ construct.at("map", "value"),
1238
+ `the map \`${keys.map}\` is drawn in more than one face; a \`scroll\` fence names its face with \`font=\``,
1239
+ );
1240
+ return;
1241
+ } else if (keys.map === undefined) {
1242
+ report(
1243
+ construct.where,
1244
+ "a `scroll` fence names the face it is set in, with `font=`, a map drawn in one " +
1245
+ "face, with `map=`, or a language whose note names its script in " +
1246
+ "`data.writtenIn`, with `lang=`",
1247
+ );
1248
+ return;
1249
+ } else return;
1250
+ }
1251
+ if (!ctx.directions || !(faces.has(keys.font) || Object.hasOwn(GENERIC_FONTS, keys.font)))
1252
+ return;
1253
+ if (ctx.directions.get(keys.font) === "vertical-rl") return;
1254
+ report(
1255
+ construct.keys.font !== undefined ? construct.at("font", "value")
1256
+ : construct.keys.map !== undefined ? construct.at("map", "value")
1257
+ : construct.keys.lang !== undefined ? construct.at("lang", "value")
1258
+ : construct.where,
1259
+ `a \`scroll\` fence sets a script written in columns, and the script of \`${keys.font}\` ` +
1260
+ "is written in lines; set it in a face whose script note states `direction: vertical-rl`, or use a div",
1261
+ );
1262
+ };
1263
+
1125
1264
  const runs = [];
1126
1265
  const details = [];
1127
1266
  for (const construct of constructs) {
@@ -1129,8 +1268,15 @@ function analyseRuns(source, ctx, syntax) {
1129
1268
  checkOwn(construct);
1130
1269
  // A key that names nothing is the finding; the text it would have set
1131
1270
  // is not then checked against a face or a language it cannot reach.
1132
- const named = errors.length === unchecked;
1271
+ let named = errors.length === unchecked;
1133
1272
  const keys = Object.assign({}, ...around(construct).map((c) => c.keys), construct.keys);
1273
+ // The language's script fills what the run leaves unstated, once its
1274
+ // own keys are known to name something.
1275
+ if (named) Object.assign(keys, fromWrittenIn(keys, ctx.writtenIn, maps));
1276
+ if (construct.kind === "scroll" && named) {
1277
+ scrollFace(construct, keys);
1278
+ named = errors.length === unchecked;
1279
+ }
1134
1280
  const { lang, font, map } = keys;
1135
1281
  // A face the run's map is not for, once the face and the map are each
1136
1282
  // known, at the construct that states either.
@@ -1177,6 +1323,7 @@ function analyseRuns(source, ctx, syntax) {
1177
1323
  languageEmphasis: lang === undefined ? undefined : ctx.languageEmphases?.get(lang),
1178
1324
  conventions: undefined,
1179
1325
  emphasis: [],
1326
+ ...(construct.kind === "scroll" && construct.node.invalid ? { unrolled: true } : {}),
1180
1327
  };
1181
1328
  detail.conventions = conventionsOf(detail, ctx);
1182
1329
  details.push(detail);
@@ -1248,6 +1395,16 @@ function analyseRuns(source, ctx, syntax) {
1248
1395
  );
1249
1396
  else if (Object.hasOwn(BLOCK_WHAT, token.type)) said(top, BLOCK_WHAT[token.type]);
1250
1397
  }
1398
+ } else if (construct.kind === "scroll") {
1399
+ // A paragraph is reflowed as one text, so its emphasis may cross
1400
+ // the lines it is written on.
1401
+ for (const [top, past] of paragraphRanges(lines, first, last))
1402
+ plainInline(
1403
+ text.slice(starts[top], starts[past - 1] + lines[past - 1].length),
1404
+ starts[top],
1405
+ marking,
1406
+ detail,
1407
+ );
1251
1408
  } else {
1252
1409
  for (let line = first; line < last; line++)
1253
1410
  plainInline(lines[line], starts[line], marking, detail, detail.holes);
@@ -1382,7 +1539,8 @@ function rewrittenAttributes(detail, search) {
1382
1539
  const changes =
1383
1540
  run.font !== undefined ||
1384
1541
  run.map !== undefined ||
1385
- (run.tag !== undefined && (keys.lang !== run.tag || run.kind === "verse"));
1542
+ (run.tag !== undefined &&
1543
+ (keys.lang !== run.tag || run.kind === "verse" || run.kind === "scroll"));
1386
1544
  if (!changes) return undefined;
1387
1545
  const classes = [...own.classes];
1388
1546
  const values = [...own.values];
@@ -1469,6 +1627,10 @@ function setRange(text, from, to, detail, lineStart, book) {
1469
1627
  * - **A div or fence in a right-to-left face** carries `dir=rtl`. No direction
1470
1628
  * mark, `<bdo>` or `<bdi>` is ever written: the code points carry their own
1471
1629
  * direction.
1630
+ * - **A scroll fence's body** is rewritten to one line per paragraph, its
1631
+ * lines joined by what the map writes for a space, and left unescaped,
1632
+ * since the body is raw. Its layout, each character with the source text it
1633
+ * was written from, is returned for the book, which draws it.
1472
1634
  *
1473
1635
  * A run with a finding is left as written; {@link scanRuns} reports it.
1474
1636
  *
@@ -1478,8 +1640,9 @@ function setRange(text, from, to, detail, lineStart, book) {
1478
1640
  * scanner projections.
1479
1641
  * @param {{target?: "site"|"foundry"|"book"}} [options] - The target the
1480
1642
  * Markdown is rendered for; only `site` writes the search marker.
1481
- * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>}}
1482
- * The rewritten Markdown, and the findings of {@link scanRuns}.
1643
+ * @returns {{markdown: string, errors: Array<{line: number, column: number, message: string}>, scrolls: Map<string, import("./content-scroll.mjs").ScrollLayout>}}
1644
+ * The rewritten Markdown, the findings of {@link scanRuns}, and each
1645
+ * rewritten scroll fence's layout, by its rewritten body.
1483
1646
  */
1484
1647
  export function renderRuns(source, ctx = {}, syntax, options = {}) {
1485
1648
  const search = options.target === "site";
@@ -1487,9 +1650,11 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1487
1650
  const authored = String(source ?? "");
1488
1651
  const text = expandVerse(authored);
1489
1652
  if (text !== authored) syntax = undefined;
1490
- if (!/\b(?:lang|font|map)=/.test(text)) return { markdown: text, errors: [] };
1653
+ /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
1654
+ const scrolls = new Map();
1655
+ if (!/\b(?:lang|font|map)=/.test(text)) return { markdown: text, errors: [], scrolls };
1491
1656
  const { details, errors } = analyseRuns(text, ctx, syntax);
1492
- if (!details.length) return { markdown: text, errors };
1657
+ if (!details.length) return { markdown: text, errors, scrolls };
1493
1658
  const lines = text.split("\n");
1494
1659
  const starts = lineStarts(text);
1495
1660
 
@@ -1507,6 +1672,72 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1507
1672
  /** @type {Array<{from: number, to: number, text: string}>} */
1508
1673
  const edits = [];
1509
1674
 
1675
+ /**
1676
+ * A scroll fence's body set as one line per paragraph, and its layout.
1677
+ *
1678
+ * @param {RunDetail} detail - The scroll fence.
1679
+ * @returns {{text: string, layout: import("./content-scroll.mjs").ScrollLayout}}
1680
+ * The body and the layout.
1681
+ */
1682
+ const setScroll = (detail) => {
1683
+ const join = setScrollText(" ", detail.mapping);
1684
+ const texts = [];
1685
+ const paragraphs = [];
1686
+ for (const [top, past] of paragraphRanges(lines, detail.run.start + 1, detail.run.end)) {
1687
+ let out = "";
1688
+ /** @type {import("./content-scroll.mjs").ScrollCell[]} */
1689
+ const cells = [];
1690
+ const conventions = [];
1691
+ let pending = "";
1692
+ const emit = (pieces) => {
1693
+ for (const piece of pieces) {
1694
+ const chars = [...piece.to];
1695
+ if (!chars.length) {
1696
+ pending += piece.from;
1697
+ continue;
1698
+ }
1699
+ const emphasis = conventions.at(-1);
1700
+ chars.forEach((char, at) =>
1701
+ cells.push({
1702
+ text: char,
1703
+ roman: at ? "" : pending + piece.from,
1704
+ ...(emphasis ? { emphasis } : {}),
1705
+ }),
1706
+ );
1707
+ pending = "";
1708
+ }
1709
+ };
1710
+ const set = (from, to) => {
1711
+ const result = setScrollText(text.slice(from, to), detail.mapping);
1712
+ out += result.text;
1713
+ emit(result.pieces);
1714
+ };
1715
+ for (let line = top; line < past; line++) {
1716
+ if (line > top) {
1717
+ out += join.text;
1718
+ emit(join.pieces);
1719
+ }
1720
+ const written = lines[line];
1721
+ const from = starts[line] + written.length - written.trimStart().length;
1722
+ const to = starts[line] + written.trimEnd().length;
1723
+ let at = from;
1724
+ for (const delimiter of detail.emphasis) {
1725
+ if (delimiter.offset < from || delimiter.offset >= to) continue;
1726
+ if (delimiter.offset > at) set(at, delimiter.offset);
1727
+ out += emphasisTag(delimiter, detail.conventions, book);
1728
+ if (delimiter.open) conventions.push(detail.conventions?.[delimiter.tag]);
1729
+ else conventions.pop();
1730
+ at = delimiter.offset + delimiter.length;
1731
+ }
1732
+ if (at < to) set(at, to);
1733
+ }
1734
+ if (pending && cells.length) cells[cells.length - 1].roman += pending;
1735
+ texts.push(out);
1736
+ paragraphs.push(cells);
1737
+ }
1738
+ return { text: texts.join("\n\n"), layout: { face: detail.face, paragraphs } };
1739
+ };
1740
+
1510
1741
  for (const detail of details) {
1511
1742
  const { run } = detail;
1512
1743
  if (faulted(run)) continue;
@@ -1528,6 +1759,22 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1528
1759
  edits.push({ from: run.start, to: run.end, text: `[${inner}]{${braces}}` });
1529
1760
  continue;
1530
1761
  }
1762
+ if (run.kind === "scroll" && attributes !== undefined && !detail.unrolled) {
1763
+ const open = lines[run.start].indexOf("{");
1764
+ const close = lines[run.start].lastIndexOf("}");
1765
+ edits.push({
1766
+ from: starts[run.start] + open + 1,
1767
+ to: starts[run.start] + close,
1768
+ text: attributes,
1769
+ });
1770
+ if (detail.plain && detail.body) {
1771
+ const { text: body, layout } = setScroll(detail);
1772
+ edits.push({ from: detail.body[0], to: detail.body[1], text: body });
1773
+ scrolls.set(scrollParagraphs(body.split("\n")).join("\n\n"), layout);
1774
+ }
1775
+ continue;
1776
+ }
1777
+ if (run.kind === "scroll") continue;
1531
1778
  if (attributes !== undefined) {
1532
1779
  const open = lines[run.start].indexOf("{");
1533
1780
  const close = lines[run.start].lastIndexOf("}");
@@ -1548,5 +1795,5 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1548
1795
  let markdown = text;
1549
1796
  for (const edit of edits.sort((a, b) => b.from - a.from))
1550
1797
  markdown = markdown.slice(0, edit.from) + edit.text + markdown.slice(edit.to);
1551
- return { markdown, errors };
1798
+ return { markdown, errors, scrolls };
1552
1799
  }