@superdoc/sdk 2.27.0 → 2.28.0-next.16

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.
@@ -9,6 +9,8 @@ import { runSuperdocStructureInsertWorkflow } from '../action-primitives/tools/s
9
9
  import { runSuperdocTextTransformWorkflow } from '../action-primitives/tools/text-transform.js';
10
10
  import { buildDocumentSnapshot, buildMutationSnapshot, matchRunsForBlock, MutationSnapshotError, resolveSnapshotSelector, } from './doc-snapshot.js';
11
11
  import { isRecord } from '../guards.js';
12
+ import { LEAD_IN_BODY_SAMPLE_CHARS, LEAD_IN_DELIMITERS, LEAD_IN_MAX_CHARS, LEAD_IN_QUOTES, labelLeadInSpanOf, leadInEndOf, leadInShapeOf, } from './lead-in-label.js';
13
+ import { RUN_BOOL_MARKS, bodyLookToWrite, callerStyledAnEntry, contextualFormattingOf, createdLeadLengthOf, insertedTextEntriesOf, isSectionTitle, leadBodySplitOf, parallelGroupIndexOf, placementAnchorIdOf, referenceProbeOf, shouldTryParallelGroup, withoutTruncationEllipsis, } from './inserted-block-formatting.js';
12
14
  export const ACTION_DEFINITIONS = actionDescriptors;
13
15
  function projectActionDefinitions(project) {
14
16
  return Object.fromEntries(Object.entries(ACTION_DEFINITIONS).map(([name, definition]) => [name, project(definition)]));
@@ -2669,7 +2671,6 @@ async function runRewriteBlock(doc, args) {
2669
2671
  return context.failure('rewrite_block', error);
2670
2672
  }
2671
2673
  }
2672
- const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
2673
2674
  function inlineFromSnapshotRun(run) {
2674
2675
  const inline = {};
2675
2676
  for (const key of RUN_BOOL_MARKS) {
@@ -5186,112 +5187,8 @@ async function runScopedReplace(doc, args) {
5186
5187
  */
5187
5188
  /** Points → twips: the node read side reports points, mutations take twips. */
5188
5189
  const TWIPS_PER_POINT = 20;
5189
- /**
5190
- * Delimiters that separate a list item's lead-in label from its body text.
5191
- * Spaced dashes are listed separately from bare ones because the label run
5192
- * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
5193
- */
5194
- const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
5195
- /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
5196
- const LEAD_IN_MAX_CHARS = 90;
5197
- /** Sentence characters sampled past the label, to read the body's own look. */
5198
- const LEAD_IN_BODY_SAMPLE_CHARS = 40;
5199
- /**
5200
- * Where an item's lead-in label ends.
5201
- *
5202
- * The SIBLINGS' delimiter wins whenever the item uses it: with a dash-style
5203
- * list, "Rust: Advanced — details" splits at the dash, not at the colon inside
5204
- * the label. Falling back to the earliest known delimiter is only for items
5205
- * that do not use the siblings' one at all.
5206
- */
5207
- function leadInEndOf(text, preferred) {
5208
- const at = (delimiter) => {
5209
- const index = text.indexOf(delimiter);
5210
- return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
5211
- };
5212
- if (preferred) {
5213
- const preferredAt = at(preferred);
5214
- if (preferredAt != null)
5215
- return preferredAt;
5216
- }
5217
- const found = LEAD_IN_DELIMITERS.map(at).filter((index) => index != null);
5218
- return found.length > 0 ? Math.min(...found) : null;
5219
- }
5220
- /** Quote characters a defined term is wrapped in ("Deliverables" means …). */
5221
- const LEAD_IN_QUOTES = ['"', '\u201c', '\u2018', "'"];
5222
- /**
5223
- * Delimiters allowed to sit INSIDE the label run. Wider than the set used to
5224
- * split at a delimiter that follows the label, because a sentence period only
5225
- * counts as a label boundary when the reference's own emphasised run ends with
5226
- * it — "1.7. Records. " — which is evidence no bare text scan can provide.
5227
- */
5228
- const LEAD_IN_TAIL_DELIMITERS = ['. ', ': ', ...LEAD_IN_DELIMITERS];
5229
- /**
5230
- * Read the label shape a reference block uses, from its text and its label run.
5231
- *
5232
- * The delimiter has to sit at the label's edge: immediately AFTER it ("Label:
5233
- * sentence"), or at its own tail ("1.7. Records. " — a numbered clause whose
5234
- * marker and label share one run). That adjacency is what distinguishes a label
5235
- * from an ordinary emphasised opening clause.
5236
- */
5237
- function leadInShapeOf(blockText, labelText, quote) {
5238
- if (quote)
5239
- return { kind: 'quoted', quote };
5240
- const after = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
5241
- if (after)
5242
- return { kind: 'before', delimiter: after };
5243
- const trimmed = labelText.trimEnd();
5244
- const inside = LEAD_IN_TAIL_DELIMITERS.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
5245
- if (inside) {
5246
- const occurrence = labelText.split(inside).length - 1;
5247
- if (occurrence > 0)
5248
- return { kind: 'inside', delimiter: inside, occurrence };
5249
- }
5250
- // A single emphasised word followed by a space and plain prose: the token
5251
- // itself is the label ("WHEREAS the Party…"). Multi-word runs stay
5252
- // unrecognised — an emphasised opening clause is prose, not a label.
5253
- if (/^\S+\s$/.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText)) {
5254
- return { kind: 'word', label: labelText.trimEnd() };
5255
- }
5256
- return null;
5257
- }
5258
- /** The span of `text` that carries the label look, or null when it has none. */
5259
5190
  function leadInSpanOf(text, shape) {
5260
- if (shape.kind === 'typed')
5261
- return typedCaptionSpanOf(text, shape.delimiter);
5262
- if (shape.kind === 'word') {
5263
- // Only the SAME token inherits the look — "WHEREAS the Parties…" does,
5264
- // "The Parties further agree…" does not (no guessing a label there).
5265
- const next = text.charAt(shape.label.length);
5266
- return text.startsWith(shape.label) && (next === '' || /\s/.test(next))
5267
- ? { start: 0, end: shape.label.length }
5268
- : null;
5269
- }
5270
- if (shape.kind === 'quoted') {
5271
- const opener = text.slice(0, 2).indexOf(shape.quote);
5272
- if (opener < 0)
5273
- return null;
5274
- const close = text.indexOf(shape.quote, opener + 1);
5275
- if (close < 0 || close - opener - 1 <= 0 || close > LEAD_IN_MAX_CHARS)
5276
- return null;
5277
- return { start: opener + 1, end: close };
5278
- }
5279
- if (shape.kind === 'inside') {
5280
- // Mirror the reference's STRUCTURE: "1.7. Records. " ends at its second
5281
- // ". ", so "1.8. Review. The parties…" must end at its second one too —
5282
- // stopping at the first would emphasise the number alone.
5283
- let at = -1;
5284
- for (let seen = 0; seen < shape.occurrence; seen += 1) {
5285
- at = text.indexOf(shape.delimiter, at + 1);
5286
- if (at < 0)
5287
- return null;
5288
- }
5289
- if (at <= 0 || at > LEAD_IN_MAX_CHARS)
5290
- return null;
5291
- return { start: 0, end: at + shape.delimiter.length };
5292
- }
5293
- const end = leadInEndOf(text, shape.delimiter);
5294
- return end == null ? null : { start: 0, end };
5191
+ return shape.kind === 'typed' ? typedCaptionSpanOf(text, shape.delimiter) : labelLeadInSpanOf(text, shape);
5295
5192
  }
5296
5193
  const TYPED_MARKER_CORE = String.raw `(?:\d{1,3}(?:\.\d{1,3})*|[ivx]{1,6}|[IVX]{1,6}|[a-z]{1,2}|[A-Z])`;
5297
5194
  const TYPED_MARKER_TOKEN = String.raw `(?:\(${TYPED_MARKER_CORE}\)|${TYPED_MARKER_CORE}[.)])`;
@@ -6564,26 +6461,12 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
6564
6461
  applied.indent = refIndent;
6565
6462
  }
6566
6463
  }
6567
- // Lead-marker pattern: manually lettered/numbered sections style the
6568
- // marker run differently from the body ("(m) " bold, rest plain) — a
6569
- // per-run pattern blocks.list rows cannot express. When the reference
6570
- // block opens with such a styled marker and the created text opens with a
6571
- // similar marker token, reproduce the reference's marker/body split on the
6572
- // created block. Skipped when the caller supplied explicit runs/marks.
6573
6464
  if (allowLeadPattern) {
6574
6465
  const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
6575
6466
  if (lead?.changed)
6576
6467
  applied.leadMarker = lead;
6577
- // matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
6578
- // numbered tokens ("1.", "(m)"), and it needs the reference split across
6579
- // two runs. Sample the reference's RUNS for everything else: a quoted
6580
- // defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
6581
- // marker and label share a run. A UNIFORM reference is not a split, so
6582
- // its emphasis never transfers.
6583
6468
  else {
6584
6469
  const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
6585
- // Assigning `undefined` would still create the key, and an `applied` with
6586
- // keys reads as "this block was matched" — hiding a block that got nothing.
6587
6470
  if (runPattern)
6588
6471
  applied.runPattern = runPattern;
6589
6472
  }
@@ -6633,21 +6516,6 @@ function typedShapeReference(postBlocks, preIds, created, createdIndex, createdT
6633
6516
  nearest((rowShape) => markerShapeFits(rowShape, shape)) ??
6634
6517
  reference);
6635
6518
  }
6636
- /** A lead-in marker token like "(m)", "1.", "a)", "(iv)" at the start of clause text. */
6637
- const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
6638
- /**
6639
- * Reproduce a reference block's lead/body run split on a freshly-created
6640
- * block. Fires only when the reference genuinely has the pattern — its first
6641
- * run is a SHORT lead-in styled differently from the body — and the created
6642
- * text opens with a matching lead of its own. Two lead shapes are recognized:
6643
- * - literal lead words shared by siblings ("WHEREAS", "NOW, THEREFORE"): the
6644
- * created text starts with the reference's exact lead text;
6645
- * - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
6646
- * the created lead is its OWN token matched by shape.
6647
- * Applies the reference lead's marks to the created lead span and only the
6648
- * reference body's font and size to the rest: the body never gains emphasis or
6649
- * an explicit mark-off it did not already carry.
6650
- */
6651
6519
  async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6652
6520
  const createdPreview = created.textPreview ?? '';
6653
6521
  if (!reference.nodeId || !created.nodeId || !createdPreview)
@@ -6656,60 +6524,20 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6656
6524
  const formatFn = maybeMethod(doc, ['format', 'apply']);
6657
6525
  if (!queryFn || !formatFn)
6658
6526
  return null;
6659
- // A short unique-enough prefix of the reference is all we need: the runs
6660
- // returned tile the MATCHED range, covering the lead and the body start.
6661
- // blocks.list truncates textPreview with a trailing ellipsis — strip it, the
6662
- // remaining prefix IS document text so query.match can find it.
6663
- const refProbe = (reference.textPreview ?? '')
6664
- .replace(/…\s*$/, '')
6665
- .slice(0, 60)
6666
- .trim();
6527
+ const refProbe = referenceProbeOf(reference.textPreview ?? '');
6667
6528
  if (!refProbe)
6668
6529
  return null;
6669
- const refRuns = await matchRunsForBlock(queryFn, refProbe, reference.nodeId);
6670
- if (refRuns.length < 2)
6671
- return null; // uniform reference — no split to copy
6672
- const leadRun = refRuns[0];
6673
- const bodyRun = refRuns[1];
6674
- // The pattern requires a real FORMATTING transition between lead and body
6675
- // (bold/italic/underline/strike). Runs split only by font/color/hyperlink
6676
- // boundaries are ordinary prose — restyling from those would misfire.
6677
- if (RUN_BOOL_MARKS.every((key) => Boolean(leadRun[key]) === Boolean(bodyRun[key])))
6678
- return null;
6679
- const refLead = leadRun.text.trimEnd();
6680
- // The first run must be a short lead-in (a token or a word or two), not
6681
- // half the sentence.
6682
- if (refLead.length === 0 || refLead.length > 40 || leadRun.start !== 0)
6530
+ const split = leadBodySplitOf(await matchRunsForBlock(queryFn, refProbe, reference.nodeId));
6531
+ if (!split)
6683
6532
  return null;
6684
- // The caller knows the text it inserted; the blocks.list preview is truncated.
6685
- const fullText = createdText || createdPreview.replace(/…\s*$/, '');
6686
- // The created block's own lead span (ASCII markers only by design).
6687
- let createdLeadLen = 0;
6688
- const boundaryAfter = (len) => len >= fullText.length || !/[A-Za-z0-9]/.test(fullText[len]);
6689
- if (fullText.startsWith(refLead) && boundaryAfter(refLead.length)) {
6690
- // Literal shared lead word(s) — "WHEREAS, …" after a "WHEREAS, …" sibling.
6691
- // Exact-case with a token boundary so "Note" never claims "Notebook".
6692
- createdLeadLen = refLead.length;
6693
- }
6694
- else {
6695
- const refMarker = LEAD_MARKER_PATTERN.exec(refLead);
6696
- const createdMarker = LEAD_MARKER_PATTERN.exec(fullText);
6697
- // Marker tokens: the reference's first run must BE the token, and the
6698
- // created text must open with a token of the same category (letters
6699
- // with letters, digits with digits — "(m)" doesn't restyle "1.").
6700
- if (refMarker &&
6701
- createdMarker &&
6702
- refMarker[0] === refLead &&
6703
- /\d/.test(refMarker[0]) === /\d/.test(createdMarker[0])) {
6704
- createdLeadLen = createdMarker[0].length;
6705
- }
6706
- }
6533
+ const fullText = createdText || withoutTruncationEllipsis(createdPreview);
6534
+ const createdLeadLen = createdLeadLengthOf(split.leadText, fullText);
6707
6535
  if (createdLeadLen === 0)
6708
6536
  return null;
6709
- const markerInline = emphasisedLookOf(inlineFromSnapshotRun(leadRun));
6537
+ const markerInline = emphasisedLookOf(inlineFromSnapshotRun(split.lead));
6710
6538
  if (!markerInline)
6711
6539
  return null;
6712
- const bodyInline = baseRunLookOf(bodyRun);
6540
+ const bodyInline = baseRunLookOf(split.body);
6713
6541
  let changed = await applyInlineQuiet(formatFn, {
6714
6542
  blockId: created.nodeId,
6715
6543
  start: 0,
@@ -6727,21 +6555,10 @@ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6727
6555
  }
6728
6556
  return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
6729
6557
  }
6730
- /**
6731
- * Copy a reference block's RUN pattern onto a created block: the body's font
6732
- * and size over the whole block, then the label look over the new block's own
6733
- * label span.
6734
- *
6735
- * Complements matchLeadMarkerPattern, which requires either a shared literal
6736
- * lead word or a marker token AND a two-run reference. This one reads the
6737
- * reference's runs (`query.match`, the only read that reports run styling) and
6738
- * handles the shapes that leaves out. A single uniform run has no label, so
6739
- * nothing but its font transfers.
6740
- */
6741
6558
  async function matchBlockRunPattern(doc, reference, created, createdText) {
6742
6559
  const formatFn = maybeMethod(doc, ['format', 'apply']);
6743
- const referenceText = (reference.textPreview ?? '').replace(/…\s*$/, '').trim();
6744
- const fullText = createdText || (created.textPreview ?? '').replace(/…\s*$/, '');
6560
+ const referenceText = withoutTruncationEllipsis(reference.textPreview ?? '').trim();
6561
+ const fullText = createdText || withoutTruncationEllipsis(created.textPreview ?? '');
6745
6562
  if (!formatFn || !reference.nodeId || !created.nodeId || !referenceText || !fullText)
6746
6563
  return null;
6747
6564
  const pattern = await listItemRunPattern(doc, referenceText, reference.nodeId);
@@ -6753,11 +6570,7 @@ async function matchBlockRunPattern(doc, reference, created, createdText) {
6753
6570
  const own = Object.keys(sampled).length > 0
6754
6571
  ? await effectiveBaseLookOf(doc, fullText, { nodeId: created.nodeId, nodeType: created.nodeType })
6755
6572
  : null;
6756
- const body = own === 'mixed'
6757
- ? {}
6758
- : own
6759
- ? Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value))
6760
- : sampled;
6573
+ const body = bodyLookToWrite(sampled, own);
6761
6574
  if (Object.keys(body).length > 0) {
6762
6575
  if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: body })) {
6763
6576
  applied.body = body;
@@ -6783,17 +6596,10 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6783
6596
  .filter(({ row }) => row.nodeId && !preIds.has(row.nodeId) && !row.isEmpty);
6784
6597
  if (createdRows.length === 0)
6785
6598
  return receipt;
6786
- // The placement anchor, when the action targeted one, is the user's own
6787
- // statement of "blend in with THIS" — prefer it over type-matched siblings.
6788
- const placement = isRecord(args?.placement) ? args.placement : null;
6789
- const anchorSelector = placement && isRecord(placement.selector) ? placement.selector : null;
6790
- const anchorId = anchorSelector && typeof anchorSelector.nodeId === 'string' ? anchorSelector.nodeId : null;
6599
+ const anchorId = placementAnchorIdOf(args);
6791
6600
  const anchorRow = anchorId ? (postBlocks.find((b) => b.nodeId === anchorId) ?? null) : null;
6792
- // When the caller styled any entry explicitly (runs/marks), it took
6793
- // ownership of intra-paragraph formatting — the automatic lead-marker
6794
- // pattern pass must not fight it.
6795
- const rawTexts = Array.isArray(args?.texts) ? args.texts : args?.text != null ? [args.text] : [];
6796
- const hasExplicitStyling = rawTexts.some((entry) => isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
6601
+ const rawTexts = insertedTextEntriesOf(args);
6602
+ const hasExplicitStyling = callerStyledAnEntry(rawTexts);
6797
6603
  const insertedTexts = rawTexts.map((entry) => {
6798
6604
  if (typeof entry === 'string')
6799
6605
  return entry;
@@ -6801,26 +6607,12 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6801
6607
  return spec ? itemSpecText(spec) : '';
6802
6608
  });
6803
6609
  const allApplied = [];
6804
- for (let ci = 0; ci < createdRows.length; ci += 1) {
6805
- const { row: created, index: createdIndex } = createdRows[ci];
6806
- // The first of several inserted blocks is the section TITLE — it should
6807
- // read like a heading (flush-left), so it must NOT inherit the body
6808
- // sibling's left indent even though it matches the same anchor.
6809
- const isTitle = createdRows.length > 1 && ci === 0;
6610
+ for (const [ci, { row: created, index: createdIndex }] of createdRows.entries()) {
6810
6611
  const createdText = insertedTexts[ci] ?? '';
6811
- const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, isTitle, !hasExplicitStyling,
6812
- // The texts the caller inserted, in order — the truncated preview
6813
- // cannot measure a label span or the end of the block.
6814
- createdText);
6612
+ const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, isSectionTitle(ci, createdRows.length), !hasExplicitStyling, createdText);
6815
6613
  let result = await match(anchorRow);
6816
- // A GROUP of paragraphs usually repeats the group before it: a signature
6817
- // block is name / "By:" / "Name:", and the new name line should look like
6818
- // the previous NAME line, not like the "Name:" line directly above it.
6819
- // Only tried when matching against the neighbour found nothing, so this
6820
- // cannot change a case that already had an answer.
6821
- const applied = isRecord(result?.applied) ? result.applied : null;
6822
- if (createdRows.length > 1 && (!applied || Object.keys(applied).length === 0)) {
6823
- const parallel = postBlocks[createdIndex - createdRows.length];
6614
+ if (shouldTryParallelGroup(createdRows.length, result)) {
6615
+ const parallel = postBlocks[parallelGroupIndexOf(createdIndex, createdRows.length)];
6824
6616
  if (parallel && preIds.has(parallel.nodeId) && !parallel.isEmpty) {
6825
6617
  result = (await match(parallel)) ?? result;
6826
6618
  }
@@ -6828,13 +6620,12 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, avail
6828
6620
  if (result)
6829
6621
  allApplied.push(result);
6830
6622
  }
6831
- if (allApplied.length === 0)
6623
+ const contextualFormatting = contextualFormattingOf(allApplied);
6624
+ if (!contextualFormatting)
6832
6625
  return receipt;
6833
- return { ...receipt, contextualFormatting: allApplied.length === 1 ? allApplied[0] : allApplied };
6626
+ return { ...receipt, contextualFormatting };
6834
6627
  }
6835
6628
  catch (err) {
6836
- // Formatting matching is best-effort polish: never fail a successful
6837
- // insert because of it — but the receipt says it was skipped.
6838
6629
  return {
6839
6630
  ...receipt,
6840
6631
  contextualFormatting: { skipped: reasonOf(err) },
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ var guards = require('../guards.cjs');
4
+
5
+ const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
6
+ const TRUNCATED_PREVIEW_TAIL = /…\s*$/;
7
+ const REFERENCE_PROBE_CHARS = 60;
8
+ const LEAD_RUN_MAX_CHARS = 40;
9
+ const MARKER_TOKEN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
10
+ const ASCII_WORD_CHARACTER = /[A-Za-z0-9]/;
11
+ const DIGIT = /\d/;
12
+ function withoutTruncationEllipsis(preview) {
13
+ return preview.replace(TRUNCATED_PREVIEW_TAIL, '');
14
+ }
15
+ function referenceProbeOf(referencePreview) {
16
+ return withoutTruncationEllipsis(referencePreview).slice(0, REFERENCE_PROBE_CHARS).trim();
17
+ }
18
+ function differsInEmphasis(a, b) {
19
+ return RUN_BOOL_MARKS.some((key) => Boolean(a[key]) !== Boolean(b[key]));
20
+ }
21
+ function leadBodySplitOf(referenceRuns) {
22
+ const [lead, body] = referenceRuns;
23
+ if (!lead || !body || !differsInEmphasis(lead, body))
24
+ return null;
25
+ const leadText = lead.text.trimEnd();
26
+ const isShortOpeningRun = leadText.length > 0 && leadText.length <= LEAD_RUN_MAX_CHARS && lead.start === 0;
27
+ return isShortOpeningRun ? { lead, body, leadText } : null;
28
+ }
29
+ function endsAtTokenBoundary(text, length) {
30
+ return length >= text.length || !ASCII_WORD_CHARACTER.test(text.charAt(length));
31
+ }
32
+ function sharedLiteralLeadLength(referenceLead, createdText) {
33
+ const opensWithSameLead = createdText.startsWith(referenceLead) && endsAtTokenBoundary(createdText, referenceLead.length);
34
+ return opensWithSameLead ? referenceLead.length : 0;
35
+ }
36
+ function sameCategoryMarkerLength(referenceLead, createdText) {
37
+ const referenceMarker = MARKER_TOKEN.exec(referenceLead)?.[0];
38
+ const createdMarker = MARKER_TOKEN.exec(createdText)?.[0];
39
+ if (referenceMarker == null || createdMarker == null || referenceMarker !== referenceLead)
40
+ return 0;
41
+ return DIGIT.test(referenceMarker) === DIGIT.test(createdMarker) ? createdMarker.length : 0;
42
+ }
43
+ function createdLeadLengthOf(referenceLead, createdText) {
44
+ return sharedLiteralLeadLength(referenceLead, createdText) || sameCategoryMarkerLength(referenceLead, createdText);
45
+ }
46
+ function bodyLookToWrite(sampled, own) {
47
+ if (own === 'mixed')
48
+ return {};
49
+ if (!own)
50
+ return sampled;
51
+ return Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value));
52
+ }
53
+ function placementAnchorIdOf(args) {
54
+ const placement = args?.placement;
55
+ const selector = guards.isRecord(placement) ? placement.selector : null;
56
+ return guards.isRecord(selector) && typeof selector.nodeId === 'string' ? selector.nodeId : null;
57
+ }
58
+ function insertedTextEntriesOf(args) {
59
+ const texts = args?.texts;
60
+ if (Array.isArray(texts))
61
+ return texts;
62
+ const text = args?.text;
63
+ return text != null ? [text] : [];
64
+ }
65
+ function callerStyledAnEntry(entries) {
66
+ return entries.some((entry) => guards.isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
67
+ }
68
+ function isSectionTitle(insertedIndex, insertedCount) {
69
+ return insertedCount > 1 && insertedIndex === 0;
70
+ }
71
+ function appliedNothing(result) {
72
+ const applied = result?.applied;
73
+ return !guards.isRecord(applied) || Object.keys(applied).length === 0;
74
+ }
75
+ function shouldTryParallelGroup(insertedCount, result) {
76
+ return insertedCount > 1 && appliedNothing(result);
77
+ }
78
+ function parallelGroupIndexOf(createdIndex, insertedCount) {
79
+ return createdIndex - insertedCount;
80
+ }
81
+ function contextualFormattingOf(results) {
82
+ const [only, ...rest] = results;
83
+ if (!only)
84
+ return null;
85
+ return rest.length === 0 ? only : results;
86
+ }
87
+
88
+ exports.LEAD_RUN_MAX_CHARS = LEAD_RUN_MAX_CHARS;
89
+ exports.REFERENCE_PROBE_CHARS = REFERENCE_PROBE_CHARS;
90
+ exports.RUN_BOOL_MARKS = RUN_BOOL_MARKS;
91
+ exports.bodyLookToWrite = bodyLookToWrite;
92
+ exports.callerStyledAnEntry = callerStyledAnEntry;
93
+ exports.contextualFormattingOf = contextualFormattingOf;
94
+ exports.createdLeadLengthOf = createdLeadLengthOf;
95
+ exports.insertedTextEntriesOf = insertedTextEntriesOf;
96
+ exports.isSectionTitle = isSectionTitle;
97
+ exports.leadBodySplitOf = leadBodySplitOf;
98
+ exports.parallelGroupIndexOf = parallelGroupIndexOf;
99
+ exports.placementAnchorIdOf = placementAnchorIdOf;
100
+ exports.referenceProbeOf = referenceProbeOf;
101
+ exports.shouldTryParallelGroup = shouldTryParallelGroup;
102
+ exports.withoutTruncationEllipsis = withoutTruncationEllipsis;
@@ -0,0 +1,22 @@
1
+ import type { SnapshotRun } from './doc-snapshot.js';
2
+ export declare const RUN_BOOL_MARKS: readonly ["bold", "italic", "underline", "strike"];
3
+ export declare const REFERENCE_PROBE_CHARS = 60;
4
+ export declare const LEAD_RUN_MAX_CHARS = 40;
5
+ export declare function withoutTruncationEllipsis(preview: string): string;
6
+ export declare function referenceProbeOf(referencePreview: string): string;
7
+ export type LeadBodySplit = {
8
+ lead: SnapshotRun;
9
+ body: SnapshotRun;
10
+ leadText: string;
11
+ };
12
+ export declare function leadBodySplitOf(referenceRuns: readonly SnapshotRun[]): LeadBodySplit | null;
13
+ export declare function createdLeadLengthOf(referenceLead: string, createdText: string): number;
14
+ export type BlockBaseLook = Record<string, unknown> | 'mixed' | null;
15
+ export declare function bodyLookToWrite(sampled: Record<string, unknown>, own: BlockBaseLook): Record<string, unknown>;
16
+ export declare function placementAnchorIdOf(args: Record<string, unknown> | undefined): string | null;
17
+ export declare function insertedTextEntriesOf(args: Record<string, unknown> | undefined): unknown[];
18
+ export declare function callerStyledAnEntry(entries: readonly unknown[]): boolean;
19
+ export declare function isSectionTitle(insertedIndex: number, insertedCount: number): boolean;
20
+ export declare function shouldTryParallelGroup(insertedCount: number, result: Record<string, unknown> | null): boolean;
21
+ export declare function parallelGroupIndexOf(createdIndex: number, insertedCount: number): number;
22
+ export declare function contextualFormattingOf(results: Record<string, unknown>[]): Record<string, unknown> | Record<string, unknown>[] | null;
@@ -0,0 +1,83 @@
1
+ import { isRecord } from '../guards.js';
2
+ export const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
3
+ const TRUNCATED_PREVIEW_TAIL = /…\s*$/;
4
+ export const REFERENCE_PROBE_CHARS = 60;
5
+ export const LEAD_RUN_MAX_CHARS = 40;
6
+ const MARKER_TOKEN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
7
+ const ASCII_WORD_CHARACTER = /[A-Za-z0-9]/;
8
+ const DIGIT = /\d/;
9
+ export function withoutTruncationEllipsis(preview) {
10
+ return preview.replace(TRUNCATED_PREVIEW_TAIL, '');
11
+ }
12
+ export function referenceProbeOf(referencePreview) {
13
+ return withoutTruncationEllipsis(referencePreview).slice(0, REFERENCE_PROBE_CHARS).trim();
14
+ }
15
+ function differsInEmphasis(a, b) {
16
+ return RUN_BOOL_MARKS.some((key) => Boolean(a[key]) !== Boolean(b[key]));
17
+ }
18
+ export function leadBodySplitOf(referenceRuns) {
19
+ const [lead, body] = referenceRuns;
20
+ if (!lead || !body || !differsInEmphasis(lead, body))
21
+ return null;
22
+ const leadText = lead.text.trimEnd();
23
+ const isShortOpeningRun = leadText.length > 0 && leadText.length <= LEAD_RUN_MAX_CHARS && lead.start === 0;
24
+ return isShortOpeningRun ? { lead, body, leadText } : null;
25
+ }
26
+ function endsAtTokenBoundary(text, length) {
27
+ return length >= text.length || !ASCII_WORD_CHARACTER.test(text.charAt(length));
28
+ }
29
+ function sharedLiteralLeadLength(referenceLead, createdText) {
30
+ const opensWithSameLead = createdText.startsWith(referenceLead) && endsAtTokenBoundary(createdText, referenceLead.length);
31
+ return opensWithSameLead ? referenceLead.length : 0;
32
+ }
33
+ function sameCategoryMarkerLength(referenceLead, createdText) {
34
+ const referenceMarker = MARKER_TOKEN.exec(referenceLead)?.[0];
35
+ const createdMarker = MARKER_TOKEN.exec(createdText)?.[0];
36
+ if (referenceMarker == null || createdMarker == null || referenceMarker !== referenceLead)
37
+ return 0;
38
+ return DIGIT.test(referenceMarker) === DIGIT.test(createdMarker) ? createdMarker.length : 0;
39
+ }
40
+ export function createdLeadLengthOf(referenceLead, createdText) {
41
+ return sharedLiteralLeadLength(referenceLead, createdText) || sameCategoryMarkerLength(referenceLead, createdText);
42
+ }
43
+ export function bodyLookToWrite(sampled, own) {
44
+ if (own === 'mixed')
45
+ return {};
46
+ if (!own)
47
+ return sampled;
48
+ return Object.fromEntries(Object.entries(sampled).filter(([key, value]) => own[key] !== value));
49
+ }
50
+ export function placementAnchorIdOf(args) {
51
+ const placement = args?.placement;
52
+ const selector = isRecord(placement) ? placement.selector : null;
53
+ return isRecord(selector) && typeof selector.nodeId === 'string' ? selector.nodeId : null;
54
+ }
55
+ export function insertedTextEntriesOf(args) {
56
+ const texts = args?.texts;
57
+ if (Array.isArray(texts))
58
+ return texts;
59
+ const text = args?.text;
60
+ return text != null ? [text] : [];
61
+ }
62
+ export function callerStyledAnEntry(entries) {
63
+ return entries.some((entry) => isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
64
+ }
65
+ export function isSectionTitle(insertedIndex, insertedCount) {
66
+ return insertedCount > 1 && insertedIndex === 0;
67
+ }
68
+ function appliedNothing(result) {
69
+ const applied = result?.applied;
70
+ return !isRecord(applied) || Object.keys(applied).length === 0;
71
+ }
72
+ export function shouldTryParallelGroup(insertedCount, result) {
73
+ return insertedCount > 1 && appliedNothing(result);
74
+ }
75
+ export function parallelGroupIndexOf(createdIndex, insertedCount) {
76
+ return createdIndex - insertedCount;
77
+ }
78
+ export function contextualFormattingOf(results) {
79
+ const [only, ...rest] = results;
80
+ if (!only)
81
+ return null;
82
+ return rest.length === 0 ? only : results;
83
+ }
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const SPACED_DASH_DELIMITERS = [' — ', ' – ', ' - '];
4
+ const BARE_DASH_DELIMITERS = ['—', '–'];
5
+ const LEAD_IN_DELIMITERS = [':', ...SPACED_DASH_DELIMITERS, ...BARE_DASH_DELIMITERS];
6
+ const DELIMITERS_A_LABEL_RUN_MAY_END_WITH = ['. ', ': ', ...LEAD_IN_DELIMITERS];
7
+ const LEAD_IN_MAX_CHARS = 90;
8
+ const LEAD_IN_BODY_SAMPLE_CHARS = 40;
9
+ const LEAD_IN_QUOTES = ['"', '“', '‘', "'"];
10
+ const QUOTED_TERM_OPENER_WINDOW = 2;
11
+ const SINGLE_WORD_THEN_SPACE = /^\S+\s$/;
12
+ function labelEndAt(text, delimiter) {
13
+ const index = text.indexOf(delimiter);
14
+ return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
15
+ }
16
+ function earliestLeadInEnd(text) {
17
+ const found = LEAD_IN_DELIMITERS.map((delimiter) => labelEndAt(text, delimiter)).filter((index) => index != null);
18
+ return found.length > 0 ? Math.min(...found) : null;
19
+ }
20
+ function leadInEndOf(text, siblingDelimiter) {
21
+ const siblingEnd = siblingDelimiter ? labelEndAt(text, siblingDelimiter) : null;
22
+ return siblingEnd ?? earliestLeadInEnd(text);
23
+ }
24
+ function labelRunTailShapeOf(labelText) {
25
+ const trimmed = labelText.trimEnd();
26
+ const delimiter = DELIMITERS_A_LABEL_RUN_MAY_END_WITH.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
27
+ if (!delimiter)
28
+ return null;
29
+ const occurrence = labelText.split(delimiter).length - 1;
30
+ return occurrence > 0 ? { kind: 'inside', delimiter, occurrence } : null;
31
+ }
32
+ function emphasisedOpeningWordOf(blockText, labelText) {
33
+ const opensWithSingleWord = SINGLE_WORD_THEN_SPACE.test(labelText) && blockText.length > labelText.length && blockText.startsWith(labelText);
34
+ return opensWithSingleWord ? { kind: 'word', label: labelText.trimEnd() } : null;
35
+ }
36
+ function leadInShapeOf(blockText, labelText, quote) {
37
+ if (quote)
38
+ return { kind: 'quoted', quote };
39
+ const delimiterAfterLabel = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
40
+ if (delimiterAfterLabel)
41
+ return { kind: 'before', delimiter: delimiterAfterLabel };
42
+ return labelRunTailShapeOf(labelText) ?? emphasisedOpeningWordOf(blockText, labelText);
43
+ }
44
+ function isWordBoundary(character) {
45
+ return character === '' || /\s/.test(character);
46
+ }
47
+ function openingWordSpanOf(text, shape) {
48
+ const opensWithSameWord = text.startsWith(shape.label) && isWordBoundary(text.charAt(shape.label.length));
49
+ return opensWithSameWord ? { start: 0, end: shape.label.length } : null;
50
+ }
51
+ function quotedTermSpanOf(text, shape) {
52
+ const opener = text.slice(0, QUOTED_TERM_OPENER_WINDOW).indexOf(shape.quote);
53
+ if (opener < 0)
54
+ return null;
55
+ const termStart = opener + 1;
56
+ const close = text.indexOf(shape.quote, termStart);
57
+ const closesAfterNonEmptyTerm = close > termStart;
58
+ if (!closesAfterNonEmptyTerm || close > LEAD_IN_MAX_CHARS)
59
+ return null;
60
+ return { start: termStart, end: close };
61
+ }
62
+ function nthIndexOf(text, search, occurrence) {
63
+ let at = -1;
64
+ for (let seen = 0; seen < occurrence; seen += 1) {
65
+ at = text.indexOf(search, at + 1);
66
+ if (at < 0)
67
+ return -1;
68
+ }
69
+ return at;
70
+ }
71
+ function spanThroughSameDelimiterCountOf(text, shape) {
72
+ const at = nthIndexOf(text, shape.delimiter, shape.occurrence);
73
+ if (at <= 0 || at > LEAD_IN_MAX_CHARS)
74
+ return null;
75
+ return { start: 0, end: at + shape.delimiter.length };
76
+ }
77
+ function spanBeforeDelimiterOf(text, shape) {
78
+ const end = leadInEndOf(text, shape.delimiter);
79
+ return end == null ? null : { start: 0, end };
80
+ }
81
+ function labelLeadInSpanOf(text, shape) {
82
+ switch (shape.kind) {
83
+ case 'word':
84
+ return openingWordSpanOf(text, shape);
85
+ case 'quoted':
86
+ return quotedTermSpanOf(text, shape);
87
+ case 'inside':
88
+ return spanThroughSameDelimiterCountOf(text, shape);
89
+ case 'before':
90
+ return spanBeforeDelimiterOf(text, shape);
91
+ }
92
+ }
93
+
94
+ exports.LEAD_IN_BODY_SAMPLE_CHARS = LEAD_IN_BODY_SAMPLE_CHARS;
95
+ exports.LEAD_IN_DELIMITERS = LEAD_IN_DELIMITERS;
96
+ exports.LEAD_IN_MAX_CHARS = LEAD_IN_MAX_CHARS;
97
+ exports.LEAD_IN_QUOTES = LEAD_IN_QUOTES;
98
+ exports.labelLeadInSpanOf = labelLeadInSpanOf;
99
+ exports.leadInEndOf = leadInEndOf;
100
+ exports.leadInShapeOf = leadInShapeOf;