@appsoftwareltd/etherpk-mcp 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -23,15 +23,15 @@ import * as encoding from "lib0/encoding";
23
23
  import { parse, stringify } from "yaml";
24
24
  import "fake-indexeddb/auto";
25
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
- import "@codemirror/language";
27
- import { languages } from "@codemirror/language-data";
28
26
  import { classHighlighter, highlightTree } from "@lezer/highlight";
27
+ import { LanguageDescription } from "@codemirror/language";
28
+ import { languages } from "@codemirror/language-data";
29
29
  import MarkdownIt from "markdown-it";
30
30
  import katex from "katex";
31
31
  import Mustache from "mustache";
32
32
  var package_default = {
33
33
  name: "@appsoftwareltd/etherpk-mcp",
34
- version: "0.7.1",
34
+ version: "0.8.0",
35
35
  license: "Elastic-2.0",
36
36
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
37
37
  type: "module",
@@ -39,10 +39,11 @@ var package_default = {
39
39
  bin: { "etherpk-mcp": "./bin/etherpk-mcp.js" },
40
40
  files: ["bin", "dist"],
41
41
  scripts: {
42
- "build": "vite build",
42
+ "client-tsconfig": "pnpm --dir ../client exec svelte-kit sync",
43
+ "build": "pnpm client-tsconfig && vite build",
43
44
  "check": "tsc -p tsconfig.json --noEmit",
44
- "test": "vitest run",
45
- "test:watch": "vitest",
45
+ "test": "pnpm client-tsconfig && vitest run",
46
+ "test:watch": "pnpm client-tsconfig && vitest",
46
47
  "prepack": "pnpm build"
47
48
  },
48
49
  dependencies: {
@@ -80,7 +81,7 @@ var package_default = {
80
81
  homepage: "https://docs.etherpk.com/using-ai-agents-with-your-notes",
81
82
  repository: {
82
83
  "type": "git",
83
- "url": "https://github.com/appsoftwareltd/etherpk",
84
+ "url": "https://github.com/appsoftwareltd/etherpk-client",
84
85
  "directory": "apps/mcp"
85
86
  },
86
87
  keywords: [
@@ -1393,50 +1394,6 @@ function wrapOo1Db(db) {
1393
1394
  };
1394
1395
  }
1395
1396
  //#endregion
1396
- //#region ../client/src/lib/storage/fs/frontmatter-span.ts
1397
- /** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
1398
- var DELIMITER = /^---[ \t]*\r?$/;
1399
- /** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
1400
- function frontmatterSpan(text) {
1401
- const firstBreak = text.indexOf("\n");
1402
- if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
1403
- const bodyFrom = firstBreak + 1;
1404
- let lineStart = bodyFrom;
1405
- let lines = 1;
1406
- while (lineStart <= text.length) {
1407
- const nextBreak = text.indexOf("\n", lineStart);
1408
- const lineEnd = nextBreak === -1 ? text.length : nextBreak;
1409
- lines += 1;
1410
- if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
1411
- let bodyTo = Math.max(bodyFrom, lineStart - 1);
1412
- if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
1413
- return {
1414
- end: nextBreak === -1 ? text.length : nextBreak + 1,
1415
- lines,
1416
- body: text.slice(bodyFrom, bodyTo),
1417
- bodyFrom,
1418
- bodyTo
1419
- };
1420
- }
1421
- if (nextBreak === -1) break;
1422
- lineStart = nextBreak + 1;
1423
- }
1424
- return null;
1425
- }
1426
- /**
1427
- * How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
1428
- * document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
1429
- * (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
1430
- * in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
1431
- * the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
1432
- * would be the only O(n) step in an otherwise line-local pass.
1433
- */
1434
- function frontmatterLines(lines) {
1435
- if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
1436
- for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
1437
- return 0;
1438
- }
1439
- //#endregion
1440
1397
  //#region ../client/src/lib/document/fenced-code.ts
1441
1398
  /** A backtick fence line, tolerant of a leading plain-bullet marker (`- `) so `- ``` ` is a fence.
1442
1399
  * Backticks only (this slice) — tildes are left untouched, so they are not auto-completed. */
@@ -1518,6 +1475,57 @@ function fencedBlocks(lines, closerTolerance = 0) {
1518
1475
  }
1519
1476
  return blocks;
1520
1477
  }
1478
+ /**
1479
+ * A line of a fenced block as code: the indentation up to the fence's column is structure (the
1480
+ * block's place in the outline) and goes; whatever lies past it is the code's own and stays.
1481
+ */
1482
+ function codeLineText(line, fenceColumn) {
1483
+ return line.length - line.trimStart().length >= fenceColumn ? line.slice(fenceColumn) : line.trimStart();
1484
+ }
1485
+ //#endregion
1486
+ //#region ../client/src/lib/storage/fs/frontmatter-span.ts
1487
+ /** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
1488
+ var DELIMITER = /^---[ \t]*\r?$/;
1489
+ /** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
1490
+ function frontmatterSpan(text) {
1491
+ const firstBreak = text.indexOf("\n");
1492
+ if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
1493
+ const bodyFrom = firstBreak + 1;
1494
+ let lineStart = bodyFrom;
1495
+ let lines = 1;
1496
+ while (lineStart <= text.length) {
1497
+ const nextBreak = text.indexOf("\n", lineStart);
1498
+ const lineEnd = nextBreak === -1 ? text.length : nextBreak;
1499
+ lines += 1;
1500
+ if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
1501
+ let bodyTo = Math.max(bodyFrom, lineStart - 1);
1502
+ if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
1503
+ return {
1504
+ end: nextBreak === -1 ? text.length : nextBreak + 1,
1505
+ lines,
1506
+ body: text.slice(bodyFrom, bodyTo),
1507
+ bodyFrom,
1508
+ bodyTo
1509
+ };
1510
+ }
1511
+ if (nextBreak === -1) break;
1512
+ lineStart = nextBreak + 1;
1513
+ }
1514
+ return null;
1515
+ }
1516
+ /**
1517
+ * How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
1518
+ * document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
1519
+ * (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
1520
+ * in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
1521
+ * the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
1522
+ * would be the only O(n) step in an otherwise line-local pass.
1523
+ */
1524
+ function frontmatterLines(lines) {
1525
+ if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
1526
+ for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
1527
+ return 0;
1528
+ }
1521
1529
  " ".repeat(2);
1522
1530
  /** Width of the `- ` marker, which sets a bullet's content column (`indent + MARKER_WIDTH`). */
1523
1531
  var MARKER_WIDTH = 2;
@@ -1729,11 +1737,17 @@ function normaliseIndentUnit(text) {
1729
1737
  * 3. **Continuation lines** — non-bullet lines indented to a bullet's content
1730
1738
  * column belong to the *same* block (the soft-newline-within-a-block).
1731
1739
  *
1740
+ * A complete [[Fenced Code Block]] is opaque to all three: it belongs whole to the block its
1741
+ * opener sits in, so a blank line, a `# comment` or a `- item` inside it is code, never a block
1742
+ * boundary, a heading or a bullet. "Complete" is the editor's pairing (`fencedBlocks`), the same
1743
+ * one the outline walk (`indent-unit.ts`) takes fences whole by.
1744
+ *
1732
1745
  * A block has a kind (heading / paragraph / bullet / task). Blocks have no
1733
1746
  * persistent identity: a block is its source range in the current parse.
1734
1747
  *
1735
- * Pure and DOM-free; the editor (keyboard ops, fold) and the derived index consume
1736
- * it. See Dual Mode Editor.md.
1748
+ * Pure and DOM-free; the derived index (`index-derive.ts`) and the publisher's navigation
1749
+ * (`publish/nav.ts`) consume it. The editor reads the same outline through `indent-unit.ts`.
1750
+ * See Dual Mode Editor.md.
1737
1751
  */
1738
1752
  function indentOf(line) {
1739
1753
  return line.length - line.trimStart().length;
@@ -1762,7 +1776,21 @@ function taskDone(line) {
1762
1776
  */
1763
1777
  function parseBlocks(markdown) {
1764
1778
  const lines = markdown.split("\n");
1765
- const outline = outlineLines(lines);
1779
+ const fences = fencedBlocks(lines);
1780
+ const outline = outlineLines(lines, fences);
1781
+ const fenceAt = new Map(fences.map((f) => [f.start, f]));
1782
+ /**
1783
+ * Add line `k` to a block's `buf` and return the line after it. A line that opens a complete
1784
+ * fenced block brings the whole block with it, so nothing inside the fence is read as structure.
1785
+ * A fence nested inside it starts within the range taken, so only outermost fences reach here.
1786
+ */
1787
+ const take = (k, buf) => {
1788
+ buf.push(lines[k].trimStart());
1789
+ const fence = fenceAt.get(k);
1790
+ if (!fence) return k + 1;
1791
+ for (let n = k + 1; n <= fence.end; n++) buf.push(codeLineText(lines[n], fence.fenceColumn));
1792
+ return fence.end + 1;
1793
+ };
1766
1794
  const flat = [];
1767
1795
  let i = 0;
1768
1796
  while (i < lines.length) {
@@ -1788,12 +1816,9 @@ function parseBlocks(markdown) {
1788
1816
  const contentCol = indentOf(lines[i]) + 2;
1789
1817
  const done = taskDone(lines[i]);
1790
1818
  const start = i;
1791
- const buf = [lines[i].trimStart()];
1792
- i++;
1793
- while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) {
1794
- buf.push(lines[i].trimStart());
1795
- i++;
1796
- }
1819
+ const buf = [];
1820
+ i = take(i, buf);
1821
+ while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) i = take(i, buf);
1797
1822
  flat.push({
1798
1823
  type: done === void 0 ? "bullet" : "task",
1799
1824
  depth,
@@ -1805,12 +1830,9 @@ function parseBlocks(markdown) {
1805
1830
  continue;
1806
1831
  }
1807
1832
  const start = i;
1808
- const buf = [lines[i].trimStart()];
1809
- i++;
1810
- while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start])) {
1811
- buf.push(lines[i].trimStart());
1812
- i++;
1813
- }
1833
+ const buf = [];
1834
+ i = take(i, buf);
1835
+ while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start])) i = take(i, buf);
1814
1836
  flat.push({
1815
1837
  type: "paragraph",
1816
1838
  depth,
@@ -1997,14 +2019,12 @@ function serialiseTags(tags) {
1997
2019
  /**
1998
2020
  * A predicate over source line numbers: is this line inside a [[Fenced Code Block]]?
1999
2021
  *
2000
- * The [[Block]] model does not treat fences as opaque, so `- [ ] x` written inside a fence
2001
- * parses as a [[Task]] like any other bullet. That was invisible while nothing listed tasks;
2002
- * the [[Tasks View]] makes it visible, as phantom tasks lifted out of code examples. Two
2003
- * things follow from excluding them here rather than in the block model: the editor's outliner
2004
- * behaviour is untouched (a fence's lines still belong to their block, which is what the
2005
- * keyboard layer expects), and a [[Protected Document]] - an `etherpk-cipher` fence - can never
2006
- * contribute a task, whatever it holds. That last one is a rule, not an optimisation: the
2007
- * [[Derived Index]] is plaintext at rest and outlives the session.
2022
+ * A `- [ ] x` written inside a fence must not become a [[Task]]: the [[Tasks View]] would list
2023
+ * phantom tasks lifted out of code examples. The [[Block]] model now takes a complete fence
2024
+ * whole (block-model.ts), so no task starts inside one; this scan stays for what the block model
2025
+ * cannot know, that a [[Protected Document]] - an `etherpk-cipher` fence - never contributes a
2026
+ * task, whatever it holds. That is a rule, not an optimisation: the [[Derived Index]] is
2027
+ * plaintext at rest and outlives the session.
2008
2028
  *
2009
2029
  * "Inside a fence" is the EDITOR's answer, not a second one: the column-scoped pairing of
2010
2030
  * `fencedBlocks` (Editor Content Rules → "Inside a block"), so a task is indexed exactly when
@@ -2040,6 +2060,15 @@ function blockLabel(block) {
2040
2060
  return bulletLabel(first);
2041
2061
  }
2042
2062
  /**
2063
+ * A block as a reader sees it: the marker stripped as in its {@link blockLabel}, and every line
2064
+ * after the first kept - continuation lines and fenced code, which a label drops. What a
2065
+ * references [[View]] quotes and a semantic passage embeds.
2066
+ */
2067
+ function blockContent(block) {
2068
+ const newline = block.text.indexOf("\n");
2069
+ return newline === -1 ? block.label : block.label + block.text.slice(newline);
2070
+ }
2071
+ /**
2043
2072
  * The label of a bullet or [[Task]] line — its marker and checkbox stripped, indentation
2044
2073
  * ignored. Exported because the [[Tasks View]]'s write-back guard has to ask "is the line in
2045
2074
  * the document still the task the index recorded?", and the only honest way to answer is with
@@ -2225,9 +2254,7 @@ var OVERLAP_MAX_CHARS = Math.floor(PASSAGE_BUDGET_CHARS / 4);
2225
2254
  var CRUMB = " > ";
2226
2255
  /** A block's text with its bullet / task / heading marker gone, continuation lines kept. */
2227
2256
  function blockBody(block) {
2228
- const newline = block.text.indexOf("\n");
2229
- const rest = newline === -1 ? "" : block.text.slice(newline);
2230
- return stripWikilinkBrackets(block.label + rest);
2257
+ return stripWikilinkBrackets(blockContent(block));
2231
2258
  }
2232
2259
  /** `[[Physics]]` embeds as `Physics`: the brackets are syntax, not meaning. */
2233
2260
  function stripWikilinkBrackets(text) {
@@ -2545,7 +2572,7 @@ function createSchema(db) {
2545
2572
  db.exec(SCHEMA$1);
2546
2573
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
2547
2574
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
2548
- db.exec(`PRAGMA user_version = 11`);
2575
+ db.exec(`PRAGMA user_version = 12`);
2549
2576
  }
2550
2577
  function activeIndexGeneration(db) {
2551
2578
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -2566,7 +2593,7 @@ function advanceIndexRevision(db) {
2566
2593
  */
2567
2594
  function isUsableIndex(db) {
2568
2595
  try {
2569
- return db.all("PRAGMA user_version")[0]?.user_version === 11;
2596
+ return db.all("PRAGMA user_version")[0]?.user_version === 12;
2570
2597
  } catch {
2571
2598
  return false;
2572
2599
  }
@@ -3077,16 +3104,40 @@ function collectSubtree(blocks, rootId) {
3077
3104
  const b = blocks[id];
3078
3105
  if (!b) return;
3079
3106
  out.push({
3080
- label: b.label,
3107
+ text: blockContent(b),
3081
3108
  depth,
3082
- isMatch: id === rootId
3109
+ isMatch: id === rootId,
3110
+ ...b.kind === "task" ? { done: b.done === true } : {}
3083
3111
  });
3084
3112
  for (const child of childrenByParent.get(id) ?? []) walk(child.localId, depth + 1);
3085
3113
  };
3086
3114
  walk(rootId, 0);
3087
3115
  return out;
3088
3116
  }
3089
- /** Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. */
3117
+ /**
3118
+ * Widen `[start, end)` so that neither end falls inside a complete fenced code block. Half a code
3119
+ * block is no excerpt at all, and an opener cut from its closer would render as raw backticks.
3120
+ */
3121
+ function widenToFences(text, start, end) {
3122
+ const lines = text.split("\n");
3123
+ const lineStarts = [];
3124
+ let offset = 0;
3125
+ for (const line of lines) {
3126
+ lineStarts.push(offset);
3127
+ offset += line.length + 1;
3128
+ }
3129
+ for (const fence of fencedBlocks(lines)) {
3130
+ const from = lineStarts[fence.start];
3131
+ const to = lineStarts[fence.end] + lines[fence.end].length;
3132
+ if (start > from && start < to) start = from;
3133
+ if (end > from && end < to) end = to;
3134
+ }
3135
+ return [start, end];
3136
+ }
3137
+ /**
3138
+ * Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. The window grows past
3139
+ * MAX_CONTEXT rather than cut a fenced code block in two ({@link widenToFences}).
3140
+ */
3090
3141
  function truncateContext(text, matchStart, matchEnd) {
3091
3142
  if (text.length <= MAX_CONTEXT) return {
3092
3143
  text,
@@ -3095,17 +3146,17 @@ function truncateContext(text, matchStart, matchEnd) {
3095
3146
  truncated: false
3096
3147
  };
3097
3148
  const mid = Math.floor((matchStart + matchEnd) / 2);
3098
- let start = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
3099
- const end = Math.min(text.length, start + MAX_CONTEXT);
3100
- start = Math.max(0, end - MAX_CONTEXT);
3101
- const prefix = start > 0 ? "…" : "";
3102
- const suffix = end < text.length ? "…" : "";
3149
+ const windowStart = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
3150
+ const windowEnd = Math.min(text.length, windowStart + MAX_CONTEXT);
3151
+ const [start, end] = widenToFences(text, Math.max(0, windowEnd - MAX_CONTEXT), windowEnd);
3152
+ const prefix = start === 0 ? "" : text[start - 1] === "\n" ? "…\n" : "…";
3153
+ const suffix = end === text.length ? "" : text[end] === "\n" ? "\n…" : "…";
3103
3154
  const shift = prefix.length - start;
3104
3155
  return {
3105
3156
  text: prefix + text.slice(start, end) + suffix,
3106
3157
  matchStart: Math.max(0, matchStart + shift),
3107
3158
  matchEnd: Math.max(0, matchEnd + shift),
3108
- truncated: true
3159
+ truncated: prefix !== "" || suffix !== ""
3109
3160
  };
3110
3161
  }
3111
3162
  /** Build a reference: a block subtree for bullets/tasks, prose context otherwise. */
@@ -3167,7 +3218,7 @@ function backlinksFor(db, concept) {
3167
3218
  WHERE p.generation=? AND l.concept_key IN (${placeholders})`, [generation, ...names]);
3168
3219
  const blocksByPage = /* @__PURE__ */ new Map();
3169
3220
  for (const pid of new Set(hits.map((h) => h.page_id))) {
3170
- const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
3221
+ const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, done, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
3171
3222
  const blocks = [];
3172
3223
  for (const r of rows) blocks[r.local_id] = {
3173
3224
  localId: r.local_id,
@@ -3175,6 +3226,7 @@ function backlinksFor(db, concept) {
3175
3226
  ord: r.ord,
3176
3227
  kind: r.kind,
3177
3228
  depth: r.depth,
3229
+ ...r.done === null ? {} : { done: r.done === 1 },
3178
3230
  startLine: 0,
3179
3231
  endLine: 0,
3180
3232
  text: r.text,
@@ -3863,7 +3915,7 @@ function cacheRoot(env) {
3863
3915
  * recovered from a joined path (see there).
3864
3916
  */
3865
3917
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
3866
- var INDEX_FILE_NAME = `index.v11.sqlite`;
3918
+ var INDEX_FILE_NAME = `index.v12.sqlite`;
3867
3919
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
3868
3920
  function cacheFile(dir) {
3869
3921
  return join(dir, CACHE_FILE_NAME);
@@ -4649,13 +4701,8 @@ var performanceRecorder = createPerformanceRecorder({ enabled: typeof window !==
4649
4701
  var ENTITLEMENT_AUDIENCE = "urn:etherpk:sync-entitlements";
4650
4702
  var ENTITLEMENT_SERVICE = "managed-sync";
4651
4703
  /**
4652
- * Version-agnostic, like `isUuid` on the Server. The subject was matched by four different
4653
- * hand-rolled patterns that happened to agree only because billing account ids are v4 today:
4654
- * one accepted v1 to v5, another v1 to v8, another any 36 characters of hex and dashes
4655
- * (quality audit B4). If an id ever became a uuidv7 - which is what better-auth already mints
4656
- * - the strictest of them would have silently stopped matching, and the Stripe webhook's
4657
- * metadata fallback would have answered 409 "Unknown Stripe customer" for a real subscription.
4658
- * One pattern, here, next to the claim it belongs to.
4704
+ * Version-agnostic, like `isUuid` on the Server: any UUID version matches, so an id minted as a
4705
+ * uuidv7 is recognised as readily as a v4. One pattern, here, next to the claim it belongs to.
4659
4706
  */
4660
4707
  var ENTITLEMENT_SUBJECT_PATTERN = /^billing-account:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4661
4708
  var httpsUrl = z.url().refine((value) => {
@@ -5093,7 +5140,7 @@ function createSyncProtocol(overrides = {}) {
5093
5140
  }
5094
5141
  var defaultProtocol = createSyncProtocol();
5095
5142
  defaultProtocol.parseClientMessage;
5096
- var parseServerMessage$1 = defaultProtocol.parseServerMessage;
5143
+ var parseServerMessage = defaultProtocol.parseServerMessage;
5097
5144
  var serializeClientMessage$1 = defaultProtocol.serializeClientMessage;
5098
5145
  defaultProtocol.serializeServerMessage;
5099
5146
  //#endregion
@@ -5102,13 +5149,10 @@ defaultProtocol.serializeServerMessage;
5102
5149
  * The asset chunking contract, shared by the Client that uploads and the Sync Server that
5103
5150
  * signs the upload URLs.
5104
5151
  *
5105
- * These constants used to live only in the Client, which made the declared `size` on
5106
- * `POST /api/v1/sync/assets` a number the server had to take on trust: it counted the
5107
- * declared figure against the storage entitlement and then handed back presigned PUT URLs
5108
- * that bound nothing, so `size: 1024` bought unlimited bytes in the bucket (quality audit
5109
- * S2). With the contract stated here the server can derive exactly how many chunks an asset
5110
- * of a given size has, and exactly how long each chunk's ciphertext must be, and sign that
5111
- * length into the URL.
5152
+ * Shared so that the declared `size` on `POST /api/v1/sync/assets` is not taken on trust: from
5153
+ * the contract stated here the server derives exactly how many chunks an asset of a given size
5154
+ * has, and exactly how long each chunk's ciphertext must be, and signs that length into each
5155
+ * presigned PUT URL.
5112
5156
  *
5113
5157
  * Changing either constant is a protocol change: the Client's chunking and the Server's
5114
5158
  * signed lengths must move together, or every upload fails with an opaque 403.
@@ -5852,13 +5896,41 @@ function createDocSync(deps) {
5852
5896
  * added and checked only at the serialisation boundary so the document engine does not repeat
5853
5897
  * a transport constant on every operation.
5854
5898
  */
5855
- /** Returns null for anything malformed, oversized or from an unsupported protocol version. */
5856
- function parseServerMessage(raw) {
5857
- const result = parseServerMessage$1(raw);
5858
- if (!result.ok) return null;
5859
- const { v: _version, ...message } = result.value;
5860
- return message;
5899
+ function readServerMessage(raw) {
5900
+ const result = parseServerMessage(raw);
5901
+ if (result.ok) {
5902
+ const { v: _version, ...message } = result.value;
5903
+ return {
5904
+ kind: "message",
5905
+ message
5906
+ };
5907
+ }
5908
+ if (result.code === "unsupported_version") {
5909
+ const { v } = JSON.parse(raw);
5910
+ if (typeof v === "number" && Number.isInteger(v)) return {
5911
+ kind: "protocol_mismatch",
5912
+ serverVersion: v
5913
+ };
5914
+ }
5915
+ return { kind: "malformed" };
5861
5916
  }
5917
+ /**
5918
+ * The Sync Server speaks another sync protocol version, so nothing it sends can be read and
5919
+ * nothing this Client sends will be accepted. Retrying cannot fix it; one side has to be
5920
+ * upgraded. The message is for logs and the Headless Client; the browser maps the error to
5921
+ * its own copy in `sync-error-copy.ts`.
5922
+ */
5923
+ var SyncProtocolMismatchError = class extends Error {
5924
+ name = "SyncProtocolMismatchError";
5925
+ constructor(serverVersion, clientVersion = 2) {
5926
+ super(serverVersion < clientVersion ? `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: the server is older, and its operator needs to upgrade it.` : `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: this Client is older and needs upgrading.`);
5927
+ this.serverVersion = serverVersion;
5928
+ this.clientVersion = clientVersion;
5929
+ }
5930
+ get serverIsOlder() {
5931
+ return this.serverVersion < this.clientVersion;
5932
+ }
5933
+ };
5862
5934
  function serializeClientMessage(message) {
5863
5935
  return serializeClientMessage$1({
5864
5936
  v: 2,
@@ -6019,6 +6091,39 @@ function sanitizeQuickNotes(raw) {
6019
6091
  }
6020
6092
  return out;
6021
6093
  }
6094
+ new Intl.Segmenter(void 0, { granularity: "word" });
6095
+ /**
6096
+ * The spelling a dictionary is asked about: a curly apostrophe (`’`), which keyboards and
6097
+ * pastes produce, straightened, since Hunspell dictionaries spell contractions with `'`.
6098
+ */
6099
+ function spellingForm(word) {
6100
+ return word.replace(/’/g, "'");
6101
+ }
6102
+ /** Longer than any real word; a line this long is not one. */
6103
+ var MAX_WORD_LENGTH = 100;
6104
+ /**
6105
+ * The word as the dictionary keeps it, or null when it cannot be one entry. A curly apostrophe is
6106
+ * straightened, the form the checker compares (`spelling/words.ts` → `spellingForm`), so a word
6107
+ * added from `don’t` also accepts `don't`.
6108
+ */
6109
+ function normaliseDictionaryWord(raw) {
6110
+ const word = spellingForm(raw.trim().normalize("NFC"));
6111
+ if (word === "" || /\s/u.test(word) || word.length > MAX_WORD_LENGTH) return null;
6112
+ return word;
6113
+ }
6114
+ /** Well-formed words, deduped (first seen wins), capped: tolerant of a peer's or a newer client's list. */
6115
+ function sanitizeDictionaryWords(raw) {
6116
+ if (!Array.isArray(raw)) return [];
6117
+ const seen = /* @__PURE__ */ new Set();
6118
+ for (const entry of raw) {
6119
+ if (typeof entry !== "string") continue;
6120
+ const word = normaliseDictionaryWord(entry);
6121
+ if (word === null || seen.has(word)) continue;
6122
+ seen.add(word);
6123
+ if (seen.size >= 5e4) break;
6124
+ }
6125
+ return [...seen];
6126
+ }
6022
6127
  //#endregion
6023
6128
  //#region ../client/src/lib/storage/fs/frontmatter.ts
6024
6129
  /**
@@ -6391,6 +6496,12 @@ function createGraphSync(deps) {
6391
6496
  let socket;
6392
6497
  let open = false;
6393
6498
  let disposed = false;
6499
+ /**
6500
+ * Set when the Sync Server turned out to speak another protocol version. The session then
6501
+ * stops for good: every reconnect would meet the same server, and nothing either side
6502
+ * sends can be read by the other. Pending appends stay in the cache for a later session.
6503
+ */
6504
+ let protocolMismatch;
6394
6505
  const reportError = (error) => {
6395
6506
  if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
6396
6507
  };
@@ -6412,6 +6523,7 @@ function createGraphSync(deps) {
6412
6523
  function waitForCurrentConnection() {
6413
6524
  if (open && socket) return Promise.resolve();
6414
6525
  if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
6526
+ if (protocolMismatch) return Promise.reject(protocolMismatch);
6415
6527
  return new Promise((resolve, reject) => {
6416
6528
  connectionWaiters.add({
6417
6529
  resolve,
@@ -6451,7 +6563,7 @@ function createGraphSync(deps) {
6451
6563
  }
6452
6564
  /**
6453
6565
  * Documents the CURRENT socket has subscribed to. The relay keeps and forwards presence
6454
- * only for a subscribed document (ASTRA F11), and this mirrors that rule at the source:
6566
+ * only for a subscribed document, and this mirrors that rule at the source:
6455
6567
  * y-protocols renews every engine's awareness state every fifteen seconds, and an engine
6456
6568
  * created for a background walk starts with an empty `{}` state, so without the gate every
6457
6569
  * unretained engine encrypted and sent presence the relay would only drop. Cleared with
@@ -6469,9 +6581,9 @@ function createGraphSync(deps) {
6469
6581
  }
6470
6582
  /**
6471
6583
  * Subscribe to every retained document on a freshly opened socket, in batches the relay
6472
- * accepts. One message used to carry the whole set, and above the protocol's per-message
6473
- * limit the relay rejects it as invalid, which left a tab with many open views silently
6474
- * without live updates for anything after a reconnect (ASTRA F11).
6584
+ * accepts. Above the protocol's per-message limit the relay rejects a subscribe as invalid,
6585
+ * which would leave a tab with many open views silently without live updates after a
6586
+ * reconnect.
6475
6587
  */
6476
6588
  function subscribeRetained() {
6477
6589
  const docIds = [...retained.keys()];
@@ -6609,6 +6721,7 @@ function createGraphSync(deps) {
6609
6721
  if (deps.onRegistryChange) registryMap.observe(() => deps.onRegistryChange?.());
6610
6722
  const metaMap = root.doc.getMap("meta");
6611
6723
  const quickNotesArray = root.doc.getArray("quickNotes");
6724
+ const dictionaryMap = root.doc.getMap("spellingDictionary");
6612
6725
  const themesMap = root.doc.getMap("themes");
6613
6726
  /** A stored theme as a plain object, or null when the entry is not one. */
6614
6727
  function readTheme(id) {
@@ -6662,9 +6775,23 @@ function createGraphSync(deps) {
6662
6775
  publishNameOnceCaughtUp();
6663
6776
  }, () => {});
6664
6777
  }
6778
+ /** Report once, fail everything waiting for a connection, and close without reconnecting. */
6779
+ function stopForProtocolMismatch(serverVersion) {
6780
+ if (protocolMismatch) return;
6781
+ protocolMismatch = new SyncProtocolMismatchError(serverVersion);
6782
+ reportError(protocolMismatch);
6783
+ for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
6784
+ connectionWaiters.clear();
6785
+ socket?.close();
6786
+ }
6665
6787
  function handleMessage(raw) {
6666
- const message = parseServerMessage(raw);
6667
- if (!message) return;
6788
+ const read = readServerMessage(raw);
6789
+ if (read.kind === "protocol_mismatch") {
6790
+ stopForProtocolMismatch(read.serverVersion);
6791
+ return;
6792
+ }
6793
+ if (read.kind === "malformed") return;
6794
+ const message = read.message;
6668
6795
  if (message.type === "catchup_batch") performanceRecorder.mark("sync.catchup.batch", {
6669
6796
  rows: message.updates.length,
6670
6797
  bytes: new TextEncoder().encode(raw).byteLength,
@@ -6710,7 +6837,7 @@ function createGraphSync(deps) {
6710
6837
  * on open. Only rebuildable snapshot uploads use a volatile queue.
6711
6838
  */
6712
6839
  function connect() {
6713
- if (disposed) return;
6840
+ if (disposed || protocolMismatch) return;
6714
6841
  deps.token().then((token) => {
6715
6842
  if (disposed) return;
6716
6843
  const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
@@ -6752,7 +6879,7 @@ function createGraphSync(deps) {
6752
6879
  pending.reject(new WatermarkConnectionInterruptedError());
6753
6880
  }
6754
6881
  watermarkRequests.clear();
6755
- if (!disposed) setTimeout(connect, RECONNECT_MS);
6882
+ if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
6756
6883
  });
6757
6884
  }, () => {
6758
6885
  if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
@@ -6859,6 +6986,21 @@ function createGraphSync(deps) {
6859
6986
  return () => quickNotesArray.unobserve(listener);
6860
6987
  }
6861
6988
  }),
6989
+ spellingDictionary: () => ({
6990
+ list: () => sanitizeDictionaryWords([...dictionaryMap.keys()]),
6991
+ add(word) {
6992
+ dictionaryMap.set(word, true);
6993
+ },
6994
+ remove(words) {
6995
+ root.doc.transact(() => {
6996
+ for (const word of words) dictionaryMap.delete(word);
6997
+ });
6998
+ },
6999
+ observe(listener) {
7000
+ dictionaryMap.observe(listener);
7001
+ return () => dictionaryMap.unobserve(listener);
7002
+ }
7003
+ }),
6862
7004
  themes: () => ({
6863
7005
  list() {
6864
7006
  const out = [];
@@ -9455,6 +9597,16 @@ function isCalendarDay(value) {
9455
9597
  function isJournalConcept(concept) {
9456
9598
  return isCalendarDay(concept);
9457
9599
  }
9600
+ /**
9601
+ * Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
9602
+ *
9603
+ * A page given one sits in `pages/` answering to the day, which is what an older version left
9604
+ * behind when a Draft for a date promoted to a page. Creating a page and renaming one both
9605
+ * refuse with this, on both backends, so the copy is the same wherever the user meets it.
9606
+ */
9607
+ function dayIsNotAPageName(day) {
9608
+ return `“${day.trim()}” is a date, and a date is the name of that day's journal entry, so a page cannot be called that. Choose a different name.`;
9609
+ }
9458
9610
  //#endregion
9459
9611
  //#region ../client/src/lib/document/wikilink/rename.ts
9460
9612
  /**
@@ -9736,11 +9888,17 @@ var RenameUnconfirmedError = class extends Error {
9736
9888
  * journal-shaped [[Pageless Concept]] (a day nobody has written yet) is refused for the same
9737
9889
  * reason: its name is its day whether or not the entry exists.
9738
9890
  *
9891
+ * The same holds in the other direction: a page renamed TO a day is refused, rather than left
9892
+ * in `pages/` answering to the day or merged into its journal entry with a `title` block the
9893
+ * entry never carries (ADR 0056).
9894
+ *
9739
9895
  * A collision is NOT a refusal (ADR 0038 §4): it is a [[Merge]], confirmed in the dialog.
9740
9896
  */
9741
9897
  function renameRefusal(options) {
9742
- if (options.to.trim() === "") return "A page needs a non-empty name.";
9898
+ const to = options.to.trim();
9899
+ if (to === "") return "A page needs a non-empty name.";
9743
9900
  if (options.kind === "journal" || options.kind === null && isJournalConcept(options.from)) return "A journal entry cannot be renamed - its name is its date, and there is exactly one per day.";
9901
+ if (options.kind === "page" && isJournalConcept(to)) return dayIsNotAPageName(to);
9744
9902
  return null;
9745
9903
  }
9746
9904
  /** Every step, direct first - what an applier iterates. */
@@ -9896,17 +10054,29 @@ function reconcileDecision({ dirty, baseText, diskText }) {
9896
10054
  //#endregion
9897
10055
  //#region ../client/src/lib/storage/fs/scan.ts
9898
10056
  var SCANNED_SUBDIRS = ["journals", "pages"];
9899
- function isMarkdown(name) {
10057
+ /** Whether a listed name is a document file: `.md`, in any case. A `.crswap` swap file is not. */
10058
+ function isDocumentFile(name) {
9900
10059
  return /\.md$/i.test(name);
9901
10060
  }
9902
- async function scanGraph(adapter) {
10061
+ function fileKey(subdir, fileName) {
10062
+ return `${subdir}/${fileName}`;
10063
+ }
10064
+ async function scanGraph(adapter, options = {}) {
10065
+ const previous = /* @__PURE__ */ new Map();
10066
+ for (const entry of options.previous ?? []) previous.set(fileKey(entry.subdir, entry.fileName), entry);
10067
+ const writeInFlight = options.writeInFlight ?? (() => false);
9903
10068
  const journals = [];
9904
10069
  const pages = [];
9905
10070
  for (const subdir of SCANNED_SUBDIRS) {
9906
10071
  const kind = documentKindOf(subdir);
9907
10072
  if (!kind) continue;
9908
10073
  for (const { name, lastModified, size } of await adapter.list(subdir)) {
9909
- if (!isMarkdown(name)) continue;
10074
+ if (!isDocumentFile(name)) continue;
10075
+ const known = previous.get(fileKey(subdir, name));
10076
+ if (known !== void 0 && (known.lastModified === lastModified && known.size === size || writeInFlight(subdir, name))) {
10077
+ (kind === "journal" ? journals : pages).push(known);
10078
+ continue;
10079
+ }
9910
10080
  const { text } = await adapter.read(subdir, name);
9911
10081
  const fm = parseFrontmatter(text);
9912
10082
  const concept = kind === "journal" ? journalConceptOf(name) : conceptOf$1(fm, fileStem(name));
@@ -9939,7 +10109,7 @@ async function scanGraph(adapter) {
9939
10109
  * debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
9940
10110
  * any browser code exists.
9941
10111
  *
9942
- * Async-seam note (see docs/docs/technical/Document Editor.md): the seam's
10112
+ * Async-seam note: the seam's
9943
10113
  * `getText()` is synchronous but disk reads are async, so `open()` returns a
9944
10114
  * handle whose buffer is empty on first open and is hydrated by an internal
9945
10115
  * awaited read that then notifies subscribers the *external* way — which is safe
@@ -10045,9 +10215,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10045
10215
  open.set(doc.key, doc);
10046
10216
  for (const listener of documentRenamed) listener(from, entry.concept);
10047
10217
  }
10048
- /** Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. */
10218
+ /**
10219
+ * Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. The scan
10220
+ * is given the entries it has and reuses each whose file has not moved, so a pass over an
10221
+ * unchanged graph reads nothing, and it is told which files have a write in flight so it
10222
+ * never opens one the store is saving - on Windows that read handle would make the
10223
+ * browser's rename of its swap file over the target fail. A fresh store has no entries, so
10224
+ * graph open reads every file and learns every identity.
10225
+ */
10049
10226
  async function refreshRegistry() {
10050
- const entries = await scanGraph(adapter);
10227
+ const entries = await scanGraph(adapter, {
10228
+ previous: registry.values(),
10229
+ writeInFlight: (subdir, fileName) => {
10230
+ for (const doc of open.values()) if (doc.saving && doc.subdir === subdir && doc.fileName === fileName) return true;
10231
+ return false;
10232
+ }
10233
+ });
10051
10234
  registry.clear();
10052
10235
  for (const entry of entries) registry.set(entry.key, entry);
10053
10236
  const sig = registrySignature(entries);
@@ -10490,6 +10673,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
10490
10673
  async createPage(title, body = "") {
10491
10674
  const concept = title.trim();
10492
10675
  if (concept === "") throw new Error("A page needs a non-empty title.");
10676
+ if (isJournalConcept(concept)) throw new Error(dayIsNotAPageName(concept));
10493
10677
  const key = conceptKey(concept);
10494
10678
  if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
10495
10679
  await adapter.ensureSkeleton();
@@ -10610,6 +10794,7 @@ function describeFilesystemSaveFailure(error, concept) {
10610
10794
  case "NoModificationAllowedError": return `${opening} The file is locked by another program - a sync tool, or an editor holding it open. Close that, then retry. ${KEPT}`;
10611
10795
  case "NotFoundError": return `${opening} The folder is no longer where it was - a drive unplugged, or the folder moved. Make it available again, then retry. ${KEPT}`;
10612
10796
  case "NotReadableError": return `${opening} The file could not be read when it was opened, so nothing is written over it. Once it can be read the app reloads it, or asks you to choose if you have typed since. ${KEPT}`;
10797
+ case "InvalidStateError": return `${opening} Another program was using the file at the same time. Retry in a moment. If it keeps happening, check what else is using this folder. ${KEPT}`;
10613
10798
  default: return `${opening}${error instanceof Error && error.message ? ` The browser reported: ${error.message}.` : ""} Retry in a moment. ${KEPT}`;
10614
10799
  }
10615
10800
  }
@@ -11346,6 +11531,7 @@ function createServerDocumentStore(graph, options) {
11346
11531
  return day;
11347
11532
  },
11348
11533
  async createPage(title, body = "") {
11534
+ if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
11349
11535
  if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
11350
11536
  const docId = crypto.randomUUID();
11351
11537
  registry.set(docId, {
@@ -12227,17 +12413,22 @@ async function openHeadlessFolder(deps) {
12227
12413
  });
12228
12414
  const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
12229
12415
  /**
12230
- * The folder's listing as one string: every document file's name, mtime and size. The
12231
- * store's own reconcile re-reads every file to learn titles and aliases, which is right
12232
- * for the browser's poll but too much for a pass before every tool call on a large
12233
- * graph, so a pass first lists the two document subdirectories - a stat per file, no
12234
- * reads - and runs the full reconcile only when this differs from the last pass. An
12235
- * edit that keeps both mtime and size (an mtime-preserving copy) is missed until
12416
+ * The folder's listing as one string: every document file's name, mtime and size. A pass
12417
+ * first lists the two document subdirectories - a stat per file, no reads - and runs the
12418
+ * store's reconcile only when this differs from the last pass, so a pass before every
12419
+ * tool call on a large graph costs nothing when nothing moved. Only document files count:
12420
+ * a browser saving into the same folder writes a `.crswap` swap file first and renames it
12421
+ * over the document when the save lands, and a pass started by the swap file would read
12422
+ * the documents while that rename is due, which on Windows makes the browser's save fail.
12423
+ * An edit that keeps both mtime and size (an mtime-preserving copy) is missed until
12236
12424
  * something else changes, the same blind spot the store's own fast path accepts.
12237
12425
  */
12238
12426
  const listingSignature = async () => {
12239
12427
  const parts = [];
12240
- for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
12428
+ for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) {
12429
+ if (!isDocumentFile(entry.name)) continue;
12430
+ parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
12431
+ }
12241
12432
  return parts.sort().join("|");
12242
12433
  };
12243
12434
  let lastListing;
@@ -13318,31 +13509,48 @@ function archiveHtml(journals) {
13318
13509
  return `<ul class="journal-archive">${journals.map((j) => `<li><time datetime="${escapeHtml$6(j.date ?? "")}">${escapeHtml$6(j.date ?? "")}</time> <a href="${escapeHtml$6(j.url)}">${escapeHtml$6(j.title)}</a>${j.excerpt ? `<p>${escapeHtml$6(j.excerpt)}</p>` : ""}</li>`).join("")}</ul>`;
13319
13510
  }
13320
13511
  //#endregion
13321
- //#region ../client/src/lib/document/publish/highlight.ts
13322
- function escapeHtml$5(text) {
13323
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13324
- }
13512
+ //#region ../client/src/lib/document/code-languages.ts
13513
+ /**
13514
+ * The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
13515
+ * fences (`@codemirror/language-data`, see `view/augmentations/code-highlight.ts`). Every surface
13516
+ * that highlights code outside an editor - the publisher (`publish/highlight.ts`) and the
13517
+ * read-only quotes (`code-tokens.ts`) - resolves a language here, so each knows the languages
13518
+ * the editor knows, by the same names and aliases.
13519
+ *
13520
+ * The match is exact: a language's name or one of its aliases, in any case. The editor's own
13521
+ * lookup (`@codemirror/lang-markdown`) also matches an alias found inside the info-string, which
13522
+ * reads `text`, `plaintext` and `context` as LaTeX (alias `tex`), so a plain-text block turned
13523
+ * into a LaTeX one, `%` starting a comment. That fuzzy step is not repeated here.
13524
+ */
13325
13525
  var loaded = /* @__PURE__ */ new Map();
13326
- function describe(lang) {
13327
- const byName = languages.find((d) => d.name.toLowerCase() === lang.toLowerCase());
13328
- if (byName) return byName;
13329
- return languages.find((d) => d.alias.some((a) => a.toLowerCase() === lang.toLowerCase())) ?? null;
13330
- }
13331
13526
  /** The grammar for an info-string, loaded once; null for a language the editor does not know either. */
13332
- async function loadLanguage(lang) {
13527
+ async function loadCodeLanguage(lang) {
13333
13528
  const key = lang.toLowerCase();
13334
13529
  let pending = loaded.get(key);
13335
13530
  if (!pending) {
13336
- const description = describe(key);
13531
+ const description = LanguageDescription.matchLanguageName(languages, key, false);
13337
13532
  pending = description ? description.load().catch(() => null) : Promise.resolve(null);
13338
13533
  loaded.set(key, pending);
13339
13534
  }
13340
13535
  return pending;
13341
13536
  }
13537
+ //#endregion
13538
+ //#region ../client/src/lib/document/publish/highlight.ts
13539
+ /**
13540
+ * Code highlighting for the site with the grammars the editor already ships: the same
13541
+ * `@codemirror/language-data` registry `code-highlight.ts` nests inside fences, resolved by
13542
+ * `code-languages.ts`, run headless over the fence's text and emitted as `<span class="tok-…">`
13543
+ * (the `classHighlighter` names), so a site knows exactly the languages the editor knows and needs
13544
+ * no script for it. A theme colours the `tok-*` classes. Unknown languages come back as null and
13545
+ * render escaped.
13546
+ */
13547
+ function escapeHtml$5(text) {
13548
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
13549
+ }
13342
13550
  /** The fence's code as highlighted HTML (the `<code>` element's inner HTML), or null when the language is unknown. */
13343
13551
  async function highlightCode(lang, code) {
13344
13552
  if (lang === "") return null;
13345
- const support = await loadLanguage(lang);
13553
+ const support = await loadCodeLanguage(lang);
13346
13554
  if (!support) return null;
13347
13555
  const tree = support.language.parser.parse(code);
13348
13556
  let out = "";
@@ -15516,7 +15724,7 @@ async function backlinks(graph, concept) {
15516
15724
  kind: ref.kind,
15517
15725
  line: ref.line,
15518
15726
  breadcrumb: ref.breadcrumb,
15519
- text: ref.kind === "block" ? ref.subtree.map((node) => node.label).join("\n") : ref.context?.text ?? ""
15727
+ text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
15520
15728
  }))
15521
15729
  }))
15522
15730
  };