@heroiclands/package-build 24.0.3 → 24.1.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.
@@ -51,18 +51,26 @@
51
51
  * form. Any other
52
52
  * word is a finding at its first character, once per run; see
53
53
  * {@link module:engine/run-words}.
54
- * - **A run inside a div or a verse fence inherits its keys**, and its own
55
- * keys override them. The language's script fills what is still unstated.
56
- * - **Text with a font, a map or a declared language is plain.** A span or a
57
- * verse holds no emphasis, link, image, footnote, wikilink, code or HTML,
58
- * and no span but a marked one; a div holds paragraphs and line breaks only.
59
- * - **Text set in a face takes `*…*` and `**…**`**, set by the convention its
54
+ * - **A run inside a span, div or verse fence inherits its keys**, and its
55
+ * own keys override them. A span switching to a different language selects
56
+ * that language's script defaults and its own explicit font and map. A span
57
+ * in the same language keeps the surrounding script choices.
58
+ * - **Text with a font, a map or a declared non-English language is plain.** A span or a
59
+ * verse holds no emphasis, image, footnote, wikilink, code or HTML, and no
60
+ * span but a marked one. A standalone resolved reference sets only its
61
+ * label; links embedded in longer native prose remain forbidden. A div
62
+ * holds paragraphs and line breaks only.
63
+ * - **A non-English span holds no bold or italic markup**, including in a
64
+ * script face. English tags and configured names resolving to English
65
+ * retain their emphasis under the font's rules. Escaped delimiters are text.
66
+ * - **Other text set in a face takes `*…*` and `**…**`**, set by the convention its
60
67
  * language's note states, else its face's script note, else plainly; see
61
68
  * {@link module:engine/script-emphasis}. A language that marks emphasis
62
69
  * with a word makes either a finding at the emphasis.
63
70
  * The text is read as written, so `[`, `]`, `"`, `<<`, `>>`, `!` and
64
71
  * `@#$%^&` are characters, and need no escaping. A real language's tag alone
65
- * marks text and leaves its Markdown alone.
72
+ * marks text and leaves its Markdown alone, subject to the non-English span
73
+ * rule. Verse indentation periods remain layout rather than mapped text.
66
74
  * - **A face draws only what it lists.** After the map, a character that is
67
75
  * neither whitespace nor in the face's `codepoints` is a finding, at the
68
76
  * source character it came from. `mono`, `serif` and `sans` are not checked.
@@ -84,14 +92,7 @@
84
92
  import MarkdownIt from "markdown-it";
85
93
 
86
94
  import { scanBlocks, BLOCK_CONTAINERS } from "./content-blocks.mjs";
87
- import {
88
- COLUMN_KEYS,
89
- COLUMNS_CLASS,
90
- expandVerse,
91
- expandVerseOrigins,
92
- scanVerse,
93
- sourcePosition,
94
- } from "./content-verse.mjs";
95
+ import { scanVerse, hasColumns, verseLine, verseGroups } from "./content-verse.mjs";
95
96
  import { scanSpans } from "./content-spans.mjs";
96
97
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
97
98
  import { directionsFromRecords } from "./script-direction.mjs";
@@ -111,11 +112,17 @@ import {
111
112
  import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
112
113
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
113
114
  import { applyMap, mapsFromRecords } from "./text-maps.mjs";
114
- import { fromWrittenIn, writtenInOf } from "./written-in.mjs";
115
+ import { writtenInOf } from "./written-in.mjs";
116
+ import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
117
+ import { matchAllOutsideCode } from "./code-fences.mjs";
118
+ import { parseExtensionAttributes } from "./extension-attributes.mjs";
115
119
 
116
120
  /** The keys this module reads, which no construct passes through as written. */
117
121
  export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
118
122
 
123
+ /** Explicit opt-in to native-script presentation on this construct. */
124
+ export const NATIVEFONT_CLASS = "nativefont";
125
+
119
126
  /** The generic families `font=` may name, each with the class that sets it. */
120
127
  export const GENERIC_FONTS = Object.freeze({
121
128
  mono: "font-mono",
@@ -166,9 +173,9 @@ const CONFIG = "package-build.config.yaml";
166
173
  /**
167
174
  * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
168
175
  * `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.
176
+ * else inherited language or an ordinary prose family. Native map/face
177
+ * defaults are selected only by this construct's explicit `.nativefont`
178
+ * opt-in; marked children make their own presentation choice.
172
179
  *
173
180
  * @typedef {object} Run
174
181
  * @property {"span"|"block"|"verse"|"scroll"} kind - Which construct.
@@ -183,6 +190,8 @@ const CONFIG = "package-build.config.yaml";
183
190
  * 0-based opening line.
184
191
  * @property {number} end - A span's offset past its attributes; a div's or
185
192
  * fence's 0-based closing line.
193
+ * @property {boolean} [nativefont] - True only for an explicit native opt-in
194
+ * with its own language; retained by index records for deferred defaults.
186
195
  */
187
196
 
188
197
  /** @type {WeakMap<object, RunContext>} */
@@ -344,6 +353,38 @@ function closing(text, open) {
344
353
  return -1;
345
354
  }
346
355
 
356
+ /**
357
+ * The label of exactly one resolved reference, as offsets within its span.
358
+ * Targets and the resolver's draft decoration are presentation, not words of
359
+ * the label's language. No surrounding prose belongs to this atom.
360
+ *
361
+ * @param {string} text - A span's entire content after wikilink resolution.
362
+ * @returns {[number, number]|undefined} The visible label's bounds.
363
+ */
364
+ function referenceLabel(text) {
365
+ let offset = 0;
366
+ const draft =
367
+ /^<span class="sohl-draft-link" title="Draft — not yet written">([\s\S]*)<\/span>$/.exec(
368
+ text,
369
+ );
370
+ if (draft) {
371
+ offset = text.indexOf(">") + 1;
372
+ text = draft[1];
373
+ } else if (text.endsWith(" (draft)")) {
374
+ text = text.slice(0, -8);
375
+ }
376
+ const uuid = /^@UUID\[[^\]\r\n]+\]\{([^{}\r\n]*)\}$/.exec(text);
377
+ if (uuid) {
378
+ const start = text.indexOf("]{") + 2;
379
+ return [offset + start, offset + text.length - 1];
380
+ }
381
+ if (!text.startsWith("[")) return undefined;
382
+ const end = closing(text, 0);
383
+ if (end < 0 || text[end + 1] !== "(") return undefined;
384
+ if (closing(text, end + 1) !== text.length - 1) return undefined;
385
+ return [offset + 1, offset + end];
386
+ }
387
+
347
388
  /**
348
389
  * The markup in text that must be plain, each at its offset.
349
390
  *
@@ -768,6 +809,8 @@ const BLOCK_WHAT = Object.freeze({
768
809
  * @property {[number, number]|undefined} body - A div's or fence's body, as
769
810
  * offsets: the first body line's start to the end of the last.
770
811
  * @property {number|undefined} contentEnd - A span's offset of its closing `]`.
812
+ * @property {[number, number]|undefined} reference - A resolved reference's
813
+ * visible label bounds, by absolute offset; its target stays untouched.
771
814
  * @property {import("./script-emphasis.mjs").LanguageEmphasis|undefined} languageEmphasis -
772
815
  * The convention its language's note states.
773
816
  * @property {import("./script-emphasis.mjs").Emphasis|undefined} conventions -
@@ -833,12 +876,30 @@ function analyseRuns(source, ctx, syntax) {
833
876
  const runKeys = (values) =>
834
877
  Object.fromEntries(Object.entries(values).filter(([key]) => RUN_KEYS.includes(key)));
835
878
 
879
+ const nativeAt = (construct) => {
880
+ const opening = lines[construct.line0];
881
+ const from =
882
+ construct.kind === "span" ?
883
+ construct.node.contentEnd + 2
884
+ : starts[construct.line0] + opening.indexOf("{") + 1;
885
+ const end =
886
+ construct.kind === "span" ?
887
+ construct.where.end
888
+ : starts[construct.line0] + opening.lastIndexOf("}") + 1;
889
+ const to = text[end - 1] === "}" ? end - 1 : end;
890
+ // Attribute tokens keep quoted values intact, so a class-looking value
891
+ // or longer class cannot steal the opt-in's diagnostic location.
892
+ for (const token of text.slice(from, to).matchAll(/(?:[^\s"\\]|\\.|"(?:\\.|[^"\\])*")+/g))
893
+ if (token[0] === ".nativefont") return positionOf(starts, from + token.index);
894
+ return construct.where;
895
+ };
896
+
836
897
  // Every construct that states a run key, with where it sits.
837
898
  const constructs = [];
838
899
  const spans = (syntax ? syntax(text).spans : scanSpans(text)).spans;
839
900
  for (const span of spans) {
840
901
  const keys = Object.fromEntries(span.attrs.filter(([key]) => RUN_KEYS.includes(key)));
841
- if (!Object.keys(keys).length) continue;
902
+ if (!span.attrs.length && !span.id) continue;
842
903
  const positions = span.attributePositions ?? {};
843
904
  constructs.push({
844
905
  kind: "span",
@@ -871,6 +932,15 @@ function analyseRuns(source, ctx, syntax) {
871
932
  });
872
933
  const keys = runKeys(block.attributes);
873
934
  if (block.name !== "div") {
935
+ if (block.classes?.includes(NATIVEFONT_CLASS))
936
+ report(
937
+ nativeAt({
938
+ kind: "block",
939
+ line0: first,
940
+ where: { line: first + 1, column: 1 },
941
+ }),
942
+ `\`.nativefont\` is read on a span, a div, a verse fence or a scroll fence, not on a ${block.name} block`,
943
+ );
874
944
  for (const key of Object.keys(keys))
875
945
  report(
876
946
  at(key, "key"),
@@ -878,7 +948,7 @@ function analyseRuns(source, ctx, syntax) {
878
948
  );
879
949
  } else {
880
950
  divs.set(first, block);
881
- if (Object.keys(keys).length)
951
+ if (Object.keys(keys).length || block.classes?.includes(NATIVEFONT_CLASS))
882
952
  constructs.push({
883
953
  kind: "block",
884
954
  keys,
@@ -911,7 +981,7 @@ function analyseRuns(source, ctx, syntax) {
911
981
  for (const poem of scanVerse(text).blocks) {
912
982
  poems.set(poem.start, poem);
913
983
  const keys = runKeys(poem.attributes);
914
- if (!Object.keys(keys).length) continue;
984
+ if (!Object.keys(keys).length && !poem.classes.includes(NATIVEFONT_CLASS)) continue;
915
985
  const opening = lines[poem.start];
916
986
  constructs.push({
917
987
  kind: "verse",
@@ -969,17 +1039,31 @@ function analyseRuns(source, ctx, syntax) {
969
1039
  });
970
1040
  }
971
1041
 
972
- const containers = constructs.filter((construct) => construct.kind !== "span");
973
- /** The containers around a construct, outermost first. */
1042
+ /** The constructs around a run, outermost first. */
974
1043
  const around = (construct) =>
975
- containers
1044
+ constructs
976
1045
  .filter(
977
1046
  (container) =>
978
1047
  container !== construct &&
979
- container.line0 < construct.line0 &&
980
- construct.line0 < container.last,
1048
+ (container.kind === "span" ?
1049
+ construct.kind === "span" &&
1050
+ container.where.start < construct.where.start &&
1051
+ container.where.end >= construct.where.end
1052
+ : container.line0 < construct.line0 && construct.line0 < container.last),
981
1053
  )
982
- .sort((a, b) => a.line0 - b.line0);
1054
+ .sort((a, b) =>
1055
+ a.kind !== "span" && b.kind !== "span" ? a.line0 - b.line0
1056
+ : a.kind !== "span" ? -1
1057
+ : b.kind !== "span" ? 1
1058
+ : a.where.start - b.where.start,
1059
+ );
1060
+ const active = constructs.filter(
1061
+ (construct) =>
1062
+ construct.kind === "scroll" ||
1063
+ construct.own.classes.includes(NATIVEFONT_CLASS) ||
1064
+ Object.keys(construct.keys).length ||
1065
+ around(construct).some((container) => Object.keys(container.keys).length),
1066
+ );
983
1067
 
984
1068
  /** A language's tag, or `undefined` when it resolves to none. */
985
1069
  const tagOf = (lang) =>
@@ -988,12 +1072,44 @@ function analyseRuns(source, ctx, syntax) {
988
1072
  : isLanguageTag(lang) ? lang
989
1073
  : undefined;
990
1074
 
1075
+ const native = (construct) => construct.own.classes.includes(NATIVEFONT_CLASS);
1076
+
991
1077
  const faceNames = () => [...faces.keys(), ...Object.keys(GENERIC_FONTS)];
992
1078
 
993
1079
  /** Check the keys a construct states itself. */
994
1080
  const checkOwn = (construct) => {
995
1081
  const { lang, font, map } = construct.keys;
996
1082
  const { at } = construct;
1083
+ if (native(construct) && lang === undefined)
1084
+ report(
1085
+ nativeAt(construct),
1086
+ "`.nativefont` requires an explicit `lang=` on the same construct",
1087
+ );
1088
+ if (native(construct) && construct.kind === "verse" && hasColumns(construct.node))
1089
+ report(
1090
+ nativeAt(construct),
1091
+ "a verse with cols>1 opts individual runs into `.nativefont`, not the fence",
1092
+ );
1093
+ if (!native(construct)) {
1094
+ if (construct.kind === "scroll")
1095
+ report(
1096
+ construct.where,
1097
+ "a scroll requires `.nativefont` and an explicit `lang=` on the same construct",
1098
+ );
1099
+ else {
1100
+ if (font !== undefined && faces.has(font))
1101
+ report(
1102
+ at("font", "value"),
1103
+ `native \`font=${font}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1104
+ );
1105
+ if (map !== undefined && maps.has(map))
1106
+ report(
1107
+ at("map", "value"),
1108
+ `\`map=${map}\` requires \`.nativefont\` and an explicit \`lang=\` on the same construct`,
1109
+ );
1110
+ }
1111
+ }
1112
+
997
1113
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
998
1114
  if (isLanguageTag(lang)) {
999
1115
  const declared = Object.keys(languages).find((name) => withinOneEdit(lang, name));
@@ -1035,10 +1151,102 @@ function analyseRuns(source, ctx, syntax) {
1035
1151
  }
1036
1152
  };
1037
1153
 
1038
- /** Each marked span inside a container, by absolute offset. */
1154
+ const nativeDefaults = (construct, keys) => {
1155
+ const defaults = ctx.writtenIn?.get(keys.lang);
1156
+ for (const key of ["map", "font"])
1157
+ if (keys[key] === undefined && defaults?.[key] !== undefined) keys[key] = defaults[key];
1158
+ if (keys.font === undefined)
1159
+ report(
1160
+ nativeAt(construct),
1161
+ `\`.nativefont\` has no default \`font=\` for \`${keys.lang}\`; state \`font=\` explicitly`,
1162
+ );
1163
+ if (keys.map !== undefined && !maps.has(keys.map))
1164
+ report(
1165
+ nativeAt(construct),
1166
+ `native default \`map=${keys.map}\` is unavailable; state \`map=\` explicitly`,
1167
+ );
1168
+ if (
1169
+ keys.font !== undefined &&
1170
+ !faces.has(keys.font) &&
1171
+ !Object.hasOwn(GENERIC_FONTS, keys.font)
1172
+ )
1173
+ report(
1174
+ nativeAt(construct),
1175
+ `native default \`font=${keys.font}\` is unavailable; state \`font=\` explicitly`,
1176
+ );
1177
+ };
1178
+
1179
+ // Raw wikilinks belong to the resolver, not the span rewriter. Validate
1180
+ // their own control attributes with the same grammar before resolution,
1181
+ // without mapping a destination or consuming the trailing attributes.
1182
+ for (const match of matchAllOutsideCode(text, new RegExp(WIKILINK.source, "g"))) {
1183
+ const parsed = parseWikilink(match[1], match[0]);
1184
+ if (!parsed.attributes) continue;
1185
+ const close = match[0].indexOf("]]") + 1;
1186
+ const from = match.index + close + 2;
1187
+ const attrs = parseExtensionAttributes(match[0].slice(close + 2, -1), { positions: true });
1188
+ const at = positionOf(starts, match.index);
1189
+ const construct = {
1190
+ kind: "span",
1191
+ keys: runKeys(attrs.values),
1192
+ node: { contentEnd: match.index + close },
1193
+ line0: at.line - 1,
1194
+ where: { ...at, start: match.index, end: match.index + match[0].length },
1195
+ own: { classes: attrs.classes },
1196
+ at: (key, part) =>
1197
+ attrs.positions?.[key]?.[part] ?
1198
+ positionOf(starts, from + attrs.positions[key][part].start)
1199
+ : at,
1200
+ };
1201
+ const unchecked = errors.length;
1202
+ checkOwn(construct);
1203
+ if (errors.length !== unchecked || !native(construct)) continue;
1204
+ const keys = { ...construct.keys };
1205
+ nativeDefaults(construct, keys);
1206
+ if (errors.length !== unchecked || keys.map === undefined || keys.font === undefined)
1207
+ continue;
1208
+ const problem = fontProblem(maps.get(keys.map), keys.font);
1209
+ if (problem)
1210
+ report(
1211
+ construct.at(construct.keys.map !== undefined ? "map" : "font", "value"),
1212
+ problem,
1213
+ );
1214
+ }
1215
+
1216
+ const verseMarkers = new Map();
1217
+ for (const poem of scanVerse(text).blocks) {
1218
+ if (hasColumns(poem)) {
1219
+ for (const group of verseGroups(poem, lines)) {
1220
+ if (group.stanzaBreak) continue;
1221
+ for (const part of group.parts) {
1222
+ const start = starts[part.line];
1223
+ if (part.prefixLength)
1224
+ verseMarkers.set(
1225
+ start + part.sourceColumn - 1,
1226
+ start + part.textColumn - 1,
1227
+ );
1228
+ if (part.markerLength)
1229
+ verseMarkers.set(
1230
+ start + part.markerStart,
1231
+ start + part.markerStart + part.markerLength,
1232
+ );
1233
+ }
1234
+ }
1235
+ } else
1236
+ for (let line = poem.start + 1; line < poem.end; line++) {
1237
+ const { markerStart, markerLength } = verseLine(lines[line]);
1238
+ if (markerLength) {
1239
+ const from = starts[line] + markerStart;
1240
+ verseMarkers.set(from, from + markerLength);
1241
+ }
1242
+ }
1243
+ }
1244
+
1245
+ /** Nested spans and verse markers excluded from a run, by absolute offset. */
1039
1246
  const holesIn = (from, to) =>
1040
- new Map(
1041
- constructs
1247
+ new Map([
1248
+ ...[...verseMarkers].filter(([start]) => start >= from && start < to),
1249
+ ...active
1042
1250
  .filter(
1043
1251
  (construct) =>
1044
1252
  construct.kind === "span" &&
@@ -1046,7 +1254,7 @@ function analyseRuns(source, ctx, syntax) {
1046
1254
  construct.where.start < to,
1047
1255
  )
1048
1256
  .map((construct) => [construct.where.start, construct.where.end]),
1049
- );
1257
+ ]);
1050
1258
 
1051
1259
  /**
1052
1260
  * Report the markup in inline text that must be plain, and collect the
@@ -1201,8 +1409,8 @@ function analyseRuns(source, ctx, syntax) {
1201
1409
  * @param {RunDetail} detail - The run.
1202
1410
  */
1203
1411
  const lexical = (parts, detail) => {
1204
- const { lang, map } = detail.run;
1205
- if (lang === undefined || map === undefined || !maps.has(map)) return;
1412
+ const { lang } = detail.run;
1413
+ if (lang === undefined) return;
1206
1414
  const book = ctx.wordBooks?.get(lang);
1207
1415
  if (!book) return;
1208
1416
  const said = new Set();
@@ -1229,25 +1437,6 @@ function analyseRuns(source, ctx, syntax) {
1229
1437
  * @param {Record<string, string>} keys - Its run keys, given the face.
1230
1438
  */
1231
1439
  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
1440
  if (!ctx.directions || !(faces.has(keys.font) || Object.hasOwn(GENERIC_FONTS, keys.font)))
1252
1441
  return;
1253
1442
  if (ctx.directions.get(keys.font) === "vertical-rl") return;
@@ -1263,16 +1452,32 @@ function analyseRuns(source, ctx, syntax) {
1263
1452
 
1264
1453
  const runs = [];
1265
1454
  const details = [];
1266
- for (const construct of constructs) {
1455
+ for (const construct of active) {
1267
1456
  const unchecked = errors.length;
1268
1457
  checkOwn(construct);
1269
1458
  // A key that names nothing is the finding; the text it would have set
1270
1459
  // is not then checked against a face or a language it cannot reach.
1271
1460
  let named = errors.length === unchecked;
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));
1461
+ // Language and ordinary prose-family choices inherit. Native map/face
1462
+ // presentation belongs to this construct alone, including same-language
1463
+ // children, so a parallel Roman run never takes a native neighbour's face.
1464
+ const ancestors = around(construct);
1465
+ const keys = {};
1466
+ for (const ancestor of ancestors) {
1467
+ if (ancestor.keys.lang !== undefined) keys.lang = ancestor.keys.lang;
1468
+ if (!native(ancestor) && Object.hasOwn(GENERIC_FONTS, ancestor.keys.font ?? ""))
1469
+ keys.font = ancestor.keys.font;
1470
+ }
1471
+ if (native(construct)) delete keys.font;
1472
+ Object.assign(keys, construct.keys);
1473
+ const columnFence = construct.kind === "verse" && hasColumns(construct.node);
1474
+ if (named && native(construct) && !columnFence) {
1475
+ nativeDefaults(construct, keys);
1476
+ named = errors.length === unchecked;
1477
+ } else if (!native(construct) && keys.font === undefined && ancestors.some(native)) {
1478
+ // Reset CSS/Typst inheritance from a surrounding native face.
1479
+ keys.font = "serif";
1480
+ }
1276
1481
  if (construct.kind === "scroll" && named) {
1277
1482
  scrollFace(construct, keys);
1278
1483
  named = errors.length === unchecked;
@@ -1280,8 +1485,12 @@ function analyseRuns(source, ctx, syntax) {
1280
1485
  const { lang, font, map } = keys;
1281
1486
  // A face the run's map is not for, once the face and the map are each
1282
1487
  // known, at the construct that states either.
1283
- const stated = construct.keys.map !== undefined || construct.keys.font !== undefined;
1488
+ const stated =
1489
+ native(construct) ||
1490
+ construct.keys.map !== undefined ||
1491
+ construct.keys.font !== undefined;
1284
1492
  if (
1493
+ named &&
1285
1494
  stated &&
1286
1495
  map !== undefined &&
1287
1496
  maps.has(map) &&
@@ -1301,13 +1510,16 @@ function analyseRuns(source, ctx, syntax) {
1301
1510
  tag: tagOf(lang),
1302
1511
  font,
1303
1512
  map,
1513
+ ...(native(construct) && construct.keys.lang !== undefined ? { nativefont: true } : {}),
1304
1514
  ...construct.where,
1305
1515
  };
1306
1516
  runs.push(run);
1307
1517
  const plain =
1308
1518
  font !== undefined ||
1309
1519
  map !== undefined ||
1310
- (lang !== undefined && Object.hasOwn(languages, lang));
1520
+ (lang !== undefined &&
1521
+ Object.hasOwn(languages, lang) &&
1522
+ !/^en(?:-|$)/i.test(run.tag ?? ""));
1311
1523
  const detail = {
1312
1524
  run,
1313
1525
  own: construct.own,
@@ -1320,6 +1532,7 @@ function analyseRuns(source, ctx, syntax) {
1320
1532
  holes: new Map(),
1321
1533
  body: undefined,
1322
1534
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
1535
+ reference: undefined,
1323
1536
  languageEmphasis: lang === undefined ? undefined : ctx.languageEmphases?.get(lang),
1324
1537
  conventions: undefined,
1325
1538
  emphasis: [],
@@ -1327,7 +1540,9 @@ function analyseRuns(source, ctx, syntax) {
1327
1540
  };
1328
1541
  detail.conventions = conventionsOf(detail, ctx);
1329
1542
  details.push(detail);
1330
- if (!plain) continue;
1543
+ const nonEnglishSpan =
1544
+ construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1545
+ if (!plain && !nonEnglishSpan) continue;
1331
1546
  const marking = [
1332
1547
  ...(lang === undefined ? [] : [`lang=${lang}`]),
1333
1548
  ...(font === undefined ? [] : [`font=${font}`]),
@@ -1337,11 +1552,34 @@ function analyseRuns(source, ctx, syntax) {
1337
1552
 
1338
1553
  if (construct.kind === "span") {
1339
1554
  const span = construct.node;
1340
- const inline = text.slice(span.start + 1, span.contentEnd);
1341
- plainInline(inline, span.start + 1, marking, detail);
1555
+ let from = span.start + 1;
1556
+ let to = span.contentEnd;
1557
+ const label = referenceLabel(text.slice(from, to));
1558
+ if (label) {
1559
+ detail.reference = [from + label[0], from + label[1]];
1560
+ [from, to] = detail.reference;
1561
+ }
1562
+ const inline = text.slice(from, to);
1563
+ detail.holes = label ? new Map() : holesIn(from, to);
1564
+ if (nonEnglishSpan) {
1565
+ const relative = new Map(
1566
+ [...detail.holes].map(([from, to]) => [
1567
+ from - (span.start + 1),
1568
+ to - (span.start + 1),
1569
+ ]),
1570
+ );
1571
+ for (const delimiter of emphasisDelimiters(inline, relative))
1572
+ if (delimiter.open)
1573
+ report(
1574
+ positionOf(starts, from + delimiter.offset),
1575
+ `a span in non-English language \`${lang}\` holds no bold or italic markup`,
1576
+ );
1577
+ }
1578
+ if (plain && errors.length === before)
1579
+ plainInline(inline, from, marking, detail, detail.holes);
1342
1580
  if (named && errors.length === before) {
1343
1581
  const parts = outsideDelimiters(
1344
- [[span.start + 1, span.contentEnd]],
1582
+ outsideHoles(from, to, detail.holes),
1345
1583
  detail.emphasis,
1346
1584
  );
1347
1585
  lexical(parts, detail);
@@ -1444,48 +1682,20 @@ function analyseRuns(source, ctx, syntax) {
1444
1682
  */
1445
1683
  export function scanRuns(source, ctx = {}, syntax) {
1446
1684
  const text = String(source ?? "");
1447
- const { markdown, origins } = expandVerseOrigins(text);
1448
1685
  const authored = analyseRuns(text, ctx, syntax);
1449
1686
  const metre = metreFindings(scanVerse(text).blocks, text.split("\n"), {
1450
1687
  metres: ctx.metres,
1688
+ languages: ctx.languages,
1451
1689
  metreBooks: ctx.metreBooks,
1452
1690
  lexicons: ctx.lexicons,
1453
- columnsClass: COLUMNS_CLASS,
1454
- columnKeys: COLUMN_KEYS,
1455
1691
  attributeColumns,
1456
1692
  });
1457
- /** @param {object[]} found @param {object[]} extra */
1458
1693
  const merged = (found, extra) =>
1459
- extra.length ?
1460
- [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column)
1461
- : found;
1462
- if (markdown === text)
1463
- return {
1464
- ...authored,
1465
- errors: merged(authored.errors, metre.errors),
1466
- warnings: merged(authored.warnings, metre.warnings),
1467
- };
1468
- // A verse in columns is checked as the spans each entry sets, which are
1469
- // written only in the expansion; every finding is placed back where the
1470
- // note wrote the text or the entry it comes from, once.
1471
- const expanded = markdown.split("\n");
1472
- const checked = analyseRuns(markdown, ctx);
1473
- const placed = (findings) => {
1474
- const seen = new Set();
1475
- return findings
1476
- .map((finding) => ({ ...finding, ...sourcePosition(finding, expanded, origins) }))
1477
- .filter((finding) => {
1478
- const key = `${finding.line}:${finding.column}:${finding.message}`;
1479
- if (seen.has(key)) return false;
1480
- seen.add(key);
1481
- return true;
1482
- })
1483
- .sort((a, b) => a.line - b.line || a.column - b.column);
1484
- };
1694
+ [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column);
1485
1695
  return {
1486
- runs: authored.runs,
1487
- errors: merged(placed(checked.errors), metre.errors),
1488
- warnings: merged(placed(checked.warnings), metre.warnings),
1696
+ ...authored,
1697
+ errors: merged(authored.errors, metre.errors),
1698
+ warnings: merged(authored.warnings, metre.warnings),
1489
1699
  };
1490
1700
  }
1491
1701
 
@@ -1537,12 +1747,13 @@ const SCRIPT_BLOCK_CLASS = "script-block";
1537
1747
  function rewrittenAttributes(detail, search) {
1538
1748
  const { run, own, keys, face, generic, textMap } = detail;
1539
1749
  const changes =
1750
+ own.classes.includes(NATIVEFONT_CLASS) ||
1540
1751
  run.font !== undefined ||
1541
1752
  run.map !== undefined ||
1542
1753
  (run.tag !== undefined &&
1543
1754
  (keys.lang !== run.tag || run.kind === "verse" || run.kind === "scroll"));
1544
1755
  if (!changes) return undefined;
1545
- const classes = [...own.classes];
1756
+ const classes = own.classes.filter((name) => name !== NATIVEFONT_CLASS);
1546
1757
  const values = [...own.values];
1547
1758
  if (face) {
1548
1759
  classes.push(SCRIPT_CLASS);
@@ -1610,10 +1821,9 @@ function setRange(text, from, to, detail, lineStart, book) {
1610
1821
  * Rewrite text marked with `lang=`, `font=` and `map=` into the plain span,
1611
1822
  * div and verse grammar the rest of each chain renders, one line for one line.
1612
1823
  *
1613
- * - **A verse in columns is expanded first** into one span per column entry
1614
- * per verse line, by {@link module:engine/content-verse.expandVerse}, so
1615
- * each entry is mapped and set as a span with its keys is. That is the one
1616
- * rewrite that changes the number of lines.
1824
+ * - **Numbered verse prefixes and indentation markers** stay outside run text.
1825
+ * Each authored run chooses its own language and presentation; physical
1826
+ * lines and source positions remain unchanged.
1617
1827
  * - **The map, if any, is applied first.** Characters Markdown would read are
1618
1828
  * then backslash-escaped, so they reach every later pass as characters.
1619
1829
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
@@ -1647,12 +1857,11 @@ function setRange(text, from, to, detail, lineStart, book) {
1647
1857
  export function renderRuns(source, ctx = {}, syntax, options = {}) {
1648
1858
  const search = options.target === "site";
1649
1859
  const book = options.target === "book";
1650
- const authored = String(source ?? "");
1651
- const text = expandVerse(authored);
1652
- if (text !== authored) syntax = undefined;
1860
+ const text = String(source ?? "");
1653
1861
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
1654
1862
  const scrolls = new Map();
1655
- if (!/\b(?:lang|font|map)=/.test(text)) return { markdown: text, errors: [], scrolls };
1863
+ if (!/\b(?:lang|font|map)=|\bnativefont\b/.test(text))
1864
+ return { markdown: text, errors: [], scrolls };
1656
1865
  const { details, errors } = analyseRuns(text, ctx, syntax);
1657
1866
  if (!details.length) return { markdown: text, errors, scrolls };
1658
1867
  const lines = text.split("\n");
@@ -1661,8 +1870,8 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1661
1870
  /** Whether a finding sits inside a run. */
1662
1871
  const faulted = (run) => {
1663
1872
  const from = (run.line - 1) * 1e6 + run.column;
1664
- const last = run.kind === "span" ? positionOf(starts, run.end).line : run.end + 1;
1665
- const to = last * 1e6;
1873
+ const end = run.kind === "span" ? positionOf(starts, run.end) : undefined;
1874
+ const to = end ? (end.line - 1) * 1e6 + end.column : (run.end + 1) * 1e6;
1666
1875
  return errors.some((error) => {
1667
1876
  const at = (error.line - 1) * 1e6 + error.column;
1668
1877
  return at >= from && at < to;
@@ -1744,19 +1953,39 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1744
1953
  const attributes = rewrittenAttributes(detail, search);
1745
1954
  if (run.kind === "span") {
1746
1955
  const contentEnd = /** @type {number} */ (detail.contentEnd);
1747
- let inner = text.slice(run.start + 1, contentEnd);
1956
+ if (book && detail.reference) {
1957
+ // The book's link renderer adds page and volume cues after
1958
+ // the label. Keep those in the page font, while the authored
1959
+ // presentation attributes still enclose the whole reference.
1960
+ const empty = { id: "", classes: [], values: [] };
1961
+ const native =
1962
+ rewrittenAttributes({ ...detail, own: empty }, search) ??
1963
+ attributeText({ lang: run.tag, ...empty });
1964
+ const [from, to] = detail.reference;
1965
+ const label = `[${setRange(text, from, to, detail, false, book)}]{${native}}`;
1966
+ const reference =
1967
+ text.slice(run.start + 1, from) + label + text.slice(to, contentEnd);
1968
+ const outer = attributeText({
1969
+ lang: undefined,
1970
+ ...detail.own,
1971
+ classes: detail.own.classes.filter((name) => name !== NATIVEFONT_CLASS),
1972
+ });
1973
+ edits.push({
1974
+ from: run.start,
1975
+ to: run.end,
1976
+ text: outer ? `[${reference}]{${outer}}` : reference,
1977
+ });
1978
+ continue;
1979
+ }
1980
+ if (attributes !== undefined)
1981
+ edits.push({ from: contentEnd + 2, to: run.end - 1, text: attributes });
1748
1982
  if (detail.plain)
1749
- inner = setRange(
1750
- text,
1751
- run.start + 1,
1752
- contentEnd,
1753
- detail,
1754
- run.start === 0 || text[run.start - 1] === "\n",
1755
- book,
1756
- );
1757
- else if (attributes === undefined) continue;
1758
- const braces = attributes ?? text.slice(contentEnd + 2, run.end - 1);
1759
- edits.push({ from: run.start, to: run.end, text: `[${inner}]{${braces}}` });
1983
+ for (const [from, to] of detail.reference ?
1984
+ [detail.reference]
1985
+ : outsideHoles(run.start + 1, contentEnd, detail.holes)) {
1986
+ const set = setRange(text, from, to, detail, false, book);
1987
+ if (set !== text.slice(from, to)) edits.push({ from, to, text: set });
1988
+ }
1760
1989
  continue;
1761
1990
  }
1762
1991
  if (run.kind === "scroll" && attributes !== undefined && !detail.unrolled) {