@heroiclands/package-build 24.0.4 → 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,17 +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
- hasColumns,
95
- verseColumns,
96
- STANZA_BREAK,
97
- } from "./content-verse.mjs";
95
+ import { scanVerse, hasColumns, verseLine, verseGroups } from "./content-verse.mjs";
98
96
  import { scanSpans } from "./content-spans.mjs";
99
97
  import { scanScrolls, scrollParagraphs } from "./content-scroll.mjs";
100
98
  import { directionsFromRecords } from "./script-direction.mjs";
@@ -114,11 +112,17 @@ import {
114
112
  import { unknownWordMessage, unknownWords, wordBooksFromRecords } from "./run-words.mjs";
115
113
  import { metreBooksFromRecords, metreFindings, metresFromRecords } from "./verse-metre.mjs";
116
114
  import { applyMap, mapsFromRecords } from "./text-maps.mjs";
117
- 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";
118
119
 
119
120
  /** The keys this module reads, which no construct passes through as written. */
120
121
  export const RUN_KEYS = Object.freeze(["lang", "font", "map"]);
121
122
 
123
+ /** Explicit opt-in to native-script presentation on this construct. */
124
+ export const NATIVEFONT_CLASS = "nativefont";
125
+
122
126
  /** The generic families `font=` may name, each with the class that sets it. */
123
127
  export const GENERIC_FONTS = Object.freeze({
124
128
  mono: "font-mono",
@@ -169,9 +173,9 @@ const CONFIG = "package-build.config.yaml";
169
173
  /**
170
174
  * One span, div, verse fence or scroll fence carrying `lang=`, `font=` or
171
175
  * `map=`. Its `lang`, `font` and `map` are the ones it is set with: its own,
172
- * else the innermost div or fence around it that states one, else its
173
- * language's script. A scroll fence that still names no face takes the face of
174
- * 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.
175
179
  *
176
180
  * @typedef {object} Run
177
181
  * @property {"span"|"block"|"verse"|"scroll"} kind - Which construct.
@@ -186,6 +190,8 @@ const CONFIG = "package-build.config.yaml";
186
190
  * 0-based opening line.
187
191
  * @property {number} end - A span's offset past its attributes; a div's or
188
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.
189
195
  */
190
196
 
191
197
  /** @type {WeakMap<object, RunContext>} */
@@ -347,6 +353,38 @@ function closing(text, open) {
347
353
  return -1;
348
354
  }
349
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
+
350
388
  /**
351
389
  * The markup in text that must be plain, each at its offset.
352
390
  *
@@ -771,6 +809,8 @@ const BLOCK_WHAT = Object.freeze({
771
809
  * @property {[number, number]|undefined} body - A div's or fence's body, as
772
810
  * offsets: the first body line's start to the end of the last.
773
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.
774
814
  * @property {import("./script-emphasis.mjs").LanguageEmphasis|undefined} languageEmphasis -
775
815
  * The convention its language's note states.
776
816
  * @property {import("./script-emphasis.mjs").Emphasis|undefined} conventions -
@@ -836,12 +876,30 @@ function analyseRuns(source, ctx, syntax) {
836
876
  const runKeys = (values) =>
837
877
  Object.fromEntries(Object.entries(values).filter(([key]) => RUN_KEYS.includes(key)));
838
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
+
839
897
  // Every construct that states a run key, with where it sits.
840
898
  const constructs = [];
841
899
  const spans = (syntax ? syntax(text).spans : scanSpans(text)).spans;
842
900
  for (const span of spans) {
843
901
  const keys = Object.fromEntries(span.attrs.filter(([key]) => RUN_KEYS.includes(key)));
844
- if (!Object.keys(keys).length) continue;
902
+ if (!span.attrs.length && !span.id) continue;
845
903
  const positions = span.attributePositions ?? {};
846
904
  constructs.push({
847
905
  kind: "span",
@@ -874,6 +932,15 @@ function analyseRuns(source, ctx, syntax) {
874
932
  });
875
933
  const keys = runKeys(block.attributes);
876
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
+ );
877
944
  for (const key of Object.keys(keys))
878
945
  report(
879
946
  at(key, "key"),
@@ -881,7 +948,7 @@ function analyseRuns(source, ctx, syntax) {
881
948
  );
882
949
  } else {
883
950
  divs.set(first, block);
884
- if (Object.keys(keys).length)
951
+ if (Object.keys(keys).length || block.classes?.includes(NATIVEFONT_CLASS))
885
952
  constructs.push({
886
953
  kind: "block",
887
954
  keys,
@@ -911,25 +978,10 @@ function analyseRuns(source, ctx, syntax) {
911
978
  collect(0, text);
912
979
 
913
980
  const poems = new Map();
914
- const romanColumnLines = new Set();
915
981
  for (const poem of scanVerse(text).blocks) {
916
982
  poems.set(poem.start, poem);
917
- // Expansion writes one span per display entry. An entry with no map
918
- // keeps the source spelling and page font, even when its language
919
- // normally selects a script. The fence itself holds only structure.
920
- if (poem.classes.includes(COLUMNS_CLASS)) {
921
- const entries = verseColumns(poem, lines[poem.start]).flatMap(
922
- (column) => column.entries,
923
- );
924
- let entry = 0;
925
- for (let line = poem.start + 1; line < poem.end; line++) {
926
- if (!lines[line].trim() || STANZA_BREAK.test(lines[line].trim())) continue;
927
- if (entries[entry++ % entries.length]?.map === undefined)
928
- romanColumnLines.add(line);
929
- }
930
- }
931
983
  const keys = runKeys(poem.attributes);
932
- if (!Object.keys(keys).length) continue;
984
+ if (!Object.keys(keys).length && !poem.classes.includes(NATIVEFONT_CLASS)) continue;
933
985
  const opening = lines[poem.start];
934
986
  constructs.push({
935
987
  kind: "verse",
@@ -987,17 +1039,31 @@ function analyseRuns(source, ctx, syntax) {
987
1039
  });
988
1040
  }
989
1041
 
990
- const containers = constructs.filter((construct) => construct.kind !== "span");
991
- /** The containers around a construct, outermost first. */
1042
+ /** The constructs around a run, outermost first. */
992
1043
  const around = (construct) =>
993
- containers
1044
+ constructs
994
1045
  .filter(
995
1046
  (container) =>
996
1047
  container !== construct &&
997
- container.line0 < construct.line0 &&
998
- 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),
999
1053
  )
1000
- .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
+ );
1001
1067
 
1002
1068
  /** A language's tag, or `undefined` when it resolves to none. */
1003
1069
  const tagOf = (lang) =>
@@ -1006,12 +1072,44 @@ function analyseRuns(source, ctx, syntax) {
1006
1072
  : isLanguageTag(lang) ? lang
1007
1073
  : undefined;
1008
1074
 
1075
+ const native = (construct) => construct.own.classes.includes(NATIVEFONT_CLASS);
1076
+
1009
1077
  const faceNames = () => [...faces.keys(), ...Object.keys(GENERIC_FONTS)];
1010
1078
 
1011
1079
  /** Check the keys a construct states itself. */
1012
1080
  const checkOwn = (construct) => {
1013
1081
  const { lang, font, map } = construct.keys;
1014
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
+
1015
1113
  if (lang !== undefined && !Object.hasOwn(languages, lang)) {
1016
1114
  if (isLanguageTag(lang)) {
1017
1115
  const declared = Object.keys(languages).find((name) => withinOneEdit(lang, name));
@@ -1053,10 +1151,102 @@ function analyseRuns(source, ctx, syntax) {
1053
1151
  }
1054
1152
  };
1055
1153
 
1056
- /** 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. */
1057
1246
  const holesIn = (from, to) =>
1058
- new Map(
1059
- constructs
1247
+ new Map([
1248
+ ...[...verseMarkers].filter(([start]) => start >= from && start < to),
1249
+ ...active
1060
1250
  .filter(
1061
1251
  (construct) =>
1062
1252
  construct.kind === "span" &&
@@ -1064,7 +1254,7 @@ function analyseRuns(source, ctx, syntax) {
1064
1254
  construct.where.start < to,
1065
1255
  )
1066
1256
  .map((construct) => [construct.where.start, construct.where.end]),
1067
- );
1257
+ ]);
1068
1258
 
1069
1259
  /**
1070
1260
  * Report the markup in inline text that must be plain, and collect the
@@ -1219,8 +1409,8 @@ function analyseRuns(source, ctx, syntax) {
1219
1409
  * @param {RunDetail} detail - The run.
1220
1410
  */
1221
1411
  const lexical = (parts, detail) => {
1222
- const { lang, map } = detail.run;
1223
- if (lang === undefined || map === undefined || !maps.has(map)) return;
1412
+ const { lang } = detail.run;
1413
+ if (lang === undefined) return;
1224
1414
  const book = ctx.wordBooks?.get(lang);
1225
1415
  if (!book) return;
1226
1416
  const said = new Set();
@@ -1247,25 +1437,6 @@ function analyseRuns(source, ctx, syntax) {
1247
1437
  * @param {Record<string, string>} keys - Its run keys, given the face.
1248
1438
  */
1249
1439
  const scrollFace = (construct, keys) => {
1250
- const textMap = keys.map === undefined ? undefined : maps.get(keys.map);
1251
- if (keys.font === undefined) {
1252
- if (textMap && textMap.fonts.size === 1) keys.font = [...textMap.fonts.keys()][0];
1253
- else if (textMap && textMap.fonts.size > 1) {
1254
- report(
1255
- construct.at("map", "value"),
1256
- `the map \`${keys.map}\` is drawn in more than one face; a \`scroll\` fence names its face with \`font=\``,
1257
- );
1258
- return;
1259
- } else if (keys.map === undefined) {
1260
- report(
1261
- construct.where,
1262
- "a `scroll` fence names the face it is set in, with `font=`, a map drawn in one " +
1263
- "face, with `map=`, or a language whose note names its script in " +
1264
- "`data.writtenIn`, with `lang=`",
1265
- );
1266
- return;
1267
- } else return;
1268
- }
1269
1440
  if (!ctx.directions || !(faces.has(keys.font) || Object.hasOwn(GENERIC_FONTS, keys.font)))
1270
1441
  return;
1271
1442
  if (ctx.directions.get(keys.font) === "vertical-rl") return;
@@ -1281,19 +1452,32 @@ function analyseRuns(source, ctx, syntax) {
1281
1452
 
1282
1453
  const runs = [];
1283
1454
  const details = [];
1284
- for (const construct of constructs) {
1455
+ for (const construct of active) {
1285
1456
  const unchecked = errors.length;
1286
1457
  checkOwn(construct);
1287
1458
  // A key that names nothing is the finding; the text it would have set
1288
1459
  // is not then checked against a face or a language it cannot reach.
1289
1460
  let named = errors.length === unchecked;
1290
- const keys = Object.assign({}, ...around(construct).map((c) => c.keys), construct.keys);
1291
- // The language's script fills what the run leaves unstated, once its
1292
- // own keys are known to name something.
1293
- const columnSource = construct.kind === "span" && romanColumnLines.has(construct.line0);
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);
1294
1473
  const columnFence = construct.kind === "verse" && hasColumns(construct.node);
1295
- if (named && !columnSource && !columnFence)
1296
- Object.assign(keys, fromWrittenIn(keys, ctx.writtenIn, maps));
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
+ }
1297
1481
  if (construct.kind === "scroll" && named) {
1298
1482
  scrollFace(construct, keys);
1299
1483
  named = errors.length === unchecked;
@@ -1301,8 +1485,12 @@ function analyseRuns(source, ctx, syntax) {
1301
1485
  const { lang, font, map } = keys;
1302
1486
  // A face the run's map is not for, once the face and the map are each
1303
1487
  // known, at the construct that states either.
1304
- 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;
1305
1492
  if (
1493
+ named &&
1306
1494
  stated &&
1307
1495
  map !== undefined &&
1308
1496
  maps.has(map) &&
@@ -1322,13 +1510,16 @@ function analyseRuns(source, ctx, syntax) {
1322
1510
  tag: tagOf(lang),
1323
1511
  font,
1324
1512
  map,
1513
+ ...(native(construct) && construct.keys.lang !== undefined ? { nativefont: true } : {}),
1325
1514
  ...construct.where,
1326
1515
  };
1327
1516
  runs.push(run);
1328
1517
  const plain =
1329
1518
  font !== undefined ||
1330
1519
  map !== undefined ||
1331
- (lang !== undefined && Object.hasOwn(languages, lang));
1520
+ (lang !== undefined &&
1521
+ Object.hasOwn(languages, lang) &&
1522
+ !/^en(?:-|$)/i.test(run.tag ?? ""));
1332
1523
  const detail = {
1333
1524
  run,
1334
1525
  own: construct.own,
@@ -1341,6 +1532,7 @@ function analyseRuns(source, ctx, syntax) {
1341
1532
  holes: new Map(),
1342
1533
  body: undefined,
1343
1534
  contentEnd: construct.kind === "span" ? construct.node.contentEnd : undefined,
1535
+ reference: undefined,
1344
1536
  languageEmphasis: lang === undefined ? undefined : ctx.languageEmphases?.get(lang),
1345
1537
  conventions: undefined,
1346
1538
  emphasis: [],
@@ -1348,7 +1540,9 @@ function analyseRuns(source, ctx, syntax) {
1348
1540
  };
1349
1541
  detail.conventions = conventionsOf(detail, ctx);
1350
1542
  details.push(detail);
1351
- if (!plain) continue;
1543
+ const nonEnglishSpan =
1544
+ construct.kind === "span" && run.tag !== undefined && !/^en(?:-|$)/i.test(run.tag);
1545
+ if (!plain && !nonEnglishSpan) continue;
1352
1546
  const marking = [
1353
1547
  ...(lang === undefined ? [] : [`lang=${lang}`]),
1354
1548
  ...(font === undefined ? [] : [`font=${font}`]),
@@ -1358,11 +1552,34 @@ function analyseRuns(source, ctx, syntax) {
1358
1552
 
1359
1553
  if (construct.kind === "span") {
1360
1554
  const span = construct.node;
1361
- const inline = text.slice(span.start + 1, span.contentEnd);
1362
- 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);
1363
1580
  if (named && errors.length === before) {
1364
1581
  const parts = outsideDelimiters(
1365
- [[span.start + 1, span.contentEnd]],
1582
+ outsideHoles(from, to, detail.holes),
1366
1583
  detail.emphasis,
1367
1584
  );
1368
1585
  lexical(parts, detail);
@@ -1465,48 +1682,20 @@ function analyseRuns(source, ctx, syntax) {
1465
1682
  */
1466
1683
  export function scanRuns(source, ctx = {}, syntax) {
1467
1684
  const text = String(source ?? "");
1468
- const { markdown, origins } = expandVerseOrigins(text);
1469
1685
  const authored = analyseRuns(text, ctx, syntax);
1470
1686
  const metre = metreFindings(scanVerse(text).blocks, text.split("\n"), {
1471
1687
  metres: ctx.metres,
1688
+ languages: ctx.languages,
1472
1689
  metreBooks: ctx.metreBooks,
1473
1690
  lexicons: ctx.lexicons,
1474
- columnsClass: COLUMNS_CLASS,
1475
- columnKeys: COLUMN_KEYS,
1476
1691
  attributeColumns,
1477
1692
  });
1478
- /** @param {object[]} found @param {object[]} extra */
1479
1693
  const merged = (found, extra) =>
1480
- extra.length ?
1481
- [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column)
1482
- : found;
1483
- if (markdown === text)
1484
- return {
1485
- ...authored,
1486
- errors: merged(authored.errors, metre.errors),
1487
- warnings: merged(authored.warnings, metre.warnings),
1488
- };
1489
- // A verse in columns is checked as the spans each entry sets, which are
1490
- // written only in the expansion; every finding is placed back where the
1491
- // note wrote the text or the entry it comes from, once.
1492
- const expanded = markdown.split("\n");
1493
- const checked = analyseRuns(markdown, ctx);
1494
- const placed = (findings) => {
1495
- const seen = new Set();
1496
- return findings
1497
- .map((finding) => ({ ...finding, ...sourcePosition(finding, expanded, origins) }))
1498
- .filter((finding) => {
1499
- const key = `${finding.line}:${finding.column}:${finding.message}`;
1500
- if (seen.has(key)) return false;
1501
- seen.add(key);
1502
- return true;
1503
- })
1504
- .sort((a, b) => a.line - b.line || a.column - b.column);
1505
- };
1694
+ [...found, ...extra].sort((a, b) => a.line - b.line || a.column - b.column);
1506
1695
  return {
1507
- runs: authored.runs,
1508
- errors: merged(placed(checked.errors), metre.errors),
1509
- warnings: merged(placed(checked.warnings), metre.warnings),
1696
+ ...authored,
1697
+ errors: merged(authored.errors, metre.errors),
1698
+ warnings: merged(authored.warnings, metre.warnings),
1510
1699
  };
1511
1700
  }
1512
1701
 
@@ -1558,12 +1747,13 @@ const SCRIPT_BLOCK_CLASS = "script-block";
1558
1747
  function rewrittenAttributes(detail, search) {
1559
1748
  const { run, own, keys, face, generic, textMap } = detail;
1560
1749
  const changes =
1750
+ own.classes.includes(NATIVEFONT_CLASS) ||
1561
1751
  run.font !== undefined ||
1562
1752
  run.map !== undefined ||
1563
1753
  (run.tag !== undefined &&
1564
1754
  (keys.lang !== run.tag || run.kind === "verse" || run.kind === "scroll"));
1565
1755
  if (!changes) return undefined;
1566
- const classes = [...own.classes];
1756
+ const classes = own.classes.filter((name) => name !== NATIVEFONT_CLASS);
1567
1757
  const values = [...own.values];
1568
1758
  if (face) {
1569
1759
  classes.push(SCRIPT_CLASS);
@@ -1631,10 +1821,9 @@ function setRange(text, from, to, detail, lineStart, book) {
1631
1821
  * Rewrite text marked with `lang=`, `font=` and `map=` into the plain span,
1632
1822
  * div and verse grammar the rest of each chain renders, one line for one line.
1633
1823
  *
1634
- * - **A verse in columns is expanded first** into one span per column entry
1635
- * per verse line, by {@link module:engine/content-verse.expandVerse}, so
1636
- * each entry is mapped and set as a span with its keys is. That is the one
1637
- * 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.
1638
1827
  * - **The map, if any, is applied first.** Characters Markdown would read are
1639
1828
  * then backslash-escaped, so they reach every later pass as characters.
1640
1829
  * - **Emphasis in a face** becomes an `<em>` or a `<strong>` carrying its
@@ -1668,12 +1857,11 @@ function setRange(text, from, to, detail, lineStart, book) {
1668
1857
  export function renderRuns(source, ctx = {}, syntax, options = {}) {
1669
1858
  const search = options.target === "site";
1670
1859
  const book = options.target === "book";
1671
- const authored = String(source ?? "");
1672
- const text = expandVerse(authored);
1673
- if (text !== authored) syntax = undefined;
1860
+ const text = String(source ?? "");
1674
1861
  /** @type {Map<string, import("./content-scroll.mjs").ScrollLayout>} */
1675
1862
  const scrolls = new Map();
1676
- 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 };
1677
1865
  const { details, errors } = analyseRuns(text, ctx, syntax);
1678
1866
  if (!details.length) return { markdown: text, errors, scrolls };
1679
1867
  const lines = text.split("\n");
@@ -1682,8 +1870,8 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1682
1870
  /** Whether a finding sits inside a run. */
1683
1871
  const faulted = (run) => {
1684
1872
  const from = (run.line - 1) * 1e6 + run.column;
1685
- const last = run.kind === "span" ? positionOf(starts, run.end).line : run.end + 1;
1686
- 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;
1687
1875
  return errors.some((error) => {
1688
1876
  const at = (error.line - 1) * 1e6 + error.column;
1689
1877
  return at >= from && at < to;
@@ -1765,19 +1953,39 @@ export function renderRuns(source, ctx = {}, syntax, options = {}) {
1765
1953
  const attributes = rewrittenAttributes(detail, search);
1766
1954
  if (run.kind === "span") {
1767
1955
  const contentEnd = /** @type {number} */ (detail.contentEnd);
1768
- 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 });
1769
1982
  if (detail.plain)
1770
- inner = setRange(
1771
- text,
1772
- run.start + 1,
1773
- contentEnd,
1774
- detail,
1775
- run.start === 0 || text[run.start - 1] === "\n",
1776
- book,
1777
- );
1778
- else if (attributes === undefined) continue;
1779
- const braces = attributes ?? text.slice(contentEnd + 2, run.end - 1);
1780
- 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
+ }
1781
1989
  continue;
1782
1990
  }
1783
1991
  if (run.kind === "scroll" && attributes !== undefined && !detail.unrolled) {