@appsoftwareltd/etherpk-mcp 0.7.1 → 0.8.1
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/README.md +193 -143
- package/dist/main.js +844 -232
- package/dist/main.js.map +1 -1
- package/package.json +14 -13
package/dist/main.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
3
3
|
import { availableParallelism, homedir, hostname, tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { access, chmod, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, chmod, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { x25519 } from "@noble/curves/ed25519.js";
|
|
9
9
|
import "@noble/hashes/argon2.js";
|
|
@@ -23,15 +23,18 @@ 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
|
+
import { lookup } from "node:dns";
|
|
33
|
+
import { request } from "node:https";
|
|
34
|
+
import { BlockList, isIP } from "node:net";
|
|
32
35
|
var package_default = {
|
|
33
36
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
34
|
-
version: "0.
|
|
37
|
+
version: "0.8.1",
|
|
35
38
|
license: "Elastic-2.0",
|
|
36
39
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
37
40
|
type: "module",
|
|
@@ -39,10 +42,11 @@ var package_default = {
|
|
|
39
42
|
bin: { "etherpk-mcp": "./bin/etherpk-mcp.js" },
|
|
40
43
|
files: ["bin", "dist"],
|
|
41
44
|
scripts: {
|
|
42
|
-
"
|
|
45
|
+
"client-tsconfig": "pnpm --dir ../client exec svelte-kit sync",
|
|
46
|
+
"build": "pnpm client-tsconfig && vite build",
|
|
43
47
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:watch": "vitest",
|
|
48
|
+
"test": "pnpm client-tsconfig && vitest run",
|
|
49
|
+
"test:watch": "pnpm client-tsconfig && vitest",
|
|
46
50
|
"prepack": "pnpm build"
|
|
47
51
|
},
|
|
48
52
|
dependencies: {
|
|
@@ -51,20 +55,20 @@ var package_default = {
|
|
|
51
55
|
"@huggingface/tokenizers": "0.2.0",
|
|
52
56
|
"@lezer/highlight": "^1.2.3",
|
|
53
57
|
"@lezer/markdown": "^1.6.4",
|
|
54
|
-
"@modelcontextprotocol/sdk": "
|
|
55
|
-
"@noble/curves": "
|
|
56
|
-
"@noble/hashes": "
|
|
58
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
59
|
+
"@noble/curves": "2.2.0",
|
|
60
|
+
"@noble/hashes": "2.2.0",
|
|
57
61
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
58
62
|
"fake-indexeddb": "^6.2.5",
|
|
59
63
|
"katex": "^0.17.0",
|
|
60
|
-
"lib0": "
|
|
64
|
+
"lib0": "0.2.117",
|
|
61
65
|
"markdown-it": "15.0.2",
|
|
62
66
|
"mermaid": "^11.16.1",
|
|
63
67
|
"mustache": "4.2.0",
|
|
64
68
|
"playwright-core": "1.59.1",
|
|
65
|
-
"y-protocols": "
|
|
69
|
+
"y-protocols": "1.0.7",
|
|
66
70
|
"yaml": "^2.9.0",
|
|
67
|
-
"yjs": "
|
|
71
|
+
"yjs": "13.6.31",
|
|
68
72
|
"zod": "^4.3.6"
|
|
69
73
|
},
|
|
70
74
|
devDependencies: {
|
|
@@ -80,7 +84,7 @@ var package_default = {
|
|
|
80
84
|
homepage: "https://docs.etherpk.com/using-ai-agents-with-your-notes",
|
|
81
85
|
repository: {
|
|
82
86
|
"type": "git",
|
|
83
|
-
"url": "https://github.com/appsoftwareltd/etherpk",
|
|
87
|
+
"url": "https://github.com/appsoftwareltd/etherpk-client",
|
|
84
88
|
"directory": "apps/mcp"
|
|
85
89
|
},
|
|
86
90
|
keywords: [
|
|
@@ -1393,50 +1397,6 @@ function wrapOo1Db(db) {
|
|
|
1393
1397
|
};
|
|
1394
1398
|
}
|
|
1395
1399
|
//#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
1400
|
//#region ../client/src/lib/document/fenced-code.ts
|
|
1441
1401
|
/** A backtick fence line, tolerant of a leading plain-bullet marker (`- `) so `- ``` ` is a fence.
|
|
1442
1402
|
* Backticks only (this slice) — tildes are left untouched, so they are not auto-completed. */
|
|
@@ -1518,6 +1478,57 @@ function fencedBlocks(lines, closerTolerance = 0) {
|
|
|
1518
1478
|
}
|
|
1519
1479
|
return blocks;
|
|
1520
1480
|
}
|
|
1481
|
+
/**
|
|
1482
|
+
* A line of a fenced block as code: the indentation up to the fence's column is structure (the
|
|
1483
|
+
* block's place in the outline) and goes; whatever lies past it is the code's own and stays.
|
|
1484
|
+
*/
|
|
1485
|
+
function codeLineText(line, fenceColumn) {
|
|
1486
|
+
return line.length - line.trimStart().length >= fenceColumn ? line.slice(fenceColumn) : line.trimStart();
|
|
1487
|
+
}
|
|
1488
|
+
//#endregion
|
|
1489
|
+
//#region ../client/src/lib/storage/fs/frontmatter-span.ts
|
|
1490
|
+
/** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
|
|
1491
|
+
var DELIMITER = /^---[ \t]*\r?$/;
|
|
1492
|
+
/** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
|
|
1493
|
+
function frontmatterSpan(text) {
|
|
1494
|
+
const firstBreak = text.indexOf("\n");
|
|
1495
|
+
if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
|
|
1496
|
+
const bodyFrom = firstBreak + 1;
|
|
1497
|
+
let lineStart = bodyFrom;
|
|
1498
|
+
let lines = 1;
|
|
1499
|
+
while (lineStart <= text.length) {
|
|
1500
|
+
const nextBreak = text.indexOf("\n", lineStart);
|
|
1501
|
+
const lineEnd = nextBreak === -1 ? text.length : nextBreak;
|
|
1502
|
+
lines += 1;
|
|
1503
|
+
if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
|
|
1504
|
+
let bodyTo = Math.max(bodyFrom, lineStart - 1);
|
|
1505
|
+
if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
|
|
1506
|
+
return {
|
|
1507
|
+
end: nextBreak === -1 ? text.length : nextBreak + 1,
|
|
1508
|
+
lines,
|
|
1509
|
+
body: text.slice(bodyFrom, bodyTo),
|
|
1510
|
+
bodyFrom,
|
|
1511
|
+
bodyTo
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
if (nextBreak === -1) break;
|
|
1515
|
+
lineStart = nextBreak + 1;
|
|
1516
|
+
}
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
|
|
1521
|
+
* document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
|
|
1522
|
+
* (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
|
|
1523
|
+
* in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
|
|
1524
|
+
* the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
|
|
1525
|
+
* would be the only O(n) step in an otherwise line-local pass.
|
|
1526
|
+
*/
|
|
1527
|
+
function frontmatterLines(lines) {
|
|
1528
|
+
if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
|
|
1529
|
+
for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
|
|
1530
|
+
return 0;
|
|
1531
|
+
}
|
|
1521
1532
|
" ".repeat(2);
|
|
1522
1533
|
/** Width of the `- ` marker, which sets a bullet's content column (`indent + MARKER_WIDTH`). */
|
|
1523
1534
|
var MARKER_WIDTH = 2;
|
|
@@ -1729,11 +1740,17 @@ function normaliseIndentUnit(text) {
|
|
|
1729
1740
|
* 3. **Continuation lines** — non-bullet lines indented to a bullet's content
|
|
1730
1741
|
* column belong to the *same* block (the soft-newline-within-a-block).
|
|
1731
1742
|
*
|
|
1743
|
+
* A complete [[Fenced Code Block]] is opaque to all three: it belongs whole to the block its
|
|
1744
|
+
* opener sits in, so a blank line, a `# comment` or a `- item` inside it is code, never a block
|
|
1745
|
+
* boundary, a heading or a bullet. "Complete" is the editor's pairing (`fencedBlocks`), the same
|
|
1746
|
+
* one the outline walk (`indent-unit.ts`) takes fences whole by.
|
|
1747
|
+
*
|
|
1732
1748
|
* A block has a kind (heading / paragraph / bullet / task). Blocks have no
|
|
1733
1749
|
* persistent identity: a block is its source range in the current parse.
|
|
1734
1750
|
*
|
|
1735
|
-
* Pure and DOM-free; the
|
|
1736
|
-
* it.
|
|
1751
|
+
* Pure and DOM-free; the derived index (`index-derive.ts`) and the publisher's navigation
|
|
1752
|
+
* (`publish/nav.ts`) consume it. The editor reads the same outline through `indent-unit.ts`.
|
|
1753
|
+
* See Dual Mode Editor.md.
|
|
1737
1754
|
*/
|
|
1738
1755
|
function indentOf(line) {
|
|
1739
1756
|
return line.length - line.trimStart().length;
|
|
@@ -1762,7 +1779,21 @@ function taskDone(line) {
|
|
|
1762
1779
|
*/
|
|
1763
1780
|
function parseBlocks(markdown) {
|
|
1764
1781
|
const lines = markdown.split("\n");
|
|
1765
|
-
const
|
|
1782
|
+
const fences = fencedBlocks(lines);
|
|
1783
|
+
const outline = outlineLines(lines, fences);
|
|
1784
|
+
const fenceAt = new Map(fences.map((f) => [f.start, f]));
|
|
1785
|
+
/**
|
|
1786
|
+
* Add line `k` to a block's `buf` and return the line after it. A line that opens a complete
|
|
1787
|
+
* fenced block brings the whole block with it, so nothing inside the fence is read as structure.
|
|
1788
|
+
* A fence nested inside it starts within the range taken, so only outermost fences reach here.
|
|
1789
|
+
*/
|
|
1790
|
+
const take = (k, buf) => {
|
|
1791
|
+
buf.push(lines[k].trimStart());
|
|
1792
|
+
const fence = fenceAt.get(k);
|
|
1793
|
+
if (!fence) return k + 1;
|
|
1794
|
+
for (let n = k + 1; n <= fence.end; n++) buf.push(codeLineText(lines[n], fence.fenceColumn));
|
|
1795
|
+
return fence.end + 1;
|
|
1796
|
+
};
|
|
1766
1797
|
const flat = [];
|
|
1767
1798
|
let i = 0;
|
|
1768
1799
|
while (i < lines.length) {
|
|
@@ -1788,12 +1819,9 @@ function parseBlocks(markdown) {
|
|
|
1788
1819
|
const contentCol = indentOf(lines[i]) + 2;
|
|
1789
1820
|
const done = taskDone(lines[i]);
|
|
1790
1821
|
const start = i;
|
|
1791
|
-
const buf = [
|
|
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
|
-
}
|
|
1822
|
+
const buf = [];
|
|
1823
|
+
i = take(i, buf);
|
|
1824
|
+
while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) i = take(i, buf);
|
|
1797
1825
|
flat.push({
|
|
1798
1826
|
type: done === void 0 ? "bullet" : "task",
|
|
1799
1827
|
depth,
|
|
@@ -1805,12 +1833,9 @@ function parseBlocks(markdown) {
|
|
|
1805
1833
|
continue;
|
|
1806
1834
|
}
|
|
1807
1835
|
const start = i;
|
|
1808
|
-
const buf = [
|
|
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
|
-
}
|
|
1836
|
+
const buf = [];
|
|
1837
|
+
i = take(i, buf);
|
|
1838
|
+
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
1839
|
flat.push({
|
|
1815
1840
|
type: "paragraph",
|
|
1816
1841
|
depth,
|
|
@@ -1997,14 +2022,12 @@ function serialiseTags(tags) {
|
|
|
1997
2022
|
/**
|
|
1998
2023
|
* A predicate over source line numbers: is this line inside a [[Fenced Code Block]]?
|
|
1999
2024
|
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
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.
|
|
2025
|
+
* A `- [ ] x` written inside a fence must not become a [[Task]]: the [[Tasks View]] would list
|
|
2026
|
+
* phantom tasks lifted out of code examples. The [[Block]] model now takes a complete fence
|
|
2027
|
+
* whole (block-model.ts), so no task starts inside one; this scan stays for what the block model
|
|
2028
|
+
* cannot know, that a [[Protected Document]] - an `etherpk-cipher` fence - never contributes a
|
|
2029
|
+
* task, whatever it holds. That is a rule, not an optimisation: the [[Derived Index]] is
|
|
2030
|
+
* plaintext at rest and outlives the session.
|
|
2008
2031
|
*
|
|
2009
2032
|
* "Inside a fence" is the EDITOR's answer, not a second one: the column-scoped pairing of
|
|
2010
2033
|
* `fencedBlocks` (Editor Content Rules → "Inside a block"), so a task is indexed exactly when
|
|
@@ -2040,6 +2063,15 @@ function blockLabel(block) {
|
|
|
2040
2063
|
return bulletLabel(first);
|
|
2041
2064
|
}
|
|
2042
2065
|
/**
|
|
2066
|
+
* A block as a reader sees it: the marker stripped as in its {@link blockLabel}, and every line
|
|
2067
|
+
* after the first kept - continuation lines and fenced code, which a label drops. What a
|
|
2068
|
+
* references [[View]] quotes and a semantic passage embeds.
|
|
2069
|
+
*/
|
|
2070
|
+
function blockContent(block) {
|
|
2071
|
+
const newline = block.text.indexOf("\n");
|
|
2072
|
+
return newline === -1 ? block.label : block.label + block.text.slice(newline);
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2043
2075
|
* The label of a bullet or [[Task]] line — its marker and checkbox stripped, indentation
|
|
2044
2076
|
* ignored. Exported because the [[Tasks View]]'s write-back guard has to ask "is the line in
|
|
2045
2077
|
* the document still the task the index recorded?", and the only honest way to answer is with
|
|
@@ -2225,9 +2257,7 @@ var OVERLAP_MAX_CHARS = Math.floor(PASSAGE_BUDGET_CHARS / 4);
|
|
|
2225
2257
|
var CRUMB = " > ";
|
|
2226
2258
|
/** A block's text with its bullet / task / heading marker gone, continuation lines kept. */
|
|
2227
2259
|
function blockBody(block) {
|
|
2228
|
-
|
|
2229
|
-
const rest = newline === -1 ? "" : block.text.slice(newline);
|
|
2230
|
-
return stripWikilinkBrackets(block.label + rest);
|
|
2260
|
+
return stripWikilinkBrackets(blockContent(block));
|
|
2231
2261
|
}
|
|
2232
2262
|
/** `[[Physics]]` embeds as `Physics`: the brackets are syntax, not meaning. */
|
|
2233
2263
|
function stripWikilinkBrackets(text) {
|
|
@@ -2545,7 +2575,7 @@ function createSchema(db) {
|
|
|
2545
2575
|
db.exec(SCHEMA$1);
|
|
2546
2576
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
|
|
2547
2577
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
|
|
2548
|
-
db.exec(`PRAGMA user_version =
|
|
2578
|
+
db.exec(`PRAGMA user_version = 12`);
|
|
2549
2579
|
}
|
|
2550
2580
|
function activeIndexGeneration(db) {
|
|
2551
2581
|
return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
|
|
@@ -2566,7 +2596,7 @@ function advanceIndexRevision(db) {
|
|
|
2566
2596
|
*/
|
|
2567
2597
|
function isUsableIndex(db) {
|
|
2568
2598
|
try {
|
|
2569
|
-
return db.all("PRAGMA user_version")[0]?.user_version ===
|
|
2599
|
+
return db.all("PRAGMA user_version")[0]?.user_version === 12;
|
|
2570
2600
|
} catch {
|
|
2571
2601
|
return false;
|
|
2572
2602
|
}
|
|
@@ -3077,16 +3107,40 @@ function collectSubtree(blocks, rootId) {
|
|
|
3077
3107
|
const b = blocks[id];
|
|
3078
3108
|
if (!b) return;
|
|
3079
3109
|
out.push({
|
|
3080
|
-
|
|
3110
|
+
text: blockContent(b),
|
|
3081
3111
|
depth,
|
|
3082
|
-
isMatch: id === rootId
|
|
3112
|
+
isMatch: id === rootId,
|
|
3113
|
+
...b.kind === "task" ? { done: b.done === true } : {}
|
|
3083
3114
|
});
|
|
3084
3115
|
for (const child of childrenByParent.get(id) ?? []) walk(child.localId, depth + 1);
|
|
3085
3116
|
};
|
|
3086
3117
|
walk(rootId, 0);
|
|
3087
3118
|
return out;
|
|
3088
3119
|
}
|
|
3089
|
-
/**
|
|
3120
|
+
/**
|
|
3121
|
+
* Widen `[start, end)` so that neither end falls inside a complete fenced code block. Half a code
|
|
3122
|
+
* block is no excerpt at all, and an opener cut from its closer would render as raw backticks.
|
|
3123
|
+
*/
|
|
3124
|
+
function widenToFences(text, start, end) {
|
|
3125
|
+
const lines = text.split("\n");
|
|
3126
|
+
const lineStarts = [];
|
|
3127
|
+
let offset = 0;
|
|
3128
|
+
for (const line of lines) {
|
|
3129
|
+
lineStarts.push(offset);
|
|
3130
|
+
offset += line.length + 1;
|
|
3131
|
+
}
|
|
3132
|
+
for (const fence of fencedBlocks(lines)) {
|
|
3133
|
+
const from = lineStarts[fence.start];
|
|
3134
|
+
const to = lineStarts[fence.end] + lines[fence.end].length;
|
|
3135
|
+
if (start > from && start < to) start = from;
|
|
3136
|
+
if (end > from && end < to) end = to;
|
|
3137
|
+
}
|
|
3138
|
+
return [start, end];
|
|
3139
|
+
}
|
|
3140
|
+
/**
|
|
3141
|
+
* Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. The window grows past
|
|
3142
|
+
* MAX_CONTEXT rather than cut a fenced code block in two ({@link widenToFences}).
|
|
3143
|
+
*/
|
|
3090
3144
|
function truncateContext(text, matchStart, matchEnd) {
|
|
3091
3145
|
if (text.length <= MAX_CONTEXT) return {
|
|
3092
3146
|
text,
|
|
@@ -3095,17 +3149,17 @@ function truncateContext(text, matchStart, matchEnd) {
|
|
|
3095
3149
|
truncated: false
|
|
3096
3150
|
};
|
|
3097
3151
|
const mid = Math.floor((matchStart + matchEnd) / 2);
|
|
3098
|
-
|
|
3099
|
-
const
|
|
3100
|
-
start = Math.max(0,
|
|
3101
|
-
const prefix = start
|
|
3102
|
-
const suffix = end
|
|
3152
|
+
const windowStart = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
|
|
3153
|
+
const windowEnd = Math.min(text.length, windowStart + MAX_CONTEXT);
|
|
3154
|
+
const [start, end] = widenToFences(text, Math.max(0, windowEnd - MAX_CONTEXT), windowEnd);
|
|
3155
|
+
const prefix = start === 0 ? "" : text[start - 1] === "\n" ? "…\n" : "…";
|
|
3156
|
+
const suffix = end === text.length ? "" : text[end] === "\n" ? "\n…" : "…";
|
|
3103
3157
|
const shift = prefix.length - start;
|
|
3104
3158
|
return {
|
|
3105
3159
|
text: prefix + text.slice(start, end) + suffix,
|
|
3106
3160
|
matchStart: Math.max(0, matchStart + shift),
|
|
3107
3161
|
matchEnd: Math.max(0, matchEnd + shift),
|
|
3108
|
-
truncated:
|
|
3162
|
+
truncated: prefix !== "" || suffix !== ""
|
|
3109
3163
|
};
|
|
3110
3164
|
}
|
|
3111
3165
|
/** Build a reference: a block subtree for bullets/tasks, prose context otherwise. */
|
|
@@ -3167,7 +3221,7 @@ function backlinksFor(db, concept) {
|
|
|
3167
3221
|
WHERE p.generation=? AND l.concept_key IN (${placeholders})`, [generation, ...names]);
|
|
3168
3222
|
const blocksByPage = /* @__PURE__ */ new Map();
|
|
3169
3223
|
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]);
|
|
3224
|
+
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
3225
|
const blocks = [];
|
|
3172
3226
|
for (const r of rows) blocks[r.local_id] = {
|
|
3173
3227
|
localId: r.local_id,
|
|
@@ -3175,6 +3229,7 @@ function backlinksFor(db, concept) {
|
|
|
3175
3229
|
ord: r.ord,
|
|
3176
3230
|
kind: r.kind,
|
|
3177
3231
|
depth: r.depth,
|
|
3232
|
+
...r.done === null ? {} : { done: r.done === 1 },
|
|
3178
3233
|
startLine: 0,
|
|
3179
3234
|
endLine: 0,
|
|
3180
3235
|
text: r.text,
|
|
@@ -3863,7 +3918,7 @@ function cacheRoot(env) {
|
|
|
3863
3918
|
* recovered from a joined path (see there).
|
|
3864
3919
|
*/
|
|
3865
3920
|
var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
|
|
3866
|
-
var INDEX_FILE_NAME = `index.
|
|
3921
|
+
var INDEX_FILE_NAME = `index.v12.sqlite`;
|
|
3867
3922
|
var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
|
|
3868
3923
|
function cacheFile(dir) {
|
|
3869
3924
|
return join(dir, CACHE_FILE_NAME);
|
|
@@ -3874,7 +3929,7 @@ function indexFile(dir) {
|
|
|
3874
3929
|
function vectorsFile(dir) {
|
|
3875
3930
|
return join(dir, VECTORS_FILE_NAME);
|
|
3876
3931
|
}
|
|
3877
|
-
function request(req) {
|
|
3932
|
+
function request$1(req) {
|
|
3878
3933
|
return new Promise((resolve, reject) => {
|
|
3879
3934
|
req.onsuccess = () => resolve(req.result);
|
|
3880
3935
|
req.onerror = () => reject(req.error);
|
|
@@ -3889,7 +3944,7 @@ function done(tx) {
|
|
|
3889
3944
|
}
|
|
3890
3945
|
/** Open the cache database the sync engine already created; never creates or upgrades it. */
|
|
3891
3946
|
async function openCacheDb() {
|
|
3892
|
-
const db = await request(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
|
|
3947
|
+
const db = await request$1(indexedDB.open(CACHE_DB, CACHE_DB_VERSION));
|
|
3893
3948
|
for (const store of CACHE_STORES) if (!db.objectStoreNames.contains(store)) {
|
|
3894
3949
|
db.close();
|
|
3895
3950
|
throw new Error(`Local Cache database has no "${store}" store; open the graph cache before restoring it.`);
|
|
@@ -3927,7 +3982,7 @@ async function captureLocalCache(graphId) {
|
|
|
3927
3982
|
try {
|
|
3928
3983
|
const tx = db.transaction([...CACHE_STORES], "readonly");
|
|
3929
3984
|
const rows = {};
|
|
3930
|
-
for (const store of CACHE_STORES) rows[store] = (await request(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
|
|
3985
|
+
for (const store of CACHE_STORES) rows[store] = (await request$1(tx.objectStore(store).getAll())).filter((row) => row.graphId === graphId).map(withStandaloneBuffers);
|
|
3931
3986
|
await done(tx);
|
|
3932
3987
|
return {
|
|
3933
3988
|
version: CACHE_DB_VERSION,
|
|
@@ -4649,13 +4704,8 @@ var performanceRecorder = createPerformanceRecorder({ enabled: typeof window !==
|
|
|
4649
4704
|
var ENTITLEMENT_AUDIENCE = "urn:etherpk:sync-entitlements";
|
|
4650
4705
|
var ENTITLEMENT_SERVICE = "managed-sync";
|
|
4651
4706
|
/**
|
|
4652
|
-
* Version-agnostic, like `isUuid` on the Server
|
|
4653
|
-
*
|
|
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.
|
|
4707
|
+
* Version-agnostic, like `isUuid` on the Server: any UUID version matches, so an id minted as a
|
|
4708
|
+
* uuidv7 is recognised as readily as a v4. One pattern, here, next to the claim it belongs to.
|
|
4659
4709
|
*/
|
|
4660
4710
|
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
4711
|
var httpsUrl = z.url().refine((value) => {
|
|
@@ -5093,7 +5143,7 @@ function createSyncProtocol(overrides = {}) {
|
|
|
5093
5143
|
}
|
|
5094
5144
|
var defaultProtocol = createSyncProtocol();
|
|
5095
5145
|
defaultProtocol.parseClientMessage;
|
|
5096
|
-
var parseServerMessage
|
|
5146
|
+
var parseServerMessage = defaultProtocol.parseServerMessage;
|
|
5097
5147
|
var serializeClientMessage$1 = defaultProtocol.serializeClientMessage;
|
|
5098
5148
|
defaultProtocol.serializeServerMessage;
|
|
5099
5149
|
//#endregion
|
|
@@ -5102,13 +5152,10 @@ defaultProtocol.serializeServerMessage;
|
|
|
5102
5152
|
* The asset chunking contract, shared by the Client that uploads and the Sync Server that
|
|
5103
5153
|
* signs the upload URLs.
|
|
5104
5154
|
*
|
|
5105
|
-
*
|
|
5106
|
-
*
|
|
5107
|
-
*
|
|
5108
|
-
*
|
|
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.
|
|
5155
|
+
* Shared so that the declared `size` on `POST /api/v1/sync/assets` is not taken on trust: from
|
|
5156
|
+
* the contract stated here the server derives exactly how many chunks an asset of a given size
|
|
5157
|
+
* has, and exactly how long each chunk's ciphertext must be, and signs that length into each
|
|
5158
|
+
* presigned PUT URL.
|
|
5112
5159
|
*
|
|
5113
5160
|
* Changing either constant is a protocol change: the Client's chunking and the Server's
|
|
5114
5161
|
* signed lengths must move together, or every upload fails with an opaque 403.
|
|
@@ -5852,13 +5899,41 @@ function createDocSync(deps) {
|
|
|
5852
5899
|
* added and checked only at the serialisation boundary so the document engine does not repeat
|
|
5853
5900
|
* a transport constant on every operation.
|
|
5854
5901
|
*/
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5902
|
+
function readServerMessage(raw) {
|
|
5903
|
+
const result = parseServerMessage(raw);
|
|
5904
|
+
if (result.ok) {
|
|
5905
|
+
const { v: _version, ...message } = result.value;
|
|
5906
|
+
return {
|
|
5907
|
+
kind: "message",
|
|
5908
|
+
message
|
|
5909
|
+
};
|
|
5910
|
+
}
|
|
5911
|
+
if (result.code === "unsupported_version") {
|
|
5912
|
+
const { v } = JSON.parse(raw);
|
|
5913
|
+
if (typeof v === "number" && Number.isInteger(v)) return {
|
|
5914
|
+
kind: "protocol_mismatch",
|
|
5915
|
+
serverVersion: v
|
|
5916
|
+
};
|
|
5917
|
+
}
|
|
5918
|
+
return { kind: "malformed" };
|
|
5861
5919
|
}
|
|
5920
|
+
/**
|
|
5921
|
+
* The Sync Server speaks another sync protocol version, so nothing it sends can be read and
|
|
5922
|
+
* nothing this Client sends will be accepted. Retrying cannot fix it; one side has to be
|
|
5923
|
+
* upgraded. The message is for logs and the Headless Client; the browser maps the error to
|
|
5924
|
+
* its own copy in `sync-error-copy.ts`.
|
|
5925
|
+
*/
|
|
5926
|
+
var SyncProtocolMismatchError = class extends Error {
|
|
5927
|
+
name = "SyncProtocolMismatchError";
|
|
5928
|
+
constructor(serverVersion, clientVersion = 2) {
|
|
5929
|
+
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.`);
|
|
5930
|
+
this.serverVersion = serverVersion;
|
|
5931
|
+
this.clientVersion = clientVersion;
|
|
5932
|
+
}
|
|
5933
|
+
get serverIsOlder() {
|
|
5934
|
+
return this.serverVersion < this.clientVersion;
|
|
5935
|
+
}
|
|
5936
|
+
};
|
|
5862
5937
|
function serializeClientMessage(message) {
|
|
5863
5938
|
return serializeClientMessage$1({
|
|
5864
5939
|
v: 2,
|
|
@@ -6019,6 +6094,39 @@ function sanitizeQuickNotes(raw) {
|
|
|
6019
6094
|
}
|
|
6020
6095
|
return out;
|
|
6021
6096
|
}
|
|
6097
|
+
new Intl.Segmenter(void 0, { granularity: "word" });
|
|
6098
|
+
/**
|
|
6099
|
+
* The spelling a dictionary is asked about: a curly apostrophe (`’`), which keyboards and
|
|
6100
|
+
* pastes produce, straightened, since Hunspell dictionaries spell contractions with `'`.
|
|
6101
|
+
*/
|
|
6102
|
+
function spellingForm(word) {
|
|
6103
|
+
return word.replace(/’/g, "'");
|
|
6104
|
+
}
|
|
6105
|
+
/** Longer than any real word; a line this long is not one. */
|
|
6106
|
+
var MAX_WORD_LENGTH = 100;
|
|
6107
|
+
/**
|
|
6108
|
+
* The word as the dictionary keeps it, or null when it cannot be one entry. A curly apostrophe is
|
|
6109
|
+
* straightened, the form the checker compares (`spelling/words.ts` → `spellingForm`), so a word
|
|
6110
|
+
* added from `don’t` also accepts `don't`.
|
|
6111
|
+
*/
|
|
6112
|
+
function normaliseDictionaryWord(raw) {
|
|
6113
|
+
const word = spellingForm(raw.trim().normalize("NFC"));
|
|
6114
|
+
if (word === "" || /\s/u.test(word) || word.length > MAX_WORD_LENGTH) return null;
|
|
6115
|
+
return word;
|
|
6116
|
+
}
|
|
6117
|
+
/** Well-formed words, deduped (first seen wins), capped: tolerant of a peer's or a newer client's list. */
|
|
6118
|
+
function sanitizeDictionaryWords(raw) {
|
|
6119
|
+
if (!Array.isArray(raw)) return [];
|
|
6120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6121
|
+
for (const entry of raw) {
|
|
6122
|
+
if (typeof entry !== "string") continue;
|
|
6123
|
+
const word = normaliseDictionaryWord(entry);
|
|
6124
|
+
if (word === null || seen.has(word)) continue;
|
|
6125
|
+
seen.add(word);
|
|
6126
|
+
if (seen.size >= 5e4) break;
|
|
6127
|
+
}
|
|
6128
|
+
return [...seen];
|
|
6129
|
+
}
|
|
6022
6130
|
//#endregion
|
|
6023
6131
|
//#region ../client/src/lib/storage/fs/frontmatter.ts
|
|
6024
6132
|
/**
|
|
@@ -6391,6 +6499,12 @@ function createGraphSync(deps) {
|
|
|
6391
6499
|
let socket;
|
|
6392
6500
|
let open = false;
|
|
6393
6501
|
let disposed = false;
|
|
6502
|
+
/**
|
|
6503
|
+
* Set when the Sync Server turned out to speak another protocol version. The session then
|
|
6504
|
+
* stops for good: every reconnect would meet the same server, and nothing either side
|
|
6505
|
+
* sends can be read by the other. Pending appends stay in the cache for a later session.
|
|
6506
|
+
*/
|
|
6507
|
+
let protocolMismatch;
|
|
6394
6508
|
const reportError = (error) => {
|
|
6395
6509
|
if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
6396
6510
|
};
|
|
@@ -6412,6 +6526,7 @@ function createGraphSync(deps) {
|
|
|
6412
6526
|
function waitForCurrentConnection() {
|
|
6413
6527
|
if (open && socket) return Promise.resolve();
|
|
6414
6528
|
if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
|
|
6529
|
+
if (protocolMismatch) return Promise.reject(protocolMismatch);
|
|
6415
6530
|
return new Promise((resolve, reject) => {
|
|
6416
6531
|
connectionWaiters.add({
|
|
6417
6532
|
resolve,
|
|
@@ -6451,7 +6566,7 @@ function createGraphSync(deps) {
|
|
|
6451
6566
|
}
|
|
6452
6567
|
/**
|
|
6453
6568
|
* Documents the CURRENT socket has subscribed to. The relay keeps and forwards presence
|
|
6454
|
-
* only for a subscribed document
|
|
6569
|
+
* only for a subscribed document, and this mirrors that rule at the source:
|
|
6455
6570
|
* y-protocols renews every engine's awareness state every fifteen seconds, and an engine
|
|
6456
6571
|
* created for a background walk starts with an empty `{}` state, so without the gate every
|
|
6457
6572
|
* unretained engine encrypted and sent presence the relay would only drop. Cleared with
|
|
@@ -6469,9 +6584,9 @@ function createGraphSync(deps) {
|
|
|
6469
6584
|
}
|
|
6470
6585
|
/**
|
|
6471
6586
|
* Subscribe to every retained document on a freshly opened socket, in batches the relay
|
|
6472
|
-
* accepts.
|
|
6473
|
-
*
|
|
6474
|
-
*
|
|
6587
|
+
* accepts. Above the protocol's per-message limit the relay rejects a subscribe as invalid,
|
|
6588
|
+
* which would leave a tab with many open views silently without live updates after a
|
|
6589
|
+
* reconnect.
|
|
6475
6590
|
*/
|
|
6476
6591
|
function subscribeRetained() {
|
|
6477
6592
|
const docIds = [...retained.keys()];
|
|
@@ -6609,6 +6724,7 @@ function createGraphSync(deps) {
|
|
|
6609
6724
|
if (deps.onRegistryChange) registryMap.observe(() => deps.onRegistryChange?.());
|
|
6610
6725
|
const metaMap = root.doc.getMap("meta");
|
|
6611
6726
|
const quickNotesArray = root.doc.getArray("quickNotes");
|
|
6727
|
+
const dictionaryMap = root.doc.getMap("spellingDictionary");
|
|
6612
6728
|
const themesMap = root.doc.getMap("themes");
|
|
6613
6729
|
/** A stored theme as a plain object, or null when the entry is not one. */
|
|
6614
6730
|
function readTheme(id) {
|
|
@@ -6662,9 +6778,23 @@ function createGraphSync(deps) {
|
|
|
6662
6778
|
publishNameOnceCaughtUp();
|
|
6663
6779
|
}, () => {});
|
|
6664
6780
|
}
|
|
6781
|
+
/** Report once, fail everything waiting for a connection, and close without reconnecting. */
|
|
6782
|
+
function stopForProtocolMismatch(serverVersion) {
|
|
6783
|
+
if (protocolMismatch) return;
|
|
6784
|
+
protocolMismatch = new SyncProtocolMismatchError(serverVersion);
|
|
6785
|
+
reportError(protocolMismatch);
|
|
6786
|
+
for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
|
|
6787
|
+
connectionWaiters.clear();
|
|
6788
|
+
socket?.close();
|
|
6789
|
+
}
|
|
6665
6790
|
function handleMessage(raw) {
|
|
6666
|
-
const
|
|
6667
|
-
if (
|
|
6791
|
+
const read = readServerMessage(raw);
|
|
6792
|
+
if (read.kind === "protocol_mismatch") {
|
|
6793
|
+
stopForProtocolMismatch(read.serverVersion);
|
|
6794
|
+
return;
|
|
6795
|
+
}
|
|
6796
|
+
if (read.kind === "malformed") return;
|
|
6797
|
+
const message = read.message;
|
|
6668
6798
|
if (message.type === "catchup_batch") performanceRecorder.mark("sync.catchup.batch", {
|
|
6669
6799
|
rows: message.updates.length,
|
|
6670
6800
|
bytes: new TextEncoder().encode(raw).byteLength,
|
|
@@ -6710,7 +6840,7 @@ function createGraphSync(deps) {
|
|
|
6710
6840
|
* on open. Only rebuildable snapshot uploads use a volatile queue.
|
|
6711
6841
|
*/
|
|
6712
6842
|
function connect() {
|
|
6713
|
-
if (disposed) return;
|
|
6843
|
+
if (disposed || protocolMismatch) return;
|
|
6714
6844
|
deps.token().then((token) => {
|
|
6715
6845
|
if (disposed) return;
|
|
6716
6846
|
const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
|
|
@@ -6752,7 +6882,7 @@ function createGraphSync(deps) {
|
|
|
6752
6882
|
pending.reject(new WatermarkConnectionInterruptedError());
|
|
6753
6883
|
}
|
|
6754
6884
|
watermarkRequests.clear();
|
|
6755
|
-
if (!disposed) setTimeout(connect, RECONNECT_MS);
|
|
6885
|
+
if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
|
|
6756
6886
|
});
|
|
6757
6887
|
}, () => {
|
|
6758
6888
|
if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
|
|
@@ -6859,6 +6989,21 @@ function createGraphSync(deps) {
|
|
|
6859
6989
|
return () => quickNotesArray.unobserve(listener);
|
|
6860
6990
|
}
|
|
6861
6991
|
}),
|
|
6992
|
+
spellingDictionary: () => ({
|
|
6993
|
+
list: () => sanitizeDictionaryWords([...dictionaryMap.keys()]),
|
|
6994
|
+
add(word) {
|
|
6995
|
+
dictionaryMap.set(word, true);
|
|
6996
|
+
},
|
|
6997
|
+
remove(words) {
|
|
6998
|
+
root.doc.transact(() => {
|
|
6999
|
+
for (const word of words) dictionaryMap.delete(word);
|
|
7000
|
+
});
|
|
7001
|
+
},
|
|
7002
|
+
observe(listener) {
|
|
7003
|
+
dictionaryMap.observe(listener);
|
|
7004
|
+
return () => dictionaryMap.unobserve(listener);
|
|
7005
|
+
}
|
|
7006
|
+
}),
|
|
6862
7007
|
themes: () => ({
|
|
6863
7008
|
list() {
|
|
6864
7009
|
const out = [];
|
|
@@ -8861,6 +9006,32 @@ function assetHashFromName(fileName) {
|
|
|
8861
9006
|
ext: (match[3] ?? "").toLowerCase()
|
|
8862
9007
|
};
|
|
8863
9008
|
}
|
|
9009
|
+
/**
|
|
9010
|
+
* Whether a decoded asset name is one file inside `assets/`, and so safe to join onto a folder.
|
|
9011
|
+
*
|
|
9012
|
+
* An asset reference comes from a document, and a document can come from a collaborator, an
|
|
9013
|
+
* import or an agent, so the name it decodes to is untrusted: `../assets/..%2Fpages%2FSecret.md`
|
|
9014
|
+
* decodes to a path that leaves `assets/`. Every place that turns a reference into a file path
|
|
9015
|
+
* (the asset stores, the publisher's bundle) checks this first, before any directory adapter
|
|
9016
|
+
* or site writer sees the name.
|
|
9017
|
+
*/
|
|
9018
|
+
function isSafeAssetName(name) {
|
|
9019
|
+
if (!isSingleFileName(name)) return false;
|
|
9020
|
+
for (const char of name) {
|
|
9021
|
+
const code = char.codePointAt(0) ?? 0;
|
|
9022
|
+
if (code < 32 || code === 127) return false;
|
|
9023
|
+
}
|
|
9024
|
+
return true;
|
|
9025
|
+
}
|
|
9026
|
+
/**
|
|
9027
|
+
* Whether `name` is one entry of a directory rather than a path: not empty, not `.` or `..`, and
|
|
9028
|
+
* free of separators and NUL. What a directory adapter requires of every name it is handed, which
|
|
9029
|
+
* is looser than {@link isSafeAssetName}: a file already on disk may carry other odd characters.
|
|
9030
|
+
*/
|
|
9031
|
+
function isSingleFileName(name) {
|
|
9032
|
+
if (name === "" || name === "." || name === "..") return false;
|
|
9033
|
+
return !name.includes("/") && !name.includes("\\") && !name.includes("\0");
|
|
9034
|
+
}
|
|
8864
9035
|
//#endregion
|
|
8865
9036
|
//#region ../client/src/lib/storage/fs/asset-store.ts
|
|
8866
9037
|
/**
|
|
@@ -8961,11 +9132,22 @@ var MIME_TYPES = {
|
|
|
8961
9132
|
function mimeTypeForExt(ext) {
|
|
8962
9133
|
return MIME_TYPES[ext.replace(/^\./, "").toLowerCase()] ?? "";
|
|
8963
9134
|
}
|
|
8964
|
-
/**
|
|
9135
|
+
/**
|
|
9136
|
+
* The on-disk name referenced by a doc-relative asset ref, or `null` if `ref` is not an asset
|
|
9137
|
+
* reference. A reference whose name decodes to anything but one file inside `assets/`, or does not
|
|
9138
|
+
* decode at all, is not one: it can never name a file on disk (see `isSafeAssetName`).
|
|
9139
|
+
*/
|
|
8965
9140
|
function assetNameFromRef(ref) {
|
|
8966
9141
|
const clean = ref.split(/[?#]/)[0];
|
|
8967
9142
|
const match = ASSET_REF$1.exec(clean);
|
|
8968
|
-
|
|
9143
|
+
if (!match) return null;
|
|
9144
|
+
let name;
|
|
9145
|
+
try {
|
|
9146
|
+
name = decodeURIComponent(match[1]);
|
|
9147
|
+
} catch {
|
|
9148
|
+
return null;
|
|
9149
|
+
}
|
|
9150
|
+
return isSafeAssetName(name) ? name : null;
|
|
8969
9151
|
}
|
|
8970
9152
|
/**
|
|
8971
9153
|
* The markdown to embed a saved asset: an inline image, or a plain link that downloads on click.
|
|
@@ -9455,6 +9637,16 @@ function isCalendarDay(value) {
|
|
|
9455
9637
|
function isJournalConcept(concept) {
|
|
9456
9638
|
return isCalendarDay(concept);
|
|
9457
9639
|
}
|
|
9640
|
+
/**
|
|
9641
|
+
* Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
|
|
9642
|
+
*
|
|
9643
|
+
* A page given one sits in `pages/` answering to the day, which is what an older version left
|
|
9644
|
+
* behind when a Draft for a date promoted to a page. Creating a page and renaming one both
|
|
9645
|
+
* refuse with this, on both backends, so the copy is the same wherever the user meets it.
|
|
9646
|
+
*/
|
|
9647
|
+
function dayIsNotAPageName(day) {
|
|
9648
|
+
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.`;
|
|
9649
|
+
}
|
|
9458
9650
|
//#endregion
|
|
9459
9651
|
//#region ../client/src/lib/document/wikilink/rename.ts
|
|
9460
9652
|
/**
|
|
@@ -9736,11 +9928,17 @@ var RenameUnconfirmedError = class extends Error {
|
|
|
9736
9928
|
* journal-shaped [[Pageless Concept]] (a day nobody has written yet) is refused for the same
|
|
9737
9929
|
* reason: its name is its day whether or not the entry exists.
|
|
9738
9930
|
*
|
|
9931
|
+
* The same holds in the other direction: a page renamed TO a day is refused, rather than left
|
|
9932
|
+
* in `pages/` answering to the day or merged into its journal entry with a `title` block the
|
|
9933
|
+
* entry never carries (ADR 0056).
|
|
9934
|
+
*
|
|
9739
9935
|
* A collision is NOT a refusal (ADR 0038 §4): it is a [[Merge]], confirmed in the dialog.
|
|
9740
9936
|
*/
|
|
9741
9937
|
function renameRefusal(options) {
|
|
9742
|
-
|
|
9938
|
+
const to = options.to.trim();
|
|
9939
|
+
if (to === "") return "A page needs a non-empty name.";
|
|
9743
9940
|
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.";
|
|
9941
|
+
if (options.kind === "page" && isJournalConcept(to)) return dayIsNotAPageName(to);
|
|
9744
9942
|
return null;
|
|
9745
9943
|
}
|
|
9746
9944
|
/** Every step, direct first - what an applier iterates. */
|
|
@@ -9896,17 +10094,29 @@ function reconcileDecision({ dirty, baseText, diskText }) {
|
|
|
9896
10094
|
//#endregion
|
|
9897
10095
|
//#region ../client/src/lib/storage/fs/scan.ts
|
|
9898
10096
|
var SCANNED_SUBDIRS = ["journals", "pages"];
|
|
9899
|
-
|
|
10097
|
+
/** Whether a listed name is a document file: `.md`, in any case. A `.crswap` swap file is not. */
|
|
10098
|
+
function isDocumentFile(name) {
|
|
9900
10099
|
return /\.md$/i.test(name);
|
|
9901
10100
|
}
|
|
9902
|
-
|
|
10101
|
+
function fileKey(subdir, fileName) {
|
|
10102
|
+
return `${subdir}/${fileName}`;
|
|
10103
|
+
}
|
|
10104
|
+
async function scanGraph(adapter, options = {}) {
|
|
10105
|
+
const previous = /* @__PURE__ */ new Map();
|
|
10106
|
+
for (const entry of options.previous ?? []) previous.set(fileKey(entry.subdir, entry.fileName), entry);
|
|
10107
|
+
const writeInFlight = options.writeInFlight ?? (() => false);
|
|
9903
10108
|
const journals = [];
|
|
9904
10109
|
const pages = [];
|
|
9905
10110
|
for (const subdir of SCANNED_SUBDIRS) {
|
|
9906
10111
|
const kind = documentKindOf(subdir);
|
|
9907
10112
|
if (!kind) continue;
|
|
9908
10113
|
for (const { name, lastModified, size } of await adapter.list(subdir)) {
|
|
9909
|
-
if (!
|
|
10114
|
+
if (!isDocumentFile(name)) continue;
|
|
10115
|
+
const known = previous.get(fileKey(subdir, name));
|
|
10116
|
+
if (known !== void 0 && (known.lastModified === lastModified && known.size === size || writeInFlight(subdir, name))) {
|
|
10117
|
+
(kind === "journal" ? journals : pages).push(known);
|
|
10118
|
+
continue;
|
|
10119
|
+
}
|
|
9910
10120
|
const { text } = await adapter.read(subdir, name);
|
|
9911
10121
|
const fm = parseFrontmatter(text);
|
|
9912
10122
|
const concept = kind === "journal" ? journalConceptOf(name) : conceptOf$1(fm, fileStem(name));
|
|
@@ -9939,7 +10149,7 @@ async function scanGraph(adapter) {
|
|
|
9939
10149
|
* debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
|
|
9940
10150
|
* any browser code exists.
|
|
9941
10151
|
*
|
|
9942
|
-
* Async-seam note
|
|
10152
|
+
* Async-seam note: the seam's
|
|
9943
10153
|
* `getText()` is synchronous but disk reads are async, so `open()` returns a
|
|
9944
10154
|
* handle whose buffer is empty on first open and is hydrated by an internal
|
|
9945
10155
|
* awaited read that then notifies subscribers the *external* way — which is safe
|
|
@@ -10045,9 +10255,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10045
10255
|
open.set(doc.key, doc);
|
|
10046
10256
|
for (const listener of documentRenamed) listener(from, entry.concept);
|
|
10047
10257
|
}
|
|
10048
|
-
/**
|
|
10258
|
+
/**
|
|
10259
|
+
* Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. The scan
|
|
10260
|
+
* is given the entries it has and reuses each whose file has not moved, so a pass over an
|
|
10261
|
+
* unchanged graph reads nothing, and it is told which files have a write in flight so it
|
|
10262
|
+
* never opens one the store is saving - on Windows that read handle would make the
|
|
10263
|
+
* browser's rename of its swap file over the target fail. A fresh store has no entries, so
|
|
10264
|
+
* graph open reads every file and learns every identity.
|
|
10265
|
+
*/
|
|
10049
10266
|
async function refreshRegistry() {
|
|
10050
|
-
const entries = await scanGraph(adapter
|
|
10267
|
+
const entries = await scanGraph(adapter, {
|
|
10268
|
+
previous: registry.values(),
|
|
10269
|
+
writeInFlight: (subdir, fileName) => {
|
|
10270
|
+
for (const doc of open.values()) if (doc.saving && doc.subdir === subdir && doc.fileName === fileName) return true;
|
|
10271
|
+
return false;
|
|
10272
|
+
}
|
|
10273
|
+
});
|
|
10051
10274
|
registry.clear();
|
|
10052
10275
|
for (const entry of entries) registry.set(entry.key, entry);
|
|
10053
10276
|
const sig = registrySignature(entries);
|
|
@@ -10490,6 +10713,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10490
10713
|
async createPage(title, body = "") {
|
|
10491
10714
|
const concept = title.trim();
|
|
10492
10715
|
if (concept === "") throw new Error("A page needs a non-empty title.");
|
|
10716
|
+
if (isJournalConcept(concept)) throw new Error(dayIsNotAPageName(concept));
|
|
10493
10717
|
const key = conceptKey(concept);
|
|
10494
10718
|
if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
|
|
10495
10719
|
await adapter.ensureSkeleton();
|
|
@@ -10610,6 +10834,7 @@ function describeFilesystemSaveFailure(error, concept) {
|
|
|
10610
10834
|
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
10835
|
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
10836
|
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}`;
|
|
10837
|
+
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
10838
|
default: return `${opening}${error instanceof Error && error.message ? ` The browser reported: ${error.message}.` : ""} Retry in a moment. ${KEPT}`;
|
|
10614
10839
|
}
|
|
10615
10840
|
}
|
|
@@ -11346,6 +11571,7 @@ function createServerDocumentStore(graph, options) {
|
|
|
11346
11571
|
return day;
|
|
11347
11572
|
},
|
|
11348
11573
|
async createPage(title, body = "") {
|
|
11574
|
+
if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
|
|
11349
11575
|
if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
|
|
11350
11576
|
const docId = crypto.randomUUID();
|
|
11351
11577
|
registry.set(docId, {
|
|
@@ -11668,6 +11894,16 @@ function isTransient(error) {
|
|
|
11668
11894
|
function kebabStem(name) {
|
|
11669
11895
|
return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
|
|
11670
11896
|
}
|
|
11897
|
+
/**
|
|
11898
|
+
* The MIME type a downloaded asset is given: its extension's, from the same short list the
|
|
11899
|
+
* Filesystem Backend uses, or `application/octet-stream`. Never the type recorded in the asset's
|
|
11900
|
+
* metadata, which is whatever the uploader's client sent: a collaborator could type an asset
|
|
11901
|
+
* `text/html`, and a blob opened in its own tab would then be a same-origin HTML document. The
|
|
11902
|
+
* extension is the reference's, the one the viewers are chosen by.
|
|
11903
|
+
*/
|
|
11904
|
+
function assetTypeFor(ref) {
|
|
11905
|
+
return mimeTypeForExt(extOf(ref)) || "application/octet-stream";
|
|
11906
|
+
}
|
|
11671
11907
|
function extOf(name) {
|
|
11672
11908
|
const m = /\.([^.]+)$/.exec(name);
|
|
11673
11909
|
return m ? m[1].toLowerCase() : "bin";
|
|
@@ -11761,7 +11997,7 @@ function createServerAssetStore(deps) {
|
|
|
11761
11997
|
return {
|
|
11762
11998
|
bytes: joined,
|
|
11763
11999
|
name: metadata.name,
|
|
11764
|
-
type:
|
|
12000
|
+
type: assetTypeFor(ref)
|
|
11765
12001
|
};
|
|
11766
12002
|
}
|
|
11767
12003
|
return {
|
|
@@ -12227,17 +12463,22 @@ async function openHeadlessFolder(deps) {
|
|
|
12227
12463
|
});
|
|
12228
12464
|
const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
|
|
12229
12465
|
/**
|
|
12230
|
-
* The folder's listing as one string: every document file's name, mtime and size.
|
|
12231
|
-
*
|
|
12232
|
-
*
|
|
12233
|
-
*
|
|
12234
|
-
*
|
|
12235
|
-
*
|
|
12466
|
+
* The folder's listing as one string: every document file's name, mtime and size. A pass
|
|
12467
|
+
* first lists the two document subdirectories - a stat per file, no reads - and runs the
|
|
12468
|
+
* store's reconcile only when this differs from the last pass, so a pass before every
|
|
12469
|
+
* tool call on a large graph costs nothing when nothing moved. Only document files count:
|
|
12470
|
+
* a browser saving into the same folder writes a `.crswap` swap file first and renames it
|
|
12471
|
+
* over the document when the save lands, and a pass started by the swap file would read
|
|
12472
|
+
* the documents while that rename is due, which on Windows makes the browser's save fail.
|
|
12473
|
+
* An edit that keeps both mtime and size (an mtime-preserving copy) is missed until
|
|
12236
12474
|
* something else changes, the same blind spot the store's own fast path accepts.
|
|
12237
12475
|
*/
|
|
12238
12476
|
const listingSignature = async () => {
|
|
12239
12477
|
const parts = [];
|
|
12240
|
-
for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir))
|
|
12478
|
+
for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) {
|
|
12479
|
+
if (!isDocumentFile(entry.name)) continue;
|
|
12480
|
+
parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
|
|
12481
|
+
}
|
|
12241
12482
|
return parts.sort().join("|");
|
|
12242
12483
|
};
|
|
12243
12484
|
let lastListing;
|
|
@@ -13318,31 +13559,48 @@ function archiveHtml(journals) {
|
|
|
13318
13559
|
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
13560
|
}
|
|
13320
13561
|
//#endregion
|
|
13321
|
-
//#region ../client/src/lib/document/
|
|
13322
|
-
|
|
13323
|
-
|
|
13324
|
-
|
|
13562
|
+
//#region ../client/src/lib/document/code-languages.ts
|
|
13563
|
+
/**
|
|
13564
|
+
* The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
|
|
13565
|
+
* fences (`@codemirror/language-data`, see `view/augmentations/code-highlight.ts`). Every surface
|
|
13566
|
+
* that highlights code outside an editor - the publisher (`publish/highlight.ts`) and the
|
|
13567
|
+
* read-only quotes (`code-tokens.ts`) - resolves a language here, so each knows the languages
|
|
13568
|
+
* the editor knows, by the same names and aliases.
|
|
13569
|
+
*
|
|
13570
|
+
* The match is exact: a language's name or one of its aliases, in any case. The editor's own
|
|
13571
|
+
* lookup (`@codemirror/lang-markdown`) also matches an alias found inside the info-string, which
|
|
13572
|
+
* reads `text`, `plaintext` and `context` as LaTeX (alias `tex`), so a plain-text block turned
|
|
13573
|
+
* into a LaTeX one, `%` starting a comment. That fuzzy step is not repeated here.
|
|
13574
|
+
*/
|
|
13325
13575
|
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
13576
|
/** The grammar for an info-string, loaded once; null for a language the editor does not know either. */
|
|
13332
|
-
async function
|
|
13577
|
+
async function loadCodeLanguage(lang) {
|
|
13333
13578
|
const key = lang.toLowerCase();
|
|
13334
13579
|
let pending = loaded.get(key);
|
|
13335
13580
|
if (!pending) {
|
|
13336
|
-
const description =
|
|
13581
|
+
const description = LanguageDescription.matchLanguageName(languages, key, false);
|
|
13337
13582
|
pending = description ? description.load().catch(() => null) : Promise.resolve(null);
|
|
13338
13583
|
loaded.set(key, pending);
|
|
13339
13584
|
}
|
|
13340
13585
|
return pending;
|
|
13341
13586
|
}
|
|
13587
|
+
//#endregion
|
|
13588
|
+
//#region ../client/src/lib/document/publish/highlight.ts
|
|
13589
|
+
/**
|
|
13590
|
+
* Code highlighting for the site with the grammars the editor already ships: the same
|
|
13591
|
+
* `@codemirror/language-data` registry `code-highlight.ts` nests inside fences, resolved by
|
|
13592
|
+
* `code-languages.ts`, run headless over the fence's text and emitted as `<span class="tok-…">`
|
|
13593
|
+
* (the `classHighlighter` names), so a site knows exactly the languages the editor knows and needs
|
|
13594
|
+
* no script for it. A theme colours the `tok-*` classes. Unknown languages come back as null and
|
|
13595
|
+
* render escaped.
|
|
13596
|
+
*/
|
|
13597
|
+
function escapeHtml$5(text) {
|
|
13598
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
13599
|
+
}
|
|
13342
13600
|
/** The fence's code as highlighted HTML (the `<code>` element's inner HTML), or null when the language is unknown. */
|
|
13343
13601
|
async function highlightCode(lang, code) {
|
|
13344
13602
|
if (lang === "") return null;
|
|
13345
|
-
const support = await
|
|
13603
|
+
const support = await loadCodeLanguage(lang);
|
|
13346
13604
|
if (!support) return null;
|
|
13347
13605
|
const tree = support.language.parser.parse(code);
|
|
13348
13606
|
let out = "";
|
|
@@ -13607,15 +13865,22 @@ function wikilinkRule(md, resolve) {
|
|
|
13607
13865
|
*/
|
|
13608
13866
|
/** A document-relative asset reference: any number of `../`, `assets/`, the name. */
|
|
13609
13867
|
var ASSET_REF = /^(?:\.\.\/)*assets\/(.+)$/;
|
|
13868
|
+
/**
|
|
13869
|
+
* The asset a reference names, decoded, or null when it names none. A name that decodes to
|
|
13870
|
+
* anything but one file inside `assets/` is not an asset: it would become a bundle path, and a site
|
|
13871
|
+
* folder, outside `assets/` (see `isSafeAssetName`). The link is then left as the author wrote it.
|
|
13872
|
+
*/
|
|
13610
13873
|
function assetNameOf(ref) {
|
|
13611
13874
|
const match = ASSET_REF.exec(ref);
|
|
13612
13875
|
if (!match) return null;
|
|
13613
13876
|
const raw = match[1].split(/[?#]/)[0];
|
|
13877
|
+
let name;
|
|
13614
13878
|
try {
|
|
13615
|
-
|
|
13879
|
+
name = decodeURIComponent(raw);
|
|
13616
13880
|
} catch {
|
|
13617
|
-
|
|
13881
|
+
name = raw;
|
|
13618
13882
|
}
|
|
13883
|
+
return isSafeAssetName(name) ? name : null;
|
|
13619
13884
|
}
|
|
13620
13885
|
function escapeHtml$3(text) {
|
|
13621
13886
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
@@ -13823,7 +14088,7 @@ function linkTargetPattern(excluded = "") {
|
|
|
13823
14088
|
* A regex may not repeat a group name, so compose this **once** per pattern. Wrap it in a group of
|
|
13824
14089
|
* your own where you need the whole link as one capture.
|
|
13825
14090
|
*/
|
|
13826
|
-
var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>[^\\]]*)\\]\\((?<target>${linkTargetPattern()})\\)`;
|
|
14091
|
+
var MARKDOWN_LINK = `(?<bang>!?)\\[(?<label>(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*)\\]\\((?<target>${linkTargetPattern()})\\)`;
|
|
13827
14092
|
/** A match's groups, typed. Every {@link MARKDOWN_LINK} match has all three. */
|
|
13828
14093
|
function linkGroups(match) {
|
|
13829
14094
|
return match.groups;
|
|
@@ -13925,6 +14190,24 @@ function contentOf(block) {
|
|
|
13925
14190
|
return first.replace(/^-\s+/, "").trim();
|
|
13926
14191
|
}
|
|
13927
14192
|
var WHOLE_LINK = new RegExp(`^${MARKDOWN_LINK}$`);
|
|
14193
|
+
/** The schemes an outline link may carry onto a published page; a target with no scheme is a path on the site. */
|
|
14194
|
+
var NAV_LINK_SCHEMES = new Set([
|
|
14195
|
+
"http",
|
|
14196
|
+
"https",
|
|
14197
|
+
"mailto"
|
|
14198
|
+
]);
|
|
14199
|
+
/**
|
|
14200
|
+
* An outline link's target, or null when a published page must not carry it. Links in page bodies
|
|
14201
|
+
* go through markdown-it's validateLink; the outline is parsed here, so it gets its own rule. The
|
|
14202
|
+
* scheme is read after removing the control characters and spaces a browser would ignore, so
|
|
14203
|
+
* '\u0001javascript:' is read as javascript.
|
|
14204
|
+
*/
|
|
14205
|
+
function navLinkTarget(target) {
|
|
14206
|
+
const visible = [...target].filter((c) => c.charCodeAt(0) > 32 && c.charCodeAt(0) !== 127).join("");
|
|
14207
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(visible);
|
|
14208
|
+
if (!scheme) return target;
|
|
14209
|
+
return NAV_LINK_SCHEMES.has(scheme[1].toLowerCase()) ? target : null;
|
|
14210
|
+
}
|
|
13928
14211
|
function buildNav(outline, resolver) {
|
|
13929
14212
|
const issues = [];
|
|
13930
14213
|
function convert(blocks) {
|
|
@@ -13958,10 +14241,24 @@ function buildNav(outline, resolver) {
|
|
|
13958
14241
|
const link = WHOLE_LINK.exec(content);
|
|
13959
14242
|
if (link && !linkGroups(link).bang) {
|
|
13960
14243
|
const { label, target } = linkGroups(link);
|
|
14244
|
+
const href = navLinkTarget(target);
|
|
14245
|
+
if (href === null) {
|
|
14246
|
+
issues.push({
|
|
14247
|
+
level: "warning",
|
|
14248
|
+
code: "nav-link-unsafe",
|
|
14249
|
+
message: `The navigation links "${label}" to a target a published page does not carry (only http, https, mailto and site paths); the entry is shown without its link.`
|
|
14250
|
+
});
|
|
14251
|
+
out.push({
|
|
14252
|
+
label,
|
|
14253
|
+
labelHtml: escapeHtml$1(label),
|
|
14254
|
+
children
|
|
14255
|
+
});
|
|
14256
|
+
continue;
|
|
14257
|
+
}
|
|
13961
14258
|
out.push({
|
|
13962
14259
|
label,
|
|
13963
14260
|
labelHtml: escapeHtml$1(label),
|
|
13964
|
-
href
|
|
14261
|
+
href,
|
|
13965
14262
|
external: true,
|
|
13966
14263
|
children
|
|
13967
14264
|
});
|
|
@@ -14573,7 +14870,7 @@ async function publishPublication(source, publication, env, options = {}) {
|
|
|
14573
14870
|
let assetsDone = 0;
|
|
14574
14871
|
for (const [name, from] of wanted) {
|
|
14575
14872
|
progress("assets", assetsDone++, wanted.size);
|
|
14576
|
-
const asset = await source.readAsset(`../assets/${name}`);
|
|
14873
|
+
const asset = await source.readAsset(`../assets/${encodeURIComponent(name)}`);
|
|
14577
14874
|
if (!asset) {
|
|
14578
14875
|
report.assets.missing.push({
|
|
14579
14876
|
name,
|
|
@@ -14723,6 +15020,8 @@ function themeFilesOfBundled(theme) {
|
|
|
14723
15020
|
files
|
|
14724
15021
|
};
|
|
14725
15022
|
}
|
|
15023
|
+
/** Far more files than any theme uses, and a bound on the fetches one publish can trigger. */
|
|
15024
|
+
var MAX_URL_THEME_FILES = 500;
|
|
14726
15025
|
/** Fetch a theme from its manifest URL; every file the manifest lists, relative to it. */
|
|
14727
15026
|
async function fetchTheme(url, fetchText) {
|
|
14728
15027
|
let json;
|
|
@@ -14734,10 +15033,12 @@ async function fetchTheme(url, fetchText) {
|
|
|
14734
15033
|
const { manifest, errors } = parseThemeManifest(json);
|
|
14735
15034
|
if (!manifest) throw new Error(`The theme at ${url} cannot be used: ${errors.join(" ")}`);
|
|
14736
15035
|
if (manifest.files.length === 0) throw new Error(`The theme at ${url} lists no files in its manifest, so nothing can be fetched.`);
|
|
15036
|
+
if (manifest.files.length > MAX_URL_THEME_FILES) throw new Error(`The theme at ${url} lists ${manifest.files.length} files; a theme may list at most ${MAX_URL_THEME_FILES}.`);
|
|
14737
15037
|
const base = new URL(url);
|
|
14738
15038
|
const files = /* @__PURE__ */ new Map();
|
|
14739
15039
|
for (const path of manifest.files) {
|
|
14740
15040
|
const fileUrl = new URL(path, base).toString();
|
|
15041
|
+
if (!isThemeFilePath(path) || new URL(fileUrl).origin !== base.origin) throw new Error(`The theme at ${url} lists "${path}", which is not a theme file beside its manifest (layouts/, partials/ or assets/).`);
|
|
14741
15042
|
try {
|
|
14742
15043
|
files.set(path, await fetchText(fileUrl));
|
|
14743
15044
|
} catch (error) {
|
|
@@ -15069,6 +15370,150 @@ async function openDiagramRenderer(env) {
|
|
|
15069
15370
|
};
|
|
15070
15371
|
}
|
|
15071
15372
|
//#endregion
|
|
15373
|
+
//#region src/public-fetch.ts
|
|
15374
|
+
/**
|
|
15375
|
+
* Fetching text from the public internet, and nowhere else: the only network reads a tool makes
|
|
15376
|
+
* with an address it did not choose itself (a theme published at a URL, and every file its
|
|
15377
|
+
* manifest lists). The URL comes from a document or a tool argument, so a collaborator or a
|
|
15378
|
+
* prompt-injected agent could otherwise point this process at a service on the local machine or
|
|
15379
|
+
* the network it sits on, and read the answer back through `read_theme_file`.
|
|
15380
|
+
*
|
|
15381
|
+
* The rules: https only, no credentials in the URL, every address the host resolves to must be
|
|
15382
|
+
* public, redirects are followed by hand and each hop checked the same way, and a response is cut
|
|
15383
|
+
* off by a deadline and a size cap. The address check runs inside the socket's own DNS lookup, so
|
|
15384
|
+
* the address connected to is the address checked; resolving first and connecting afterwards
|
|
15385
|
+
* would let a second resolution answer differently.
|
|
15386
|
+
*/
|
|
15387
|
+
/** Ranges no theme is served from: loopback, private, link-local, shared, reserved, documentation, multicast. */
|
|
15388
|
+
var blocked = new BlockList();
|
|
15389
|
+
for (const [network, prefix] of [
|
|
15390
|
+
["0.0.0.0", 8],
|
|
15391
|
+
["10.0.0.0", 8],
|
|
15392
|
+
["100.64.0.0", 10],
|
|
15393
|
+
["127.0.0.0", 8],
|
|
15394
|
+
["169.254.0.0", 16],
|
|
15395
|
+
["172.16.0.0", 12],
|
|
15396
|
+
["192.0.0.0", 24],
|
|
15397
|
+
["192.0.2.0", 24],
|
|
15398
|
+
["192.168.0.0", 16],
|
|
15399
|
+
["198.18.0.0", 15],
|
|
15400
|
+
["198.51.100.0", 24],
|
|
15401
|
+
["203.0.113.0", 24],
|
|
15402
|
+
["224.0.0.0", 4],
|
|
15403
|
+
["240.0.0.0", 4]
|
|
15404
|
+
]) blocked.addSubnet(network, prefix, "ipv4");
|
|
15405
|
+
for (const [network, prefix] of [
|
|
15406
|
+
["::", 128],
|
|
15407
|
+
["::1", 128],
|
|
15408
|
+
["64:ff9b::", 96],
|
|
15409
|
+
["100::", 64],
|
|
15410
|
+
["2001:db8::", 32],
|
|
15411
|
+
["fc00::", 7],
|
|
15412
|
+
["fe80::", 10],
|
|
15413
|
+
["ff00::", 8]
|
|
15414
|
+
]) blocked.addSubnet(network, prefix, "ipv6");
|
|
15415
|
+
/** Whether an IP address is one on the public internet. Anything that is not an address is not. */
|
|
15416
|
+
function isPublicAddress(address) {
|
|
15417
|
+
const family = isIP(address);
|
|
15418
|
+
if (family === 4) return !blocked.check(address, "ipv4");
|
|
15419
|
+
if (family !== 6) return false;
|
|
15420
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
|
|
15421
|
+
if (dotted) return isPublicAddress(dotted[1]);
|
|
15422
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
|
|
15423
|
+
if (hex) {
|
|
15424
|
+
const [high, low] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
15425
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
15426
|
+
}
|
|
15427
|
+
return !blocked.check(address, "ipv6");
|
|
15428
|
+
}
|
|
15429
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
15430
|
+
/** Far above any real theme file, and a bound on what one response can hold in memory. */
|
|
15431
|
+
var DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
15432
|
+
var DEFAULT_MAX_REDIRECTS = 3;
|
|
15433
|
+
var NotPublicError = class extends Error {
|
|
15434
|
+
code = "ENOTPUBLIC";
|
|
15435
|
+
};
|
|
15436
|
+
/** A lookup that answers only when every address the name resolves to is public. */
|
|
15437
|
+
function publicOnly(lookup) {
|
|
15438
|
+
return (hostname, options, callback) => {
|
|
15439
|
+
lookup(hostname, { all: true }, (error, addresses) => {
|
|
15440
|
+
if (error) return callback(error);
|
|
15441
|
+
const refused = addresses.find((entry) => !isPublicAddress(entry.address));
|
|
15442
|
+
if (refused || addresses.length === 0) return callback(new NotPublicError(`${hostname} resolves to ${refused?.address ?? "no address"}, which is not a public address.`));
|
|
15443
|
+
if (options.all) callback(null, addresses);
|
|
15444
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
15445
|
+
});
|
|
15446
|
+
};
|
|
15447
|
+
}
|
|
15448
|
+
function checkUrl(url) {
|
|
15449
|
+
if (url.protocol !== "https:") throw new Error(`Only https URLs can be fetched, not ${url.protocol}//${url.host}.`);
|
|
15450
|
+
if (url.username || url.password) throw new Error("A URL with credentials in it cannot be fetched.");
|
|
15451
|
+
}
|
|
15452
|
+
function fetchOnce(url, deps, deadline) {
|
|
15453
|
+
return new Promise((resolve, reject) => {
|
|
15454
|
+
const remaining = deadline - Date.now();
|
|
15455
|
+
if (remaining <= 0) return reject(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`));
|
|
15456
|
+
const req = deps.request(url, {
|
|
15457
|
+
method: "GET",
|
|
15458
|
+
lookup: publicOnly(deps.lookup),
|
|
15459
|
+
headers: {
|
|
15460
|
+
accept: "*/*",
|
|
15461
|
+
"user-agent": "etherpk-mcp"
|
|
15462
|
+
}
|
|
15463
|
+
}, (res) => {
|
|
15464
|
+
const status = res.statusCode ?? 0;
|
|
15465
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
15466
|
+
res.resume();
|
|
15467
|
+
return resolve({ redirect: new URL(res.headers.location, url) });
|
|
15468
|
+
}
|
|
15469
|
+
if (status < 200 || status >= 300) {
|
|
15470
|
+
res.resume();
|
|
15471
|
+
return reject(new Error(`${status} ${res.statusMessage ?? ""}`.trim()));
|
|
15472
|
+
}
|
|
15473
|
+
const chunks = [];
|
|
15474
|
+
let size = 0;
|
|
15475
|
+
res.on("data", (chunk) => {
|
|
15476
|
+
size += chunk.byteLength;
|
|
15477
|
+
if (size > deps.maxBytes) {
|
|
15478
|
+
req.destroy(/* @__PURE__ */ new Error(`${url.href} is larger than ${deps.maxBytes} bytes.`));
|
|
15479
|
+
return;
|
|
15480
|
+
}
|
|
15481
|
+
chunks.push(chunk);
|
|
15482
|
+
});
|
|
15483
|
+
res.on("end", () => {
|
|
15484
|
+
clearTimeout(timer);
|
|
15485
|
+
if (size <= deps.maxBytes) resolve({ text: Buffer.concat(chunks).toString("utf8") });
|
|
15486
|
+
});
|
|
15487
|
+
res.on("error", reject);
|
|
15488
|
+
});
|
|
15489
|
+
const timer = setTimeout(() => req.destroy(/* @__PURE__ */ new Error(`Fetching ${url.href} took longer than ${deps.timeoutMs / 1e3} seconds.`)), remaining);
|
|
15490
|
+
req.on("error", (error) => {
|
|
15491
|
+
clearTimeout(timer);
|
|
15492
|
+
reject(error);
|
|
15493
|
+
});
|
|
15494
|
+
req.end();
|
|
15495
|
+
});
|
|
15496
|
+
}
|
|
15497
|
+
/** The text at a public https URL, under the rules at the top of this module. */
|
|
15498
|
+
async function fetchPublicText(href, options = {}) {
|
|
15499
|
+
const deps = {
|
|
15500
|
+
lookup: options.lookup ?? lookup,
|
|
15501
|
+
request: options.request ?? request,
|
|
15502
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
15503
|
+
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
15504
|
+
maxRedirects: options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
|
15505
|
+
};
|
|
15506
|
+
const deadline = Date.now() + deps.timeoutMs;
|
|
15507
|
+
let url = new URL(href);
|
|
15508
|
+
for (let hops = 0;; hops += 1) {
|
|
15509
|
+
checkUrl(url);
|
|
15510
|
+
const hop = await fetchOnce(url, deps, deadline);
|
|
15511
|
+
if ("text" in hop) return hop.text;
|
|
15512
|
+
if (hops >= deps.maxRedirects) throw new Error(`${href} redirected more than ${deps.maxRedirects} times.`);
|
|
15513
|
+
url = hop.redirect;
|
|
15514
|
+
}
|
|
15515
|
+
}
|
|
15516
|
+
//#endregion
|
|
15072
15517
|
//#region src/publish-environment.ts
|
|
15073
15518
|
/**
|
|
15074
15519
|
* The publisher's environment in Node (ADR 0082, ADR 0084): themes resolved the way the Client
|
|
@@ -15078,11 +15523,8 @@ async function openDiagramRenderer(env) {
|
|
|
15078
15523
|
* counterpart of `host/browser-environment.ts`; the core is shared.
|
|
15079
15524
|
*/
|
|
15080
15525
|
var require = createRequire(import.meta.url);
|
|
15081
|
-
|
|
15082
|
-
|
|
15083
|
-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
15084
|
-
return response.text();
|
|
15085
|
-
}
|
|
15526
|
+
/** A url theme's files, from public https hosts only (see public-fetch.ts). */
|
|
15527
|
+
var fetchText$1 = (url) => fetchPublicText(url);
|
|
15086
15528
|
var katexAssetsPromise = null;
|
|
15087
15529
|
/** `katex.min.css` plus its woff2 fonts under `fonts/`, as the stylesheet references them. */
|
|
15088
15530
|
async function katexAssets() {
|
|
@@ -15237,6 +15679,56 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
|
|
|
15237
15679
|
} };
|
|
15238
15680
|
}
|
|
15239
15681
|
//#endregion
|
|
15682
|
+
//#region src/local-folders.ts
|
|
15683
|
+
/**
|
|
15684
|
+
* Where the tools may write on this machine. A tool argument is chosen by the agent, and the agent
|
|
15685
|
+
* can be steered by what it reads in the notes, so a folder it names is only ever a place under
|
|
15686
|
+
* the graph's downloads directory (ADR 0086 keeps even that choice from `publish`). A name that
|
|
15687
|
+
* resolves outside it, directly or through a symbolic link, is refused.
|
|
15688
|
+
*/
|
|
15689
|
+
/** A folder the agent named that is not under the base; the tools report it as `invalid_argument`. */
|
|
15690
|
+
var FolderRefused = class extends Error {};
|
|
15691
|
+
/** Whether `child` is `parent` or a path beneath it; both already resolved. */
|
|
15692
|
+
function within(parent, child) {
|
|
15693
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
15694
|
+
}
|
|
15695
|
+
/** The nearest part of `path` that exists on disk: the path itself, or its closest existing parent. */
|
|
15696
|
+
async function existingPart(path) {
|
|
15697
|
+
let at = path;
|
|
15698
|
+
for (;;) try {
|
|
15699
|
+
await lstat(at);
|
|
15700
|
+
return at;
|
|
15701
|
+
} catch {
|
|
15702
|
+
const up = dirname(at);
|
|
15703
|
+
if (up === at) return at;
|
|
15704
|
+
at = up;
|
|
15705
|
+
}
|
|
15706
|
+
}
|
|
15707
|
+
/**
|
|
15708
|
+
* The folder the agent asked for under `base`, or `fallback` (a path relative to `base`) when it
|
|
15709
|
+
* named none. A relative name is taken relative to `base`; an absolute one must already be under
|
|
15710
|
+
* it. Checked twice: as written, and after resolving every symbolic link in the part that exists.
|
|
15711
|
+
*/
|
|
15712
|
+
async function folderUnder(base, requested, fallback) {
|
|
15713
|
+
const root = resolve(base);
|
|
15714
|
+
const wanted = requested?.trim() ? requested.trim() : fallback;
|
|
15715
|
+
const target = resolve(root, wanted);
|
|
15716
|
+
if (!within(root, target)) throw new FolderRefused(`"${wanted}" is outside ${root}. Name a folder under it, or leave it out for the default.`);
|
|
15717
|
+
await mkdir(root, { recursive: true });
|
|
15718
|
+
if (!within(await realpath(root), await realpath(await existingPart(target)))) throw new FolderRefused(`"${wanted}" leads outside ${root} through a symbolic link.`);
|
|
15719
|
+
return target;
|
|
15720
|
+
}
|
|
15721
|
+
/**
|
|
15722
|
+
* Whether a preview page may load `url`: its own files and inline data, nothing else. A theme's
|
|
15723
|
+
* script runs when the preview is photographed, and a theme can come from a collaborator or a URL,
|
|
15724
|
+
* so the page is kept off the network and away from the rest of the disk.
|
|
15725
|
+
*/
|
|
15726
|
+
function previewRequestAllowed(url, folder) {
|
|
15727
|
+
if (url.startsWith("data:") || url.startsWith("blob:") || url === "about:blank") return true;
|
|
15728
|
+
const own = pathToFileURL(folder.endsWith(sep) ? folder : folder + sep).href;
|
|
15729
|
+
return url.startsWith(own);
|
|
15730
|
+
}
|
|
15731
|
+
//#endregion
|
|
15240
15732
|
//#region src/headless-assets.ts
|
|
15241
15733
|
/**
|
|
15242
15734
|
* What the asset tools need from a graph's [[Asset]]s, behind one seam for both backends
|
|
@@ -15247,9 +15739,46 @@ function withPublishFolder(folders, graphKey, publicationId, folder) {
|
|
|
15247
15739
|
* reference names an asset (`identify`), which is what the index is asked about when the tools
|
|
15248
15740
|
* decide whether an asset is reachable from a document the agent can read.
|
|
15249
15741
|
*/
|
|
15250
|
-
/**
|
|
15251
|
-
|
|
15252
|
-
|
|
15742
|
+
/**
|
|
15743
|
+
* The largest file `upload_asset` reads. The Sync Server applies its own, usually smaller, limit;
|
|
15744
|
+
* this one bounds what a single call can pull into memory whatever the backend.
|
|
15745
|
+
*/
|
|
15746
|
+
var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
15747
|
+
async function realOrResolved(path) {
|
|
15748
|
+
try {
|
|
15749
|
+
return await realpath(path);
|
|
15750
|
+
} catch {
|
|
15751
|
+
return resolve(path);
|
|
15752
|
+
}
|
|
15753
|
+
}
|
|
15754
|
+
/**
|
|
15755
|
+
* Refuse a file an agent should not be able to put into a graph. The path is a tool argument, and
|
|
15756
|
+
* an agent can be steered by what it reads, so an upload is a way to copy a local file somewhere
|
|
15757
|
+
* collaborators can read it. Refused: the Headless Client's cache (the graph's decrypted
|
|
15758
|
+
* contents) and config (sign-in tokens), and anything hidden (a dot-file, or a file inside a
|
|
15759
|
+
* dot-folder such as `.ssh` or `.aws`), which is where credentials and settings live. `real` has
|
|
15760
|
+
* every link resolved, so a link is judged by the file it leads to.
|
|
15761
|
+
*/
|
|
15762
|
+
async function refuseProtected(path, real, rules) {
|
|
15763
|
+
if (within(await realOrResolved(rules.downloadsDir), real)) return;
|
|
15764
|
+
if (within(await realOrResolved(cacheRoot(rules.env)), real)) throw new Error(`${path} is in the Headless Client's cache, which holds the graph's decrypted contents.`);
|
|
15765
|
+
const configFile = await realOrResolved(defaultConfigPath(rules.env));
|
|
15766
|
+
const configDir = await realOrResolved(dirname(defaultConfigPath({
|
|
15767
|
+
...rules.env,
|
|
15768
|
+
ETHERPK_MCP_CONFIG: void 0
|
|
15769
|
+
})));
|
|
15770
|
+
if (real === configFile || within(configDir, real)) throw new Error(`${path} is the Headless Client's config, which holds its sign-in tokens.`);
|
|
15771
|
+
const hidden = real.split(sep).find((part) => part.startsWith("."));
|
|
15772
|
+
if (hidden) throw new Error(`${path} is hidden (${hidden}), where credentials and settings are kept. Copy it to an ordinary folder to upload it.`);
|
|
15773
|
+
}
|
|
15774
|
+
/** A local file as `upload_asset` reads it: an ordinary file, not refused above, within the size limit. */
|
|
15775
|
+
async function readLocalFile(path, rules) {
|
|
15776
|
+
const real = await realpath(path);
|
|
15777
|
+
await refuseProtected(path, real, rules);
|
|
15778
|
+
const info = await stat(real);
|
|
15779
|
+
if (!info.isFile()) throw new Error(`${path} is not a file.`);
|
|
15780
|
+
if (info.size > 104857600) throw new Error(`${path} is larger than ${MAX_UPLOAD_BYTES / (1024 * 1024)} MiB.`);
|
|
15781
|
+
const buffer = await readFile(real);
|
|
15253
15782
|
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
|
15254
15783
|
bytes.set(buffer);
|
|
15255
15784
|
return {
|
|
@@ -15257,24 +15786,54 @@ async function readLocalFile(path) {
|
|
|
15257
15786
|
bytes
|
|
15258
15787
|
};
|
|
15259
15788
|
}
|
|
15789
|
+
/** Names Windows reserves for devices, whatever the extension. */
|
|
15790
|
+
var RESERVED = /^(con|prn|aux|nul|com\d|lpt\d)$/i;
|
|
15791
|
+
var MAX_NAME_LENGTH = 120;
|
|
15260
15792
|
/**
|
|
15261
|
-
*
|
|
15262
|
-
*
|
|
15263
|
-
*
|
|
15793
|
+
* A file name for an asset's stored name. The stored name is metadata any collaborator can set,
|
|
15794
|
+
* so it is reduced to one plain file: the last path segment, letters, digits, marks, spaces and
|
|
15795
|
+
* `. _ - ( )` kept and everything else replaced, no leading dot (so never hidden, never `..`),
|
|
15796
|
+
* no trailing dot or space (Windows drops them), not a Windows device name, and short.
|
|
15797
|
+
*/
|
|
15798
|
+
function downloadName(name) {
|
|
15799
|
+
let safe = (name.split(/[\\/]/).pop() ?? "").replace(/[^\p{L}\p{M}\p{N} ._()-]/gu, "_").replace(/^[.\s]+/, "").replace(/[.\s]+$/, "");
|
|
15800
|
+
if (safe === "") return "asset";
|
|
15801
|
+
if (RESERVED.test(safe.split(".")[0])) safe = `_${safe}`;
|
|
15802
|
+
if (Array.from(safe).length <= MAX_NAME_LENGTH) return safe;
|
|
15803
|
+
const dot = safe.lastIndexOf(".");
|
|
15804
|
+
const ext = dot > 0 && safe.length - dot <= 20 ? safe.slice(dot) : "";
|
|
15805
|
+
return Array.from(safe.slice(0, safe.length - ext.length)).slice(0, MAX_NAME_LENGTH - ext.length).join("") + ext;
|
|
15806
|
+
}
|
|
15807
|
+
/** Whether `path` is an ordinary file (not a link) holding exactly `bytes`. */
|
|
15808
|
+
async function holds(path, bytes) {
|
|
15809
|
+
const info = await lstat(path);
|
|
15810
|
+
if (!info.isFile() || info.size !== bytes.byteLength) return false;
|
|
15811
|
+
return (await readFile(path)).equals(bytes);
|
|
15812
|
+
}
|
|
15813
|
+
/**
|
|
15814
|
+
* Write an asset's bytes under `directory`, named by {@link downloadName}, and never over
|
|
15815
|
+
* anything already there: the write fails rather than replace a file or follow a link left in
|
|
15816
|
+
* its place, and the next candidate is tried. A second asset that shares a display name lands
|
|
15817
|
+
* beside the first with a suffix, so an agent reading two `diagram.png`s from two pages gets
|
|
15818
|
+
* both; the same bytes again answer the file already written.
|
|
15264
15819
|
*/
|
|
15265
15820
|
async function writeDownload(directory, name, bytes) {
|
|
15266
15821
|
await mkdir(directory, { recursive: true });
|
|
15267
|
-
const
|
|
15268
|
-
const
|
|
15269
|
-
const
|
|
15270
|
-
|
|
15271
|
-
for (let n =
|
|
15272
|
-
const
|
|
15273
|
-
|
|
15274
|
-
|
|
15275
|
-
|
|
15276
|
-
|
|
15277
|
-
|
|
15822
|
+
const safe = downloadName(name);
|
|
15823
|
+
const dot = safe.lastIndexOf(".");
|
|
15824
|
+
const stem = dot > 0 ? safe.slice(0, dot) : safe;
|
|
15825
|
+
const ext = dot > 0 ? safe.slice(dot) : "";
|
|
15826
|
+
for (let n = 1; n <= 1e3; n += 1) {
|
|
15827
|
+
const candidate = join(directory, n === 1 ? safe : `${stem}-${n}${ext}`);
|
|
15828
|
+
try {
|
|
15829
|
+
await writeFile(candidate, bytes, { flag: "wx" });
|
|
15830
|
+
return candidate;
|
|
15831
|
+
} catch (error) {
|
|
15832
|
+
if (error.code !== "EEXIST") throw error;
|
|
15833
|
+
}
|
|
15834
|
+
if (await holds(candidate, bytes)) return candidate;
|
|
15835
|
+
}
|
|
15836
|
+
throw new Error(`${directory} already holds a thousand files named like ${safe}.`);
|
|
15278
15837
|
}
|
|
15279
15838
|
/** UTF-16 units of document text returned before `truncated` is set. */
|
|
15280
15839
|
var READ_TEXT_CAP = 2e5;
|
|
@@ -15516,7 +16075,7 @@ async function backlinks(graph, concept) {
|
|
|
15516
16075
|
kind: ref.kind,
|
|
15517
16076
|
line: ref.line,
|
|
15518
16077
|
breadcrumb: ref.breadcrumb,
|
|
15519
|
-
text: ref.kind === "block" ? ref.subtree.map((node) => node.
|
|
16078
|
+
text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
|
|
15520
16079
|
}))
|
|
15521
16080
|
}))
|
|
15522
16081
|
};
|
|
@@ -15821,7 +16380,10 @@ async function uploadAsset(graph, args) {
|
|
|
15821
16380
|
if (!args.path || args.path.trim() === "") throw new ToolError("invalid_argument", "path must name a file on this machine.");
|
|
15822
16381
|
let file;
|
|
15823
16382
|
try {
|
|
15824
|
-
file = await readLocalFile(args.path.trim()
|
|
16383
|
+
file = await readLocalFile(args.path.trim(), {
|
|
16384
|
+
env: process.env,
|
|
16385
|
+
downloadsDir: assets.downloadsDir
|
|
16386
|
+
});
|
|
15825
16387
|
} catch (error) {
|
|
15826
16388
|
throw new ToolError("invalid_argument", `Cannot read "${args.path}": ${error instanceof Error ? error.message : String(error)}`);
|
|
15827
16389
|
}
|
|
@@ -15866,8 +16428,15 @@ async function readAsset(graph, args) {
|
|
|
15866
16428
|
if (!documents) throw new ToolError("asset_not_found", `No document you can read references "${displayNameOf(ref)}", so it is not available here.`);
|
|
15867
16429
|
const asset = await assets.store.readBytes(ref);
|
|
15868
16430
|
if (!asset) throw new ToolError("asset_not_found", `The graph does not hold "${displayNameOf(ref)}" (the reference may be broken).`);
|
|
16431
|
+
let directory;
|
|
16432
|
+
try {
|
|
16433
|
+
directory = await folderUnder(assets.downloadsDir, args.out_dir, ".");
|
|
16434
|
+
} catch (error) {
|
|
16435
|
+
if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
|
|
16436
|
+
throw error;
|
|
16437
|
+
}
|
|
15869
16438
|
return {
|
|
15870
|
-
path: await writeDownload(
|
|
16439
|
+
path: await writeDownload(directory, asset.name || displayNameOf(ref), asset.bytes),
|
|
15871
16440
|
name: asset.name || displayNameOf(ref),
|
|
15872
16441
|
type: asset.type,
|
|
15873
16442
|
bytes: asset.bytes.byteLength,
|
|
@@ -16418,15 +16987,25 @@ function hostOf(host) {
|
|
|
16418
16987
|
cmd: host?.cmd ?? "etherpk-mcp"
|
|
16419
16988
|
};
|
|
16420
16989
|
}
|
|
16990
|
+
/** The graph's downloads directory: every folder these tools write to, or read a theme back from, is under it. */
|
|
16991
|
+
function downloadsOf(graph) {
|
|
16992
|
+
return graph.assets?.downloadsDir ?? join(process.cwd(), "etherpk-downloads");
|
|
16993
|
+
}
|
|
16994
|
+
/** A folder under the downloads directory, or the tool's refusal. */
|
|
16995
|
+
async function toolFolder(graph, requested, fallback) {
|
|
16996
|
+
try {
|
|
16997
|
+
return await folderUnder(downloadsOf(graph), requested, fallback);
|
|
16998
|
+
} catch (error) {
|
|
16999
|
+
if (error instanceof FolderRefused) throw new ToolError("invalid_argument", error.message);
|
|
17000
|
+
throw error;
|
|
17001
|
+
}
|
|
17002
|
+
}
|
|
16421
17003
|
async function settle(graph) {
|
|
16422
17004
|
const result = await graph.settle();
|
|
16423
17005
|
if (!result.settled) throw new ToolError("not_settled", result.message);
|
|
16424
17006
|
}
|
|
16425
|
-
|
|
16426
|
-
|
|
16427
|
-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
16428
|
-
return response.text();
|
|
16429
|
-
}
|
|
17007
|
+
/** A url theme's files, from public https hosts only (see public-fetch.ts). */
|
|
17008
|
+
var fetchText = (url) => fetchPublicText(url);
|
|
16430
17009
|
/** The publications whose saved mapping names a theme: what makes it undeletable. */
|
|
16431
17010
|
async function publicationsUsing(graph, themeId) {
|
|
16432
17011
|
const { source } = await graph.publishing.readSource();
|
|
@@ -16523,12 +17102,11 @@ async function readTheme(graph, args) {
|
|
|
16523
17102
|
await graph.store.refresh();
|
|
16524
17103
|
const ref = args.ref.trim();
|
|
16525
17104
|
const { source, files, theme } = await filesOf(graph, ref);
|
|
16526
|
-
const folder =
|
|
17105
|
+
const folder = await toolFolder(graph, args.out_dir, join("themes", ref.replace(/[^A-Za-z0-9._-]/g, "_")));
|
|
17106
|
+
const site = nodeSiteFolder(folder);
|
|
16527
17107
|
const written = [];
|
|
16528
17108
|
for (const [path, text] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
16529
|
-
|
|
16530
|
-
await mkdir(dirname(full), { recursive: true });
|
|
16531
|
-
await writeFile(full, text);
|
|
17109
|
+
await site.writeFile(path, text);
|
|
16532
17110
|
written.push({
|
|
16533
17111
|
path,
|
|
16534
17112
|
bytes: Buffer.byteLength(text)
|
|
@@ -16680,22 +17258,43 @@ async function deleteThemeFile(graph, args) {
|
|
|
16680
17258
|
errors: validationOf(after)
|
|
16681
17259
|
};
|
|
16682
17260
|
}
|
|
16683
|
-
/**
|
|
16684
|
-
|
|
16685
|
-
|
|
17261
|
+
/** Bounds on a theme folder: far beyond a real theme, and short of a whole disk. */
|
|
17262
|
+
var THEME_FOLDER_LIMITS = {
|
|
17263
|
+
files: 500,
|
|
17264
|
+
depth: 6,
|
|
17265
|
+
fileBytes: 1024 * 1024,
|
|
17266
|
+
totalBytes: 8 * 1024 * 1024
|
|
17267
|
+
};
|
|
17268
|
+
/**
|
|
17269
|
+
* Every allowed file under a directory, as the theme's whole file set. Symbolic links are not
|
|
17270
|
+
* followed, so a folder cannot pull in a file from elsewhere on disk, and the walk stops at the
|
|
17271
|
+
* limits above rather than reading whatever tree it was pointed at.
|
|
17272
|
+
*/
|
|
17273
|
+
async function filesUnder(base) {
|
|
16686
17274
|
const files = /* @__PURE__ */ new Map();
|
|
16687
|
-
|
|
17275
|
+
let seen = 0;
|
|
17276
|
+
let total = 0;
|
|
17277
|
+
const walk = async (at, depth) => {
|
|
17278
|
+
if (depth > THEME_FOLDER_LIMITS.depth) return;
|
|
16688
17279
|
for (const entry of await readdir(at, { withFileTypes: true })) {
|
|
17280
|
+
if (entry.isSymbolicLink()) continue;
|
|
16689
17281
|
const full = join(at, entry.name);
|
|
16690
17282
|
if (entry.isDirectory()) {
|
|
16691
|
-
if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full);
|
|
17283
|
+
if (entry.name !== ".git" && entry.name !== "node_modules") await walk(full, depth + 1);
|
|
16692
17284
|
continue;
|
|
16693
17285
|
}
|
|
17286
|
+
if (!entry.isFile()) continue;
|
|
17287
|
+
if (++seen > THEME_FOLDER_LIMITS.files) throw new Error(`it holds more than ${THEME_FOLDER_LIMITS.files} files`);
|
|
16694
17288
|
const path = relative(base, full).split(sep).join("/");
|
|
16695
|
-
if (isThemeFilePath(path))
|
|
17289
|
+
if (!isThemeFilePath(path)) continue;
|
|
17290
|
+
const { size } = await lstat(full);
|
|
17291
|
+
if (size > THEME_FOLDER_LIMITS.fileBytes) throw new Error(`${path} is larger than ${THEME_FOLDER_LIMITS.fileBytes} bytes`);
|
|
17292
|
+
total += size;
|
|
17293
|
+
if (total > THEME_FOLDER_LIMITS.totalBytes) throw new Error(`its theme files come to more than ${THEME_FOLDER_LIMITS.totalBytes} bytes`);
|
|
17294
|
+
files.set(path, await readFile(full, "utf8"));
|
|
16696
17295
|
}
|
|
16697
17296
|
};
|
|
16698
|
-
await walk(base);
|
|
17297
|
+
await walk(base, 0);
|
|
16699
17298
|
return files;
|
|
16700
17299
|
}
|
|
16701
17300
|
/**
|
|
@@ -16706,9 +17305,11 @@ async function filesUnder(dir) {
|
|
|
16706
17305
|
async function importThemeFolder(graph, args) {
|
|
16707
17306
|
await graph.store.refresh();
|
|
16708
17307
|
const theme = await requireEditable(graph, args.id.trim());
|
|
17308
|
+
if (!args.dir?.trim()) throw new ToolError("invalid_argument", "dir must name the theme folder, as read_theme returned it.");
|
|
17309
|
+
const dir = await toolFolder(graph, args.dir, "");
|
|
16709
17310
|
let files;
|
|
16710
17311
|
try {
|
|
16711
|
-
files = await filesUnder(
|
|
17312
|
+
files = await filesUnder(dir);
|
|
16712
17313
|
} catch (error) {
|
|
16713
17314
|
throw new ToolError("invalid_argument", `Cannot read "${args.dir}": ${error instanceof Error ? error.message : String(error)}`);
|
|
16714
17315
|
}
|
|
@@ -16745,8 +17346,8 @@ async function deleteTheme(graph, args) {
|
|
|
16745
17346
|
/**
|
|
16746
17347
|
* Render a theme to a folder on this machine and say where: the publication's real site when
|
|
16747
17348
|
* one is named, the sample site the Theme editor previews over otherwise. A preview is scratch,
|
|
16748
|
-
* not a [[Publish Folder]]:
|
|
16749
|
-
* emptied first
|
|
17349
|
+
* not a [[Publish Folder]]: it is always under the graph's downloads directory, the default folder
|
|
17350
|
+
* there is emptied first, and a named `out_dir` (also under it) is written into as it is. With `screenshots`, the browser
|
|
16750
17351
|
* photographs the front page and the first other page at desktop and phone widths, so the agent
|
|
16751
17352
|
* can look at what it changed.
|
|
16752
17353
|
*/
|
|
@@ -16779,7 +17380,7 @@ async function previewTheme(graph, args, host) {
|
|
|
16779
17380
|
});
|
|
16780
17381
|
const { bundle, report } = await publishPublication(source, publication, environment);
|
|
16781
17382
|
const scratch = !args.out_dir?.trim();
|
|
16782
|
-
const folder =
|
|
17383
|
+
const folder = await toolFolder(graph, args.out_dir, join("previews", themeRef.replace(/[^A-Za-z0-9._-]/g, "_")));
|
|
16783
17384
|
if (scratch) await rm(folder, {
|
|
16784
17385
|
recursive: true,
|
|
16785
17386
|
force: true
|
|
@@ -16810,12 +17411,10 @@ async function previewTheme(graph, args, host) {
|
|
|
16810
17411
|
await renderer?.dispose();
|
|
16811
17412
|
}
|
|
16812
17413
|
}
|
|
17414
|
+
/** The rendered site into the folder, through the writer `publish` uses: no path may leave the folder. */
|
|
16813
17415
|
async function writeBundle(folder, bundle) {
|
|
16814
|
-
|
|
16815
|
-
|
|
16816
|
-
await mkdir(dirname(full), { recursive: true });
|
|
16817
|
-
await writeFile(full, content);
|
|
16818
|
-
}
|
|
17416
|
+
const site = nodeSiteFolder(folder);
|
|
17417
|
+
for (const [path, content] of bundle) await site.writeFile(path, content);
|
|
16819
17418
|
}
|
|
16820
17419
|
/** The front page and the first other page, desktop and phone, as PNGs beside the site. */
|
|
16821
17420
|
async function photograph(env, folder, pages) {
|
|
@@ -16825,12 +17424,16 @@ async function photograph(env, folder, pages) {
|
|
|
16825
17424
|
const shots = [];
|
|
16826
17425
|
const targets = ["index.html", ...pages.filter((p) => p !== "index.html" && p !== "404.html").slice(0, 1)];
|
|
16827
17426
|
for (const page of targets) for (const width of [1280, 390]) {
|
|
16828
|
-
const context = await browser.newContext({
|
|
16829
|
-
|
|
16830
|
-
|
|
16831
|
-
|
|
17427
|
+
const context = await browser.newContext({
|
|
17428
|
+
viewport: {
|
|
17429
|
+
width,
|
|
17430
|
+
height: width === 390 ? 844 : 800
|
|
17431
|
+
},
|
|
17432
|
+
offline: true
|
|
17433
|
+
});
|
|
17434
|
+
await context.route("**/*", (route) => previewRequestAllowed(route.request().url(), folder) ? route.continue() : route.abort());
|
|
16832
17435
|
const tab = await context.newPage();
|
|
16833
|
-
await tab.goto(
|
|
17436
|
+
await tab.goto(pathToFileURL(join(folder, page)).href, { waitUntil: "load" });
|
|
16834
17437
|
const out = join(folder, `preview-${page.replace(/\.html$/, "")}-${width}.png`);
|
|
16835
17438
|
await tab.screenshot({
|
|
16836
17439
|
path: out,
|
|
@@ -16910,7 +17513,7 @@ function createMcpServer(graph, info) {
|
|
|
16910
17513
|
"Start with graph_info to see what you are connected to. read_documents reads several documents in one call; tasks lists tasks and set_task changes one (status, priority, due and scheduled dates) by document and line.",
|
|
16911
17514
|
"Publishing: list_publications shows the publications this graph defines (a publication is a page whose frontmatter defines it; its outline is the site navigation) and the public documents none takes; a document is on a site when its frontmatter has public: true and names the publication in publications. create_publication and update_publication change the settings; publish writes the site into the publish folder the user set for it on this machine with the etherpk-mcp publish command (the tool cannot choose a folder) and returns the report. Diagrams need a browser the user installs once with \"diagrams setup\".",
|
|
16912
17515
|
"Themes: a publication's look is a theme - Mustache templates, a stylesheet, a script and a manifest. list_themes shows the bundled ones (read-only) and the graph's own; read_theme writes a theme's files to a folder on this machine to read and edit; customise_publication_theme copies a publication's bundled theme into the graph and points the publication at the copy (create_theme copies any theme); write_theme_file, delete_theme_file and import_theme_folder change a graph theme; preview_theme renders a theme to a folder (with screenshots when a browser is set up) to check before publish. For a snippet such as an analytics script, an include slot (update_publication includes, e.g. head) filled by a page may be lighter than a theme copy.",
|
|
16913
|
-
"Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session).",
|
|
17516
|
+
"Images and files are assets: upload_asset adds a file from this machine and returns the markdown to paste into a document; read_asset writes an asset to a local file you can open; list_assets shows the assets documents reference. An asset is available only where a document you can read references it (or you uploaded it this session). read_asset, read_theme and preview_theme write under the graph's downloads directory and return the path; name a folder relative to it, never elsewhere.",
|
|
16914
17517
|
"The user documentation is at https://docs.etherpk.com."
|
|
16915
17518
|
].join("\n") });
|
|
16916
17519
|
server.registerTool("list_documents", {
|
|
@@ -17069,18 +17672,18 @@ function createMcpServer(graph, info) {
|
|
|
17069
17672
|
}, async (args) => run(() => setFrontmatter(graph, args)));
|
|
17070
17673
|
server.registerTool("upload_asset", {
|
|
17071
17674
|
title: "Upload an asset",
|
|
17072
|
-
description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"asset_refused\" when the server declines it on a quota.",
|
|
17675
|
+
description: "Add a file on this machine (an image, a PDF, any file) to the graph as an asset. Returns the reference and the markdown to paste into a document (an image tag for an image, a link otherwise). Bytes the graph already holds are not stored twice: the result then says reused: true and points at the existing asset. The file is stored as it is, without image optimisation. Refused with error \"invalid_argument\" for a folder, a file over 100 MiB, a hidden file or one in a hidden folder (.ssh, .env), and the Headless Client's own config and cache; with error \"asset_refused\" when the server declines it on a quota.",
|
|
17073
17676
|
inputSchema: {
|
|
17074
|
-
path: z.string().min(1).describe("Absolute path of
|
|
17677
|
+
path: z.string().min(1).describe("Absolute path of an ordinary file on this machine."),
|
|
17075
17678
|
name: z.string().min(1).optional().describe("The name to store it under; the file's own name by default.")
|
|
17076
17679
|
}
|
|
17077
17680
|
}, async (args) => run(() => uploadAsset(graph, args)));
|
|
17078
17681
|
server.registerTool("read_asset", {
|
|
17079
17682
|
title: "Read an asset",
|
|
17080
|
-
description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\".",
|
|
17683
|
+
description: "Write an asset's bytes to a file on this machine and return the path, so you can open or view it. Pass the reference as a document shows it (\"../assets/<name>\") or the name alone. Available only where a document you can read references the asset (or you uploaded it this session); otherwise error \"asset_not_found\". The file is written under the graph's downloads directory, named after the asset, and never over an existing file.",
|
|
17081
17684
|
inputSchema: {
|
|
17082
17685
|
ref: z.string().min(1),
|
|
17083
|
-
out_dir: z.string().min(1).optional().describe("
|
|
17686
|
+
out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; the downloads directory itself by default. A folder outside it is refused.")
|
|
17084
17687
|
}
|
|
17085
17688
|
}, async (args) => run(() => readAsset(graph, args)));
|
|
17086
17689
|
server.registerTool("list_assets", {
|
|
@@ -17141,7 +17744,7 @@ function createMcpServer(graph, info) {
|
|
|
17141
17744
|
description: "Write a theme's files (theme.json, layouts/, partials/, assets/) to a folder on this machine and return the path and file list, so you can read and edit them with your own tools. ref is a graph theme id, a bundled theme name or a url. Editing the folder changes nothing until write_theme_file or import_theme_folder brings it back; a bundled or url theme cannot be edited in place at all (create_theme copies it).",
|
|
17142
17745
|
inputSchema: {
|
|
17143
17746
|
ref: z.string().min(1),
|
|
17144
|
-
out_dir: z.string().min(1).optional().describe("
|
|
17747
|
+
out_dir: z.string().min(1).optional().describe("A folder under the graph's downloads directory, relative to it; themes/<ref> there by default. A folder outside it is refused.")
|
|
17145
17748
|
}
|
|
17146
17749
|
}, async (args) => run(() => readTheme(graph, args)));
|
|
17147
17750
|
server.registerTool("read_theme_file", {
|
|
@@ -17189,7 +17792,7 @@ function createMcpServer(graph, info) {
|
|
|
17189
17792
|
}, async (args) => run(() => deleteThemeFile(graph, args)));
|
|
17190
17793
|
server.registerTool("import_theme_folder", {
|
|
17191
17794
|
title: "Import a theme folder",
|
|
17192
|
-
description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. Validated afterwards.",
|
|
17795
|
+
description: "Replace a graph theme's files with a folder's contents - the way back after editing what read_theme wrote. Files the folder no longer has are removed from the theme; the folder needs a theme.json at its top. dir is the folder read_theme returned, or another under the graph's downloads directory; a folder outside it is refused, and symbolic links in it are skipped. Validated afterwards.",
|
|
17193
17796
|
inputSchema: {
|
|
17194
17797
|
id: z.string().min(1),
|
|
17195
17798
|
dir: z.string().min(1)
|
|
@@ -17202,7 +17805,7 @@ function createMcpServer(graph, info) {
|
|
|
17202
17805
|
}, async (args) => run(() => deleteTheme(graph, args)));
|
|
17203
17806
|
server.registerTool("preview_theme", {
|
|
17204
17807
|
title: "Preview a theme",
|
|
17205
|
-
description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder.",
|
|
17808
|
+
description: "Render a theme to a folder on this machine and return where: a publication's real pages when a publication is given (with its own theme, or the theme named), or a sample site covering every construct when only a theme is given. Open the HTML to check markup and styles. With screenshots: true and a browser set up (diagrams setup or ETHERPK_CHROMIUM), the front page and one content page are photographed at desktop and phone widths as PNGs you can view. A preview is scratch, not the publish folder. The page loads nothing from the network while it is photographed. out_dir is a folder under the graph's downloads directory (previews/<name> there by default); a folder outside it is refused.",
|
|
17206
17809
|
inputSchema: {
|
|
17207
17810
|
theme: z.string().min(1).optional(),
|
|
17208
17811
|
publication: z.string().min(1).optional(),
|
|
@@ -17252,9 +17855,18 @@ function standalone(bytes) {
|
|
|
17252
17855
|
copy.set(bytes);
|
|
17253
17856
|
return copy;
|
|
17254
17857
|
}
|
|
17858
|
+
/**
|
|
17859
|
+
* `name` as one file of `folder`, or a refusal. The store passes names that came from documents
|
|
17860
|
+
* (an asset reference decodes to one), and `join` would follow a `..` or a separator out of the
|
|
17861
|
+
* graph; the browser's directory handles refuse such names, and this keeps the two adapters alike.
|
|
17862
|
+
*/
|
|
17863
|
+
function fileIn(folder, name) {
|
|
17864
|
+
if (!isSingleFileName(name)) throw new Error(`"${name}" is not a file name.`);
|
|
17865
|
+
return join(folder, name);
|
|
17866
|
+
}
|
|
17255
17867
|
function createNodeDirectoryAdapter(root) {
|
|
17256
17868
|
const dir = resolve(root);
|
|
17257
|
-
const path = (subdir, name) => join(dir, subdir, name);
|
|
17869
|
+
const path = (subdir, name) => fileIn(join(dir, subdir), name);
|
|
17258
17870
|
/**
|
|
17259
17871
|
* The file's mtime as integer milliseconds, as the browser's `File.lastModified` is. Node's
|
|
17260
17872
|
* `mtimeMs` is a float built from seconds plus nanoseconds, and on some filesystems the
|
|
@@ -17335,7 +17947,7 @@ function createNodeDirectoryAdapter(root) {
|
|
|
17335
17947
|
for (const subdir of SUBDIRS) await mkdir(join(dir, subdir), { recursive: true });
|
|
17336
17948
|
},
|
|
17337
17949
|
async readRootFile(name) {
|
|
17338
|
-
const file =
|
|
17950
|
+
const file = fileIn(dir, name);
|
|
17339
17951
|
let text;
|
|
17340
17952
|
try {
|
|
17341
17953
|
text = await readFile(file, "utf8");
|
|
@@ -17349,7 +17961,7 @@ function createNodeDirectoryAdapter(root) {
|
|
|
17349
17961
|
};
|
|
17350
17962
|
},
|
|
17351
17963
|
async writeRootFile(name, text) {
|
|
17352
|
-
const file =
|
|
17964
|
+
const file = fileIn(dir, name);
|
|
17353
17965
|
await writeFile(file, text, "utf8");
|
|
17354
17966
|
return {
|
|
17355
17967
|
text,
|
|
@@ -17462,9 +18074,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
|
|
|
17462
18074
|
${CMD} diagrams status
|
|
17463
18075
|
Which browser a publish would use, if any.
|
|
17464
18076
|
|
|
17465
|
-
This machine can be signed in to several Sync Servers at once
|
|
17466
|
-
|
|
17467
|
-
|
|
18077
|
+
This machine can be signed in to several Sync Servers at once; --sync-server says which one
|
|
18078
|
+
a command means, and can be left out while only one is signed in. The config file is
|
|
18079
|
+
${defaultConfigPath()} (override with
|
|
17468
18080
|
ETHERPK_MCP_CONFIG); cached graphs live under ~/.cache/etherpk/mcp (override with
|
|
17469
18081
|
ETHERPK_MCP_CACHE_DIR).
|
|
17470
18082
|
Docs: https://docs.etherpk.com/using-ai-agents-with-your-notes
|