@hraness/wordcell 0.21.3 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -64
- package/dist/{authority-4sp9d552.js → authority-1pgftmwq.js} +55 -10
- package/dist/benchmark.js +3 -3
- package/dist/capture.js +6 -5
- package/dist/cli.js +37 -31
- package/dist/clip/acquire.js +3 -2
- package/dist/clip/cli.js +6 -5
- package/dist/clip/doctor.js +4 -3
- package/dist/clip/jobs.js +3 -3
- package/dist/clip/refresh.js +1 -1
- package/dist/evaluation-builder.js +18 -17
- package/dist/evaluation-kb.js +14 -13
- package/dist/graph-authority.js +3 -3
- package/dist/graph-percolation.js +5 -5
- package/dist/index-0dqfnrz9.js +1985 -0
- package/dist/{index-0k2x4nn9.js → index-1tm7bgx7.js} +16 -4
- package/dist/{index-vdw5yjfa.js → index-2hf27mws.js} +108 -18
- package/dist/index-3agn8scn.js +306 -0
- package/dist/{index-0rfcqab6.js → index-4ag64rc1.js} +555 -59
- package/dist/index-4knsp9qj.js +34 -0
- package/dist/index-66pshdtx.js +341 -0
- package/dist/index-9rf81m0p.js +368 -0
- package/dist/{index-59ssmqqm.js → index-a6kbv718.js} +1 -1
- package/dist/{index-51bap0mq.js → index-avbce90h.js} +9 -9
- package/dist/{index-9zcpdbjg.js → index-de2w8crk.js} +13 -13
- package/dist/{index-pbr6rv47.js → index-e6p9e926.js} +6 -6
- package/dist/{index-bcfn9xah.js → index-famy7fhs.js} +5 -5
- package/dist/{index-van48n3g.js → index-fc4dr114.js} +4 -2
- package/dist/{index-jzcws0zn.js → index-g5vsqmdy.js} +27 -57
- package/dist/{index-115b07ap.js → index-hcw140eb.js} +5 -5
- package/dist/index-j70m75wd.js +217 -0
- package/dist/{index-3ehtt59c.js → index-qwgsmtsz.js} +1 -1
- package/dist/{index-bgfzwt4h.js → index-t2bs9xdr.js} +4 -4
- package/dist/index.js +285 -93
- package/dist/portfolio.js +14 -13
- package/dist/publish-model.js +36 -0
- package/dist/publish-reader/reader.css +1042 -0
- package/dist/publish-reader/reader.js +1534 -0
- package/dist/publish-reader/theme.js +92 -0
- package/dist/publish-search.js +52 -0
- package/dist/publish.js +36 -0
- package/dist/rerank-typesafe.js +13 -0
- package/dist/rerank.js +14 -0
- package/dist/sdk.js +14 -11
- package/dist/search.js +2 -2
- package/dist/semantic.js +4 -4
- package/dist/{support-vm4mkw0k.js → support-mywv1em0.js} +38 -32
- package/dist/workflows/decision-context.js +13 -12
- package/dist/workflows/index.js +13 -12
- package/package.json +63 -5
- package/skills/wordcell/AGENTS.md +1 -0
- package/skills/wordcell/SKILL.md +2 -1
- package/skills/wordcell/agents/openai.yaml +1 -1
- package/skills/wordcell/references/publish.md +93 -0
- package/skills/wordcell/references/query.md +22 -0
- package/src/cli-program.ts +347 -9
- package/src/index.ts +4 -0
- package/src/oh/authority.ts +14 -5
- package/src/oh/canonical-rust.ts +127 -0
- package/src/oh/projection-rust.ts +63 -0
- package/src/oh-adoption.ts +7 -1
- package/src/publish-graph.ts +0 -0
- package/src/publish-index.ts +195 -0
- package/src/publish-markdown.ts +617 -0
- package/src/publish-model.ts +599 -0
- package/src/publish-nav.ts +204 -0
- package/src/publish-pages.ts +382 -0
- package/src/publish-reader/reader.css +1042 -0
- package/src/publish-reader/reader.ts +1017 -0
- package/src/publish-reader/theme.ts +97 -0
- package/src/publish-search.ts +418 -0
- package/src/publish-select.ts +419 -0
- package/src/publish-theme.ts +106 -0
- package/src/publish.ts +904 -0
- package/src/rerank-credentials.ts +56 -0
- package/src/rerank-typesafe.ts +472 -0
- package/src/rerank.ts +389 -0
- package/src/sdk.ts +157 -6
- package/src/serve.ts +180 -0
- package/src/vault.ts +23 -9
- package/dist/{index-why54q5p.js → index-gm9t95d9.js} +3 -3
package/src/publish.ts
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { canonicalJson, canonicalSha256 } from "@hraness/oh";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
validateMarkdownAttachments,
|
|
10
|
+
} from "./attachments.js";
|
|
11
|
+
import { findKbPackageRoot } from "./clip/package-root.js";
|
|
12
|
+
import {
|
|
13
|
+
lookupNote,
|
|
14
|
+
normalizeVaultPath,
|
|
15
|
+
type Note,
|
|
16
|
+
} from "./graph.js";
|
|
17
|
+
import { buildSiteIndex } from "./publish-index.js";
|
|
18
|
+
import {
|
|
19
|
+
renderMarkdownToHtml,
|
|
20
|
+
type PublishRenderContext,
|
|
21
|
+
} from "./publish-markdown.js";
|
|
22
|
+
import {
|
|
23
|
+
parseSiteCatalogV1,
|
|
24
|
+
parseSiteDocsV1,
|
|
25
|
+
parseSiteGraphV1,
|
|
26
|
+
parseSiteManifestV1,
|
|
27
|
+
parseSiteNoteV1,
|
|
28
|
+
parseSitePostingsV1,
|
|
29
|
+
parseSiteTermsV1,
|
|
30
|
+
WORDCELL_SITE_CATALOG_FORMAT_V1,
|
|
31
|
+
WORDCELL_SITE_FORMAT_V1,
|
|
32
|
+
WORDCELL_SITE_GRAPH_FORMAT_V1,
|
|
33
|
+
WORDCELL_SITE_LIMITS_V1,
|
|
34
|
+
WORDCELL_SITE_NOTE_FORMAT_V1,
|
|
35
|
+
type WordcellPublishReport,
|
|
36
|
+
type WordcellSiteCatalogV1,
|
|
37
|
+
type WordcellSiteGraphEdgeV1,
|
|
38
|
+
type WordcellSiteManifestV1,
|
|
39
|
+
type WordcellSiteNoteLinkV1,
|
|
40
|
+
type WordcellSiteNoteRelationV1,
|
|
41
|
+
type WordcellSiteNoteV1,
|
|
42
|
+
} from "./publish-model.js";
|
|
43
|
+
import { siteNavFromCatalog } from "./publish-nav.js";
|
|
44
|
+
import {
|
|
45
|
+
renderGraphPage,
|
|
46
|
+
renderLandingPage,
|
|
47
|
+
renderNotFoundPage,
|
|
48
|
+
renderNotePage,
|
|
49
|
+
renderRobotsTxt,
|
|
50
|
+
renderSitemapXml,
|
|
51
|
+
relativePrefix,
|
|
52
|
+
type PageContext,
|
|
53
|
+
} from "./publish-pages.js";
|
|
54
|
+
import {
|
|
55
|
+
publishAssetTarget,
|
|
56
|
+
selectPublishNotes,
|
|
57
|
+
type PublishSelection,
|
|
58
|
+
type PublishSelectionInput,
|
|
59
|
+
} from "./publish-select.js";
|
|
60
|
+
import { publishNormalize } from "./publish-search.js";
|
|
61
|
+
import { scanVault, type VaultSnapshot } from "./vault.js";
|
|
62
|
+
|
|
63
|
+
export const WORDCELL_PUBLISH_GENERATOR = "@hraness/wordcell" as const;
|
|
64
|
+
|
|
65
|
+
/** Canonical serialization for every artifact file in the contract. */
|
|
66
|
+
export function serializeSiteFile(value: unknown): string {
|
|
67
|
+
return `${canonicalJson(value)}\n`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Admit generated JSON under the same contract the reader uses, before writes. */
|
|
71
|
+
function setSiteJson(
|
|
72
|
+
files: Map<string, Uint8Array>,
|
|
73
|
+
path: string,
|
|
74
|
+
value: unknown,
|
|
75
|
+
parse: (input: unknown) => unknown,
|
|
76
|
+
): void {
|
|
77
|
+
try {
|
|
78
|
+
parse(value);
|
|
79
|
+
} catch (error: unknown) {
|
|
80
|
+
throw new Error(`Cannot publish ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
81
|
+
}
|
|
82
|
+
files.set(path, encodeUtf8(serializeSiteFile(value)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type PublishOptions = {
|
|
86
|
+
/** Vault root (the CLI defaults it to the working directory). */
|
|
87
|
+
readonly root: string;
|
|
88
|
+
/** Output directory for the emitted site. */
|
|
89
|
+
readonly out: string;
|
|
90
|
+
/** Vault-relative index note path when the vault overrides the default. */
|
|
91
|
+
readonly index?: string;
|
|
92
|
+
readonly title?: string;
|
|
93
|
+
readonly description?: string;
|
|
94
|
+
/** URL path prefix the site is served under (default "/"). */
|
|
95
|
+
readonly basePath?: string;
|
|
96
|
+
/** Absolute site origin used for the sitemap. */
|
|
97
|
+
readonly baseUrl?: string;
|
|
98
|
+
/** Emit robots disallow and noindex markers when true. */
|
|
99
|
+
readonly noindex?: boolean;
|
|
100
|
+
/** Disable the content index (fields-only search). */
|
|
101
|
+
readonly indexContent?: boolean;
|
|
102
|
+
/** Omit generatedAt for reproducible artifacts. */
|
|
103
|
+
readonly deterministic?: boolean;
|
|
104
|
+
/** Return the plan and file set without writing to disk. */
|
|
105
|
+
readonly dryRun?: boolean;
|
|
106
|
+
/** Maximum selected ids in the report, default 20; never changes the selection. */
|
|
107
|
+
readonly listLimit?: number;
|
|
108
|
+
/** Replace an existing non-empty output directory. */
|
|
109
|
+
readonly force?: boolean;
|
|
110
|
+
readonly selection?: PublishSelectionInput;
|
|
111
|
+
readonly now?: () => Date;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type PublishProjection = {
|
|
115
|
+
/** Site-relative path → file bytes in deterministic path order. */
|
|
116
|
+
readonly files: ReadonlyMap<string, Uint8Array>;
|
|
117
|
+
readonly manifest: WordcellSiteManifestV1;
|
|
118
|
+
readonly report: WordcellPublishReport;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Filesystem seam for projection. `resolveAssetPath` maps one decoded
|
|
123
|
+
* attachment target to a vault-relative file path; `readAsset` supplies its
|
|
124
|
+
* bytes. Both are injectable so tests drive the projection without disk.
|
|
125
|
+
*/
|
|
126
|
+
export type PublishIo = {
|
|
127
|
+
readonly resolveAssetPath: (source: string, target: string) => string | undefined;
|
|
128
|
+
readonly readAsset: (path: string) => Promise<Uint8Array | undefined>;
|
|
129
|
+
readonly readerFiles?: () => Promise<ReadonlyMap<string, Uint8Array>>;
|
|
130
|
+
readonly version?: string;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const SITE_PATHS = Object.freeze({
|
|
134
|
+
catalog: "catalog.json",
|
|
135
|
+
graph: "graph.json",
|
|
136
|
+
docs: "index/docs.json",
|
|
137
|
+
terms: "index/terms.json",
|
|
138
|
+
postingsPrefix: "index/c/",
|
|
139
|
+
notePrefix: "n/",
|
|
140
|
+
assetPrefix: "assets/",
|
|
141
|
+
readerPrefix: "reader/",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** Whole-artifact write budget; separate from per-asset and corpus bounds. */
|
|
145
|
+
export const MAX_SITE_BYTES = 1_024 * 1_024 * 1_024;
|
|
146
|
+
export const DEFAULT_PUBLISH_LIST_LIMIT = 20;
|
|
147
|
+
export const MAX_PUBLISH_LIST_LIMIT = 1_000;
|
|
148
|
+
export const MAX_PUBLISH_LIST_BYTES = 16_384;
|
|
149
|
+
/** Portable filesystem component bound, including emitted file extensions. */
|
|
150
|
+
export const MAX_PUBLISH_PATH_COMPONENT_BYTES = 255;
|
|
151
|
+
|
|
152
|
+
/** Validate the complete artifact namespace before replacing an existing site. */
|
|
153
|
+
function validateSitePaths(paths: Iterable<string>): void {
|
|
154
|
+
const files = new Set(paths);
|
|
155
|
+
for (const path of files) {
|
|
156
|
+
const segments = path.split("/");
|
|
157
|
+
if (/[:\\\u0000-\u001f\u007f]/u.test(path)
|
|
158
|
+
|| segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
159
|
+
throw new Error(`Cannot publish unsafe artifact path ${JSON.stringify(path)}.`);
|
|
160
|
+
}
|
|
161
|
+
let ancestor = "";
|
|
162
|
+
for (const [index, segment] of segments.entries()) {
|
|
163
|
+
if (Buffer.byteLength(segment, "utf8") > MAX_PUBLISH_PATH_COMPONENT_BYTES) {
|
|
164
|
+
throw new RangeError(`Cannot publish ${JSON.stringify(path)}: a path component exceeds ${MAX_PUBLISH_PATH_COMPONENT_BYTES} UTF-8 bytes; shorten the source note or attachment filename.`);
|
|
165
|
+
}
|
|
166
|
+
ancestor = ancestor === "" ? segment : `${ancestor}/${segment}`;
|
|
167
|
+
if (index < segments.length - 1 && files.has(ancestor)) {
|
|
168
|
+
throw new Error(`Cannot publish ${JSON.stringify(path)}: ${JSON.stringify(ancestor)} is both a file and a parent directory; rename one of the conflicting source notes.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function publishListLimit(value: number | undefined): number {
|
|
175
|
+
const limit = value ?? DEFAULT_PUBLISH_LIST_LIMIT;
|
|
176
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_PUBLISH_LIST_LIMIT) {
|
|
177
|
+
throw new RangeError(`--list-limit must be an integer from 0 through ${MAX_PUBLISH_LIST_LIMIT}`);
|
|
178
|
+
}
|
|
179
|
+
return limit;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function encodeUtf8(text: string): Uint8Array {
|
|
183
|
+
return new TextEncoder().encode(text);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
187
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeBasePath(input: string | undefined): string {
|
|
191
|
+
const raw = (input ?? "/").trim();
|
|
192
|
+
const prefixed = raw.startsWith("/") ? raw : `/${raw}`;
|
|
193
|
+
return prefixed.endsWith("/") ? prefixed : `${prefixed}/`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeBaseUrl(input: string | undefined): string | undefined {
|
|
197
|
+
if (input === undefined) return undefined;
|
|
198
|
+
let url: URL;
|
|
199
|
+
try {
|
|
200
|
+
url = new URL(input);
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`--base-url must be an absolute http(s) URL, got ${JSON.stringify(input)}.`);
|
|
203
|
+
}
|
|
204
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
205
|
+
throw new Error(`--base-url must use http or https, got ${url.protocol}.`);
|
|
206
|
+
}
|
|
207
|
+
return url.toString().replace(/\/+$/u, "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function boundedNormalized(text: string, maximumBytes: number): {
|
|
211
|
+
readonly text: string;
|
|
212
|
+
readonly truncated: boolean;
|
|
213
|
+
} {
|
|
214
|
+
const normalized = publishNormalize(text);
|
|
215
|
+
if (Buffer.byteLength(normalized, "utf8") <= maximumBytes) {
|
|
216
|
+
return { text: normalized, truncated: false };
|
|
217
|
+
}
|
|
218
|
+
const bytes = Buffer.from(normalized, "utf8");
|
|
219
|
+
let end = maximumBytes;
|
|
220
|
+
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end -= 1;
|
|
221
|
+
return { text: bytes.subarray(0, end).toString("utf8"), truncated: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Project a scanned vault into the `hraness.wordcell.site.v1` file set.
|
|
226
|
+
* Selection is resolved first; excluded notes do not enter structured output.
|
|
227
|
+
* Selected prose and attachments are published as authored, without redaction.
|
|
228
|
+
*/
|
|
229
|
+
export async function projectVault(
|
|
230
|
+
snapshot: Pick<VaultSnapshot, "root" | "notes" | "analysis">,
|
|
231
|
+
options: Omit<PublishOptions, "out" | "dryRun" | "force">,
|
|
232
|
+
io: PublishIo,
|
|
233
|
+
): Promise<PublishProjection> {
|
|
234
|
+
const listLimit = publishListLimit(options.listLimit);
|
|
235
|
+
const selection = selectPublishNotes(snapshot.notes, snapshot.analysis, options.selection ?? {});
|
|
236
|
+
if (selection.notes.length > WORDCELL_SITE_LIMITS_V1.notes) {
|
|
237
|
+
throw new RangeError(
|
|
238
|
+
`Publish selection exceeds the ${WORDCELL_SITE_LIMITS_V1.notes}-note contract limit.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const noteById = new Map(selection.notes.map((note) => [note.id, note]));
|
|
243
|
+
const slugFor = (id: string): string => {
|
|
244
|
+
const slug = selection.slugById.get(id);
|
|
245
|
+
if (slug === undefined) throw new Error(`Selected note ${id} has no slug.`);
|
|
246
|
+
return slug;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Resolve the attachment closure: only targets referenced by selected notes,
|
|
250
|
+
// only paths the resolver can prove stay inside the vault.
|
|
251
|
+
const assetByPath = new Map<string, { readonly name: string; readonly bytes: Uint8Array }>();
|
|
252
|
+
let assetsSkipped = 0;
|
|
253
|
+
let assetsTruncated = false;
|
|
254
|
+
for (const note of selection.notes) {
|
|
255
|
+
const refs = selection.attachmentsById.get(note.id) ?? [];
|
|
256
|
+
for (const ref of refs) {
|
|
257
|
+
const path = io.resolveAssetPath(note.path, ref.target);
|
|
258
|
+
if (path === undefined || assetByPath.has(path)) continue;
|
|
259
|
+
if (assetByPath.size >= WORDCELL_SITE_LIMITS_V1.assets) {
|
|
260
|
+
assetsTruncated = true;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const bytes = await io.readAsset(path);
|
|
264
|
+
if (bytes === undefined || bytes.byteLength > WORDCELL_SITE_LIMITS_V1.assetBytes) {
|
|
265
|
+
assetsSkipped += 1;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const hash = sha256Hex(bytes).slice(0, 16);
|
|
269
|
+
const dot = path.lastIndexOf(".");
|
|
270
|
+
const extension = dot === -1 ? "bin" : path.slice(dot + 1).toLowerCase();
|
|
271
|
+
assetByPath.set(path, { name: `${hash}.${extension}`, bytes });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const linkEntries = (links: readonly { source: string; target: string }[], id: string, key: "source" | "target"): WordcellSiteNoteLinkV1[] =>
|
|
276
|
+
links
|
|
277
|
+
.filter((link) => link[key] === id)
|
|
278
|
+
.map((link) => {
|
|
279
|
+
const other = key === "source" ? link.target : link.source;
|
|
280
|
+
const note = noteById.get(other);
|
|
281
|
+
return note === undefined
|
|
282
|
+
? undefined
|
|
283
|
+
: { s: slugFor(note.id), t: note.title };
|
|
284
|
+
})
|
|
285
|
+
.filter((entry): entry is WordcellSiteNoteLinkV1 => entry !== undefined)
|
|
286
|
+
.toSorted((a, b) => a.s.localeCompare(b.s) || a.t.localeCompare(b.t));
|
|
287
|
+
|
|
288
|
+
const relationEntries = (
|
|
289
|
+
relations: readonly { source: string; target: string; predicate: string }[],
|
|
290
|
+
id: string,
|
|
291
|
+
key: "source" | "target",
|
|
292
|
+
): WordcellSiteNoteRelationV1[] =>
|
|
293
|
+
relations
|
|
294
|
+
.filter((relation) => relation[key] === id)
|
|
295
|
+
.map((relation) => {
|
|
296
|
+
const other = key === "source" ? relation.target : relation.source;
|
|
297
|
+
const note = noteById.get(other);
|
|
298
|
+
return note === undefined
|
|
299
|
+
? undefined
|
|
300
|
+
: { p: relation.predicate, s: slugFor(note.id), t: note.title };
|
|
301
|
+
})
|
|
302
|
+
.filter((entry): entry is WordcellSiteNoteRelationV1 => entry !== undefined)
|
|
303
|
+
.toSorted((a, b) => a.s.localeCompare(b.s) || a.p.localeCompare(b.p) || a.t.localeCompare(b.t));
|
|
304
|
+
|
|
305
|
+
// Pass one: payloads (link panels) for every selected note.
|
|
306
|
+
const payloadBySlug = new Map<string, WordcellSiteNoteV1>();
|
|
307
|
+
let anyTextTruncated = false;
|
|
308
|
+
for (const note of selection.notes) {
|
|
309
|
+
const slug = slugFor(note.id);
|
|
310
|
+
const text = boundedNormalized(note.searchableText, WORDCELL_SITE_LIMITS_V1.noteTextBytes);
|
|
311
|
+
anyTextTruncated ||= text.truncated;
|
|
312
|
+
const payload: WordcellSiteNoteV1 = {
|
|
313
|
+
format: WORDCELL_SITE_NOTE_FORMAT_V1,
|
|
314
|
+
id: note.id,
|
|
315
|
+
slug,
|
|
316
|
+
title: note.title,
|
|
317
|
+
aliases: note.aliases,
|
|
318
|
+
...(typeof note.metadata["type"] === "string" && note.metadata["type"] !== ""
|
|
319
|
+
? { type: note.metadata["type"] }
|
|
320
|
+
: {}),
|
|
321
|
+
tags: note.tags,
|
|
322
|
+
summary: note.summary,
|
|
323
|
+
text: text.text,
|
|
324
|
+
textTruncated: text.truncated,
|
|
325
|
+
links: linkEntries(selection.links, note.id, "source"),
|
|
326
|
+
backlinks: linkEntries(selection.links, note.id, "target"),
|
|
327
|
+
relations: relationEntries(selection.relationsById.get(note.id) ?? [], note.id, "source"),
|
|
328
|
+
relationBacklinks: relationEntries(
|
|
329
|
+
selection.relationBacklinksById.get(note.id) ?? [],
|
|
330
|
+
note.id,
|
|
331
|
+
"target",
|
|
332
|
+
),
|
|
333
|
+
};
|
|
334
|
+
payloadBySlug.set(slug, payload);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const resolveNote = (source: Note) => (rawTarget: string) => {
|
|
338
|
+
if (rawTarget === "") return undefined;
|
|
339
|
+
const found = lookupNote(selection.notes, rawTarget);
|
|
340
|
+
const note = found.kind === "found"
|
|
341
|
+
? found.note
|
|
342
|
+
: rawTarget.startsWith(".")
|
|
343
|
+
? (() => {
|
|
344
|
+
const joined = normalizeVaultPath(posix.join(posix.dirname(source.path), rawTarget));
|
|
345
|
+
const retry = lookupNote(selection.notes, joined);
|
|
346
|
+
return retry.kind === "found" ? retry.note : undefined;
|
|
347
|
+
})()
|
|
348
|
+
: undefined;
|
|
349
|
+
return note === undefined ? undefined : { slug: slugFor(note.id), title: note.title };
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Pass two: render bodies, then pages (page asides need all payloads).
|
|
353
|
+
const bodyBySlug = new Map<string, string>();
|
|
354
|
+
for (const note of selection.notes) {
|
|
355
|
+
const slug = slugFor(note.id);
|
|
356
|
+
const rel = relativePrefix(slug);
|
|
357
|
+
const ctx: PublishRenderContext = {
|
|
358
|
+
source: note.path,
|
|
359
|
+
resolveNote: resolveNote(note),
|
|
360
|
+
resolveAsset: (rawTarget, from) => {
|
|
361
|
+
const decoded = publishAssetTarget(rawTarget);
|
|
362
|
+
if (decoded === undefined) return undefined;
|
|
363
|
+
const path = io.resolveAssetPath(from, decoded);
|
|
364
|
+
if (path === undefined) return undefined;
|
|
365
|
+
const asset = assetByPath.get(path);
|
|
366
|
+
return asset === undefined ? undefined : `${rel}${SITE_PATHS.assetPrefix}${asset.name}`;
|
|
367
|
+
},
|
|
368
|
+
noteHref: (slugTarget) => (slugTarget === "" ? rel || "./" : `${rel}n/${encodeURI(slugTarget)}/`),
|
|
369
|
+
};
|
|
370
|
+
bodyBySlug.set(slug, renderMarkdownToHtml(note.content, ctx));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const site = {
|
|
374
|
+
title: options.title?.trim() || basename(resolve(snapshot.root)) || "Vault",
|
|
375
|
+
basePath: normalizeBasePath(options.basePath),
|
|
376
|
+
...(options.description === undefined ? {} : { description: options.description }),
|
|
377
|
+
};
|
|
378
|
+
const noindex = options.noindex === true;
|
|
379
|
+
|
|
380
|
+
const catalog: WordcellSiteCatalogV1 = {
|
|
381
|
+
format: WORDCELL_SITE_CATALOG_FORMAT_V1,
|
|
382
|
+
entries: selection.notes.map((note, index) => ({
|
|
383
|
+
i: index,
|
|
384
|
+
s: slugFor(note.id),
|
|
385
|
+
t: note.title,
|
|
386
|
+
...(typeof note.metadata["type"] === "string" && note.metadata["type"] !== ""
|
|
387
|
+
? { type: note.metadata["type"] }
|
|
388
|
+
: {}),
|
|
389
|
+
...(note.tags.length === 0 ? {} : { g: note.tags }),
|
|
390
|
+
})),
|
|
391
|
+
};
|
|
392
|
+
const nav = siteNavFromCatalog(catalog.entries);
|
|
393
|
+
const titles = new Map(catalog.entries.map((entry) => [entry.s, entry.t]));
|
|
394
|
+
|
|
395
|
+
const edgeIndexById = new Map(selection.notes.map((note, index) => [note.id, index]));
|
|
396
|
+
const rawEdges: WordcellSiteGraphEdgeV1[] = [
|
|
397
|
+
...selection.links.map((link) => ({
|
|
398
|
+
s: edgeIndexById.get(link.source) ?? -1,
|
|
399
|
+
t: edgeIndexById.get(link.target) ?? -1,
|
|
400
|
+
k: "link" as const,
|
|
401
|
+
})),
|
|
402
|
+
...selection.notes.flatMap((note) =>
|
|
403
|
+
(selection.relationsById.get(note.id) ?? []).map((relation) => ({
|
|
404
|
+
s: edgeIndexById.get(relation.source) ?? -1,
|
|
405
|
+
t: edgeIndexById.get(relation.target) ?? -1,
|
|
406
|
+
k: "relation" as const,
|
|
407
|
+
p: relation.predicate,
|
|
408
|
+
}))),
|
|
409
|
+
];
|
|
410
|
+
const edges = rawEdges
|
|
411
|
+
.filter((edge) => edge.s >= 0 && edge.t >= 0)
|
|
412
|
+
.toSorted((a, b) => a.s - b.s || a.t - b.t || a.k.localeCompare(b.k) || (a.p ?? "").localeCompare(b.p ?? ""));
|
|
413
|
+
|
|
414
|
+
const files = new Map<string, Uint8Array>();
|
|
415
|
+
for (const note of selection.notes) {
|
|
416
|
+
const slug = slugFor(note.id);
|
|
417
|
+
if (slug !== "") {
|
|
418
|
+
setSiteJson(files, `n/${slug}.json`, payloadBySlug.get(slug), parseSiteNoteV1);
|
|
419
|
+
const ctx: PageContext = {
|
|
420
|
+
site,
|
|
421
|
+
rel: relativePrefix(slug),
|
|
422
|
+
noindex,
|
|
423
|
+
generator: WORDCELL_PUBLISH_GENERATOR,
|
|
424
|
+
nav,
|
|
425
|
+
current: slug,
|
|
426
|
+
titles,
|
|
427
|
+
};
|
|
428
|
+
const payload = payloadBySlug.get(slug);
|
|
429
|
+
if (payload === undefined) throw new Error(`Missing payload for ${slug}.`);
|
|
430
|
+
files.set(
|
|
431
|
+
`n/${slug}/index.html`,
|
|
432
|
+
encodeUtf8(renderNotePage(
|
|
433
|
+
note,
|
|
434
|
+
bodyBySlug.get(slug) ?? "",
|
|
435
|
+
{
|
|
436
|
+
links: payload.links,
|
|
437
|
+
backlinks: payload.backlinks,
|
|
438
|
+
relations: payload.relations,
|
|
439
|
+
relationBacklinks: payload.relationBacklinks,
|
|
440
|
+
},
|
|
441
|
+
ctx,
|
|
442
|
+
)),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const indexPayload = payloadBySlug.get("");
|
|
447
|
+
if (indexPayload !== undefined) {
|
|
448
|
+
setSiteJson(files, "index.json", indexPayload, parseSiteNoteV1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const landingCtx: PageContext = {
|
|
452
|
+
site,
|
|
453
|
+
rel: "",
|
|
454
|
+
noindex,
|
|
455
|
+
generator: WORDCELL_PUBLISH_GENERATOR,
|
|
456
|
+
nav,
|
|
457
|
+
current: "",
|
|
458
|
+
titles,
|
|
459
|
+
};
|
|
460
|
+
files.set("index.html", encodeUtf8(renderLandingPage(
|
|
461
|
+
indexPayload === undefined ? undefined : bodyBySlug.get(""),
|
|
462
|
+
catalog.entries,
|
|
463
|
+
landingCtx,
|
|
464
|
+
)));
|
|
465
|
+
files.set("graph/index.html", encodeUtf8(renderGraphPage(
|
|
466
|
+
catalog.entries,
|
|
467
|
+
edges.length,
|
|
468
|
+
{ ...landingCtx, rel: "../" },
|
|
469
|
+
)));
|
|
470
|
+
files.set("404.html", encodeUtf8(renderNotFoundPage(landingCtx)));
|
|
471
|
+
files.set("robots.txt", encodeUtf8(renderRobotsTxt(noindex)));
|
|
472
|
+
|
|
473
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
474
|
+
if (baseUrl !== undefined) {
|
|
475
|
+
const slugs = selection.notes.map((note) => slugFor(note.id));
|
|
476
|
+
files.set("sitemap.xml", encodeUtf8(renderSitemapXml(
|
|
477
|
+
slugs,
|
|
478
|
+
`${baseUrl}${site.basePath === "/" ? "" : site.basePath.replace(/\/$/u, "")}`,
|
|
479
|
+
)));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
setSiteJson(files, SITE_PATHS.catalog, catalog, parseSiteCatalogV1);
|
|
483
|
+
|
|
484
|
+
const index = buildSiteIndex(selection.notes, selection.slugById, {
|
|
485
|
+
...(options.indexContent === undefined ? {} : { indexContent: options.indexContent }),
|
|
486
|
+
});
|
|
487
|
+
setSiteJson(files, SITE_PATHS.docs, index.docs, parseSiteDocsV1);
|
|
488
|
+
setSiteJson(files, SITE_PATHS.terms, index.terms, parseSiteTermsV1);
|
|
489
|
+
for (const [shard, postings] of index.postings) {
|
|
490
|
+
setSiteJson(files, `${SITE_PATHS.postingsPrefix}${shard}.json`, postings, parseSitePostingsV1);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
setSiteJson(files, SITE_PATHS.graph, {
|
|
494
|
+
format: WORDCELL_SITE_GRAPH_FORMAT_V1,
|
|
495
|
+
edges,
|
|
496
|
+
}, parseSiteGraphV1);
|
|
497
|
+
|
|
498
|
+
for (const [path, asset] of [...assetByPath.entries()].toSorted(([a], [b]) =>
|
|
499
|
+
a.localeCompare(b))) {
|
|
500
|
+
files.set(`${SITE_PATHS.assetPrefix}${asset.name}`, asset.bytes);
|
|
501
|
+
void path;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const reader = io.readerFiles === undefined
|
|
505
|
+
? await defaultReaderFiles()
|
|
506
|
+
: await io.readerFiles();
|
|
507
|
+
for (const [name, bytes] of reader) files.set(`${SITE_PATHS.readerPrefix}${name}`, bytes);
|
|
508
|
+
|
|
509
|
+
const generatedAt = (options.now ?? (() => new Date()))();
|
|
510
|
+
const digest = canonicalSha256({
|
|
511
|
+
format: "hraness.wordcell.site-source.v1",
|
|
512
|
+
notes: selection.notes.map((note) => ({
|
|
513
|
+
id: note.id,
|
|
514
|
+
slug: slugFor(note.id),
|
|
515
|
+
sha256: sha256Hex(encodeUtf8(note.content)),
|
|
516
|
+
})),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
let totalBytes = 0;
|
|
520
|
+
let assetBytes = 0;
|
|
521
|
+
for (const [path, bytes] of files) {
|
|
522
|
+
totalBytes += bytes.byteLength;
|
|
523
|
+
if (path.startsWith(SITE_PATHS.assetPrefix)) assetBytes += bytes.byteLength;
|
|
524
|
+
}
|
|
525
|
+
if (assetBytes > WORDCELL_SITE_LIMITS_V1.totalAssetBytes) {
|
|
526
|
+
throw new RangeError(
|
|
527
|
+
`Published attachments exceed the ${WORDCELL_SITE_LIMITS_V1.totalAssetBytes}-byte budget.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
if (totalBytes > MAX_SITE_BYTES) {
|
|
531
|
+
throw new RangeError(`Published site exceeds the ${MAX_SITE_BYTES}-byte artifact budget.`);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const truncated = {
|
|
535
|
+
...(index.termsTruncated ? { terms: true as const } : {}),
|
|
536
|
+
...(index.textTruncated || anyTextTruncated ? { text: true as const } : {}),
|
|
537
|
+
...(assetsTruncated ? { assets: true as const } : {}),
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const manifest: WordcellSiteManifestV1 = {
|
|
541
|
+
format: WORDCELL_SITE_FORMAT_V1,
|
|
542
|
+
site,
|
|
543
|
+
generated: {
|
|
544
|
+
by: WORDCELL_PUBLISH_GENERATOR,
|
|
545
|
+
version: io.version ?? "0.0.0-dev",
|
|
546
|
+
...(options.deterministic === true ? {} : { at: generatedAt.toISOString() }),
|
|
547
|
+
},
|
|
548
|
+
source: {
|
|
549
|
+
selection: selection.descriptor as Readonly<Record<string, unknown>>,
|
|
550
|
+
notes: selection.notes.length,
|
|
551
|
+
digest: `sha256:${digest}`,
|
|
552
|
+
},
|
|
553
|
+
paths: SITE_PATHS,
|
|
554
|
+
search: {
|
|
555
|
+
mode: "exact",
|
|
556
|
+
content: index.mode,
|
|
557
|
+
shards: index.postings.size,
|
|
558
|
+
hash: "fnv1a32-8bit",
|
|
559
|
+
},
|
|
560
|
+
counts: {
|
|
561
|
+
notes: selection.notes.length,
|
|
562
|
+
assets: assetByPath.size,
|
|
563
|
+
bytes: totalBytes,
|
|
564
|
+
},
|
|
565
|
+
truncated,
|
|
566
|
+
};
|
|
567
|
+
setSiteJson(files, "manifest.json", manifest, parseSiteManifestV1);
|
|
568
|
+
validateSitePaths(files.keys());
|
|
569
|
+
const ordered = new Map(
|
|
570
|
+
[...files.entries()].toSorted(([a], [b]) => a.localeCompare(b)),
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const listedIds: string[] = [];
|
|
574
|
+
let listedBytes = 0;
|
|
575
|
+
for (const note of selection.notes) {
|
|
576
|
+
const bytes = Buffer.byteLength(note.id, "utf8");
|
|
577
|
+
if (listedIds.length >= listLimit || listedBytes + bytes > MAX_PUBLISH_LIST_BYTES) break;
|
|
578
|
+
listedIds.push(note.id);
|
|
579
|
+
listedBytes += bytes;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const report: WordcellPublishReport = {
|
|
583
|
+
format: WORDCELL_SITE_FORMAT_V1,
|
|
584
|
+
out: "",
|
|
585
|
+
deterministic: options.deterministic === true,
|
|
586
|
+
selection: {
|
|
587
|
+
ids: listedIds,
|
|
588
|
+
total: selection.notes.length,
|
|
589
|
+
truncated: listedIds.length < selection.notes.length,
|
|
590
|
+
digest: manifest.source.digest,
|
|
591
|
+
},
|
|
592
|
+
files: ordered.size,
|
|
593
|
+
bytes: totalBytes,
|
|
594
|
+
notes: {
|
|
595
|
+
published: selection.notes.length,
|
|
596
|
+
excludedPrivate: selection.excludedPrivate,
|
|
597
|
+
excludedBySelection: selection.excludedBySelection,
|
|
598
|
+
},
|
|
599
|
+
links: {
|
|
600
|
+
kept: selection.links.length,
|
|
601
|
+
droppedExternal: selection.droppedExternalLinks + selection.droppedExternalRelations,
|
|
602
|
+
},
|
|
603
|
+
assets: {
|
|
604
|
+
count: assetByPath.size,
|
|
605
|
+
bytes: assetBytes,
|
|
606
|
+
skipped: assetsSkipped,
|
|
607
|
+
},
|
|
608
|
+
search: {
|
|
609
|
+
content: index.mode,
|
|
610
|
+
terms: index.termsCount,
|
|
611
|
+
truncated: index.termsTruncated || index.textTruncated,
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
return { files: ordered, manifest, report };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function withinRoot(root: string, candidate: string): boolean {
|
|
619
|
+
const path = relative(root, candidate);
|
|
620
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Resolve missing output suffixes against the nearest existing real directory. */
|
|
624
|
+
async function canonicalOutputPath(path: string): Promise<string> {
|
|
625
|
+
let ancestor = path;
|
|
626
|
+
const suffix: string[] = [];
|
|
627
|
+
for (;;) {
|
|
628
|
+
try {
|
|
629
|
+
const stat = await lstat(ancestor);
|
|
630
|
+
if (ancestor === path && stat.isSymbolicLink()) {
|
|
631
|
+
throw new Error("--out must not be a symbolic link.");
|
|
632
|
+
}
|
|
633
|
+
return resolve(await realpath(ancestor), ...suffix);
|
|
634
|
+
} catch (error: unknown) {
|
|
635
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
|
636
|
+
const parent = dirname(ancestor);
|
|
637
|
+
if (parent === ancestor) throw error;
|
|
638
|
+
suffix.unshift(basename(ancestor));
|
|
639
|
+
ancestor = parent;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function assertSeparateOutput(root: string, out: string): void {
|
|
645
|
+
if (withinRoot(root, out)) {
|
|
646
|
+
throw new Error("--out must not be the vault root or a directory inside it.");
|
|
647
|
+
}
|
|
648
|
+
if (withinRoot(out, root)) {
|
|
649
|
+
throw new Error("--out must not contain the vault root; replacement would delete the vault.");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function existingPath(path: string): Promise<Stats | undefined> {
|
|
654
|
+
try {
|
|
655
|
+
return await lstat(path);
|
|
656
|
+
} catch (error: unknown) {
|
|
657
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function sameOutputIdentity(expected: Stats | undefined, actual: Stats | undefined): boolean {
|
|
663
|
+
if (expected === undefined || actual === undefined) return expected === actual;
|
|
664
|
+
return actual.isDirectory() && !actual.isSymbolicLink()
|
|
665
|
+
&& expected.dev === actual.dev && expected.ino === actual.ino
|
|
666
|
+
&& expected.mtimeMs === actual.mtimeMs && expected.ctimeMs === actual.ctimeMs;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function writeProjectedSite(
|
|
670
|
+
root: string,
|
|
671
|
+
out: string,
|
|
672
|
+
files: ReadonlyMap<string, Uint8Array>,
|
|
673
|
+
force: boolean,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
const expected = await existingPath(out);
|
|
676
|
+
if (expected !== undefined) {
|
|
677
|
+
if (!expected.isDirectory() || expected.isSymbolicLink()) {
|
|
678
|
+
throw new Error(`--out ${out} exists and is not a directory.`);
|
|
679
|
+
}
|
|
680
|
+
if (!force && (await readdir(out)).length > 0) {
|
|
681
|
+
throw new Error(`--out ${out} is not empty (pass --force to overwrite).`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const checkOutput = async (): Promise<void> => {
|
|
685
|
+
const canonical = await canonicalOutputPath(out);
|
|
686
|
+
assertSeparateOutput(await realpath(root), canonical);
|
|
687
|
+
if (canonical !== out || !sameOutputIdentity(expected, await existingPath(out))) {
|
|
688
|
+
throw new Error(`--out ${out} changed while publishing; leave it unchanged and retry.`);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
await checkOutput();
|
|
692
|
+
await mkdir(dirname(out), { recursive: true });
|
|
693
|
+
const stageRoot = await mkdtemp(resolve(dirname(out), ".wordcell-stage-"));
|
|
694
|
+
// The staged path is longer than its final counterpart, so host path-length
|
|
695
|
+
// failures happen before the previous output moves.
|
|
696
|
+
const stage = resolve(stageRoot, basename(out));
|
|
697
|
+
let backupRoot: string | undefined;
|
|
698
|
+
let backup: string | undefined;
|
|
699
|
+
let backupHoldsOutput = false;
|
|
700
|
+
let primaryError: unknown;
|
|
701
|
+
try {
|
|
702
|
+
await mkdir(stage);
|
|
703
|
+
for (const [path, bytes] of files) {
|
|
704
|
+
const absolute = resolve(stage, path);
|
|
705
|
+
if (!withinRoot(stage, absolute)) throw new Error(`Refusing to write outside staged output: ${path}`);
|
|
706
|
+
await mkdir(resolve(stage, posix.dirname(path)), { recursive: true });
|
|
707
|
+
// A case-insensitive filesystem can reveal collisions absent from the map.
|
|
708
|
+
await writeFile(absolute, bytes, { flag: "wx" });
|
|
709
|
+
}
|
|
710
|
+
if (expected !== undefined) {
|
|
711
|
+
backupRoot = await mkdtemp(resolve(dirname(out), ".wordcell-backup-"));
|
|
712
|
+
backup = resolve(backupRoot, basename(out));
|
|
713
|
+
}
|
|
714
|
+
await checkOutput();
|
|
715
|
+
if (backup !== undefined) {
|
|
716
|
+
await rename(out, backup);
|
|
717
|
+
backupHoldsOutput = true;
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
await rename(stage, out);
|
|
721
|
+
} catch (promotionError: unknown) {
|
|
722
|
+
if (backup !== undefined && backupHoldsOutput) {
|
|
723
|
+
try {
|
|
724
|
+
if (await existingPath(out) !== undefined) {
|
|
725
|
+
throw new Error("another entry appeared at the output path");
|
|
726
|
+
}
|
|
727
|
+
await rename(backup, out);
|
|
728
|
+
backupHoldsOutput = false;
|
|
729
|
+
} catch (restoreError: unknown) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`Could not promote the new site or restore --out; the previous site is preserved at ${backup}. `
|
|
732
|
+
+ `Restore failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}. `
|
|
733
|
+
+ `Promotion failed: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
throw promotionError;
|
|
738
|
+
}
|
|
739
|
+
if (backupRoot !== undefined) {
|
|
740
|
+
try {
|
|
741
|
+
await rm(backupRoot, { recursive: true });
|
|
742
|
+
backupHoldsOutput = false;
|
|
743
|
+
backupRoot = undefined;
|
|
744
|
+
} catch (error: unknown) {
|
|
745
|
+
throw new Error(`Published the new site, but previous-site backup cleanup is incomplete at ${backup}; cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
} catch (error: unknown) {
|
|
749
|
+
primaryError = error;
|
|
750
|
+
throw error;
|
|
751
|
+
} finally {
|
|
752
|
+
try {
|
|
753
|
+
await rm(stageRoot, { recursive: true, force: true });
|
|
754
|
+
} catch (error: unknown) {
|
|
755
|
+
throw new Error(`Could not remove staging directory ${stageRoot}`
|
|
756
|
+
+ `${backupHoldsOutput ? `; retained backup: ${backup}` : ""}. `
|
|
757
|
+
+ `${error instanceof Error ? error.message : String(error)}`
|
|
758
|
+
+ `${primaryError === undefined ? "" : `; original failure: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}`}`);
|
|
759
|
+
}
|
|
760
|
+
// Never erase the only recoverable copy when promotion or rollback fails.
|
|
761
|
+
if (backupRoot !== undefined && !backupHoldsOutput) {
|
|
762
|
+
await rm(backupRoot, { recursive: true, force: true });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const READER_FILES = ["reader.js", "reader.css", "theme.js"] as const;
|
|
768
|
+
|
|
769
|
+
async function defaultReaderFiles(): Promise<Map<string, Uint8Array>> {
|
|
770
|
+
const packageRoot = findKbPackageRoot();
|
|
771
|
+
const files = new Map<string, Uint8Array>();
|
|
772
|
+
for (const name of READER_FILES) {
|
|
773
|
+
for (const candidate of [
|
|
774
|
+
`${packageRoot}/dist/publish-reader/${name}`,
|
|
775
|
+
`${packageRoot}/src/publish-reader/${name}`,
|
|
776
|
+
]) {
|
|
777
|
+
try {
|
|
778
|
+
files.set(name, new Uint8Array(await readFile(candidate)));
|
|
779
|
+
break;
|
|
780
|
+
} catch {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return files;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function defaultVersion(): Promise<string> {
|
|
789
|
+
try {
|
|
790
|
+
const manifest: unknown = JSON.parse(
|
|
791
|
+
await readFile(`${findKbPackageRoot()}/package.json`, "utf8"),
|
|
792
|
+
);
|
|
793
|
+
if (
|
|
794
|
+
typeof manifest === "object" && manifest !== null
|
|
795
|
+
&& "version" in manifest && typeof manifest.version === "string"
|
|
796
|
+
) {
|
|
797
|
+
return manifest.version;
|
|
798
|
+
}
|
|
799
|
+
} catch {
|
|
800
|
+
// Fall through to the development placeholder.
|
|
801
|
+
}
|
|
802
|
+
return "0.0.0-dev";
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Publish a vault on disk to a static site directory. `dryRun: true` returns
|
|
807
|
+
* the plan and projected file set without touching the output directory.
|
|
808
|
+
*/
|
|
809
|
+
export async function publishVault(
|
|
810
|
+
options: PublishOptions,
|
|
811
|
+
): Promise<PublishProjection & { readonly out: string }> {
|
|
812
|
+
publishListLimit(options.listLimit);
|
|
813
|
+
const requestedRoot = resolve(options.root);
|
|
814
|
+
const requestedOut = resolve(options.out);
|
|
815
|
+
assertSeparateOutput(requestedRoot, requestedOut);
|
|
816
|
+
const resolvedRoot = await realpath(requestedRoot);
|
|
817
|
+
const out = await canonicalOutputPath(requestedOut);
|
|
818
|
+
assertSeparateOutput(resolvedRoot, out);
|
|
819
|
+
|
|
820
|
+
const snapshot = await scanVault(resolvedRoot, {
|
|
821
|
+
mentionScope: false,
|
|
822
|
+
...(options.index === undefined ? {} : { index: options.index }),
|
|
823
|
+
});
|
|
824
|
+
const selection = selectPublishNotes(snapshot.notes, snapshot.analysis, options.selection ?? {});
|
|
825
|
+
if (selection.notes.length === 0 && !options.dryRun) {
|
|
826
|
+
throw new Error("Publish selection is empty; check the selectors with --dry-run before replacing output.");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Validate attachments once against the real filesystem, over selected
|
|
830
|
+
// documents only; the resolver then answers lookups from memory.
|
|
831
|
+
const validation = await validateMarkdownAttachments({
|
|
832
|
+
root: resolvedRoot,
|
|
833
|
+
documents: selection.notes.map((note) => ({ path: note.path, content: note.content })),
|
|
834
|
+
});
|
|
835
|
+
const resolvedByRef = new Map<string, string>();
|
|
836
|
+
for (const attachment of validation.attachments) {
|
|
837
|
+
resolvedByRef.set(`${attachment.source}\0${attachment.target}`, attachment.path);
|
|
838
|
+
}
|
|
839
|
+
const missing = new Set(
|
|
840
|
+
validation.issues
|
|
841
|
+
.filter((issue) => issue.kind === "missing" || issue.kind === "ambiguous"
|
|
842
|
+
|| issue.kind === "case-mismatch" || issue.kind === "case-collision")
|
|
843
|
+
.map((issue) => `${issue.source}\0${issue.target}`),
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const io: PublishIo = {
|
|
847
|
+
resolveAssetPath: (source, target) => {
|
|
848
|
+
const key = `${source}\0${target}`;
|
|
849
|
+
if (missing.has(key)) return undefined;
|
|
850
|
+
return resolvedByRef.get(key);
|
|
851
|
+
},
|
|
852
|
+
readAsset: async (path) => {
|
|
853
|
+
const absolute = resolve(resolvedRoot, path);
|
|
854
|
+
if (!withinRoot(resolvedRoot, absolute)) return undefined;
|
|
855
|
+
let stat;
|
|
856
|
+
try {
|
|
857
|
+
stat = await lstat(absolute);
|
|
858
|
+
} catch {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return undefined;
|
|
862
|
+
if (stat.size > WORDCELL_SITE_LIMITS_V1.assetBytes) return undefined;
|
|
863
|
+
return new Uint8Array(await readFile(absolute));
|
|
864
|
+
},
|
|
865
|
+
version: await defaultVersion(),
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const projection = await projectVault(snapshot, options, io);
|
|
869
|
+
const report = { ...projection.report, out };
|
|
870
|
+
|
|
871
|
+
if (!options.dryRun) {
|
|
872
|
+
await writeProjectedSite(resolvedRoot, out, projection.files, options.force === true);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
return { ...projection, report, out };
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
export function renderPublishReportText(
|
|
879
|
+
report: WordcellPublishReport,
|
|
880
|
+
dryRun: boolean,
|
|
881
|
+
): string {
|
|
882
|
+
const lines = [
|
|
883
|
+
`${dryRun ? "Publish plan" : "Published"} ${report.notes.published} note${report.notes.published === 1 ? "" : "s"}`
|
|
884
|
+
+ `${report.out === "" ? "" : ` → ${report.out}`}`,
|
|
885
|
+
` bytes: ${report.bytes.toLocaleString("en-US")} across ${report.files} files`,
|
|
886
|
+
` attachments: ${report.assets.count} copied, ${report.assets.skipped} skipped`,
|
|
887
|
+
` links: ${report.links.kept} kept, ${report.links.droppedExternal} dropped (targets outside the selection)`,
|
|
888
|
+
` excluded: ${report.notes.excludedPrivate} private, ${report.notes.excludedBySelection} by selection`,
|
|
889
|
+
` search: ${report.search.content} content index, ${report.search.terms.toLocaleString("en-US")} terms`,
|
|
890
|
+
];
|
|
891
|
+
if (report.selection.ids.length > 0) {
|
|
892
|
+
lines.push(` selected ids: ${report.selection.ids.join(", ")}`);
|
|
893
|
+
}
|
|
894
|
+
if (report.selection.truncated) {
|
|
895
|
+
lines.push(` selected ids shown: ${report.selection.ids.length} of ${report.selection.total} (use --list-limit to change the report bound)`);
|
|
896
|
+
}
|
|
897
|
+
lines.push(` source: ${report.selection.digest}`);
|
|
898
|
+
if (report.search.truncated) {
|
|
899
|
+
lines.push(" warning: search index truncated at contract limits");
|
|
900
|
+
}
|
|
901
|
+
return `${lines.join("\n")}\n`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export type { PublishSelection, PublishSelectionInput };
|