@neta-art/cohub 8.10.1 → 8.11.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.
@@ -1,42 +1,6 @@
1
+ import { BoardFileSnapshotFacts, BuildSnapshotInput, FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, FileCategory, ResolvedCover, buildCodeExcerpt, buildFileExcerpt, buildFileSnapshot, fileBaseName, fileCategory, fileStem, readCoverFromFrontmatter, readFrontmatterScalars, readTitleFromFrontmatter, resolveCoverRef, resolveFileTitle, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter } from "./file-snapshot.js";
1
2
  //#region src/board/core/file-preview.d.ts
2
- /**
3
- * File-card preview derivation — pure, renderer-agnostic, dependency-free.
4
- *
5
- * A board file card is a *thumbnail entry point* to a workspace file, never a
6
- * second copy of it. So everything here derives display facts from file content
7
- * that the caller already has, and the resulting snapshot is treated strictly as
8
- * a cache keyed by the file's mtime: the file on disk stays the single source of
9
- * truth, and nothing in this module ever writes back to it.
10
- *
11
- * Three presentation tiers fall out of which facts are available, so an
12
- * unrecognised file degrades instead of being rejected:
13
- *
14
- * - `cover` — a cover image was declared in markdown frontmatter;
15
- * - `text` — text-like content yielded a readable excerpt;
16
- * - `blank` — anything else (binary, empty, oversized): icon, name and type.
17
- */
18
- /** Hard cap on a stored excerpt. Board cards show a few lines at most, and the
19
- * snapshot rides along in every board transaction — so this stays small. */
20
- declare const FILE_EXCERPT_MAX_CHARS = 480;
21
- /** Files above this size are shown as `blank`; we never pull them for a preview. */
22
- declare const FILE_EXCERPT_MAX_BYTES: number;
23
3
  type FilePreviewKind = "cover" | "text" | "blank";
24
- /**
25
- * Cached display facts for a file node. Every field is derived from the file and
26
- * carries `mtimeMs` so a stale snapshot is detectable rather than silently wrong.
27
- */
28
- type BoardFileSnapshotFacts = {
29
- title?: string;
30
- mimeType?: string;
31
- size?: number;
32
- mtimeMs?: number;
33
- /** Cleaned leading prose, capped at FILE_EXCERPT_MAX_CHARS. */
34
- excerpt?: string;
35
- /** Cover declared as a path inside the space, resolved against the file's dir. */
36
- coverPath?: string;
37
- /** Cover declared as an absolute `https:` URL. */
38
- coverUrl?: string;
39
- };
40
4
  /**
41
5
  * Which tier a card renders at. Derived rather than stored: presentation follows
42
6
  * from the facts present, so there is no second piece of state to fall out of
@@ -47,132 +11,47 @@ declare function filePreviewKind(snapshot: BoardFileSnapshotFacts | undefined):
47
11
  declare function fileTypeLabel(path: string): string;
48
12
  /** Human-readable byte size for the meta line. */
49
13
  declare function formatFileSize(bytes: number | undefined): string;
50
- /**
51
- * A cover reference, classified by how it must be loaded.
52
- *
53
- * `http:` is rejected in favour of `https:` (a board should not downgrade the
54
- * page to mixed content), and `data:`/`blob:` are rejected because they would
55
- * embed opaque, unbounded bytes into board data that no other client can verify.
56
- * Everything else is treated as a path inside the space.
57
- */
58
- type ResolvedCover = {
59
- kind: "url";
60
- url: string;
61
- } | {
62
- kind: "path";
63
- path: string;
64
- } | null;
65
- /** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
66
- declare function resolveSpacePath(fromFilePath: string, ref: string): string;
67
- /**
68
- * Classify a raw cover reference from frontmatter.
69
- *
70
- * Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
71
- * The trade-off is that opening such a board issues a request to that third
72
- * party; the renderer degrades silently if it fails, and nothing is retried in a
73
- * loop.
74
- */
75
- declare function resolveCoverRef(fromFilePath: string, raw: string | undefined | null): ResolvedCover;
76
- /** A frontmatter block's raw body plus the content that followed it. */
77
- type SplitSource = {
78
- frontmatter: string | null;
79
- body: string;
80
- };
81
- /**
82
- * Split leading YAML frontmatter from a markdown source. Deliberately minimal:
83
- * this only needs the raw block so a handful of scalar keys can be read, not a
84
- * YAML parser.
85
- */
86
- declare function splitFrontmatter(source: string): SplitSource;
87
- /** Read the document title declared by top-level frontmatter. */
88
- declare function readTitleFromFrontmatter(frontmatter: string | null): string | null;
89
- /**
90
- * Read the first cover-ish scalar from a frontmatter block.
91
- *
92
- * Only top-level keys are considered (a leading-space line is nested and skipped)
93
- * so `cover:` under some other mapping cannot be mistaken for the file's own.
94
- */
95
- declare function readCoverFromFrontmatter(frontmatter: string | null): string | null;
96
- /**
97
- * Reduce source text to a short, readable excerpt.
98
- *
99
- * The goal is a card-sized hint of what the file is about, so markdown
100
- * decoration is flattened rather than rendered: fenced code is dropped whole
101
- * (it reads as noise at thumbnail size), headings and emphasis lose their
102
- * markers, links keep their text, and blank runs collapse.
103
- */
104
- declare function buildFileExcerpt(source: string, limit?: number): string;
105
- type BuildSnapshotInput = {
106
- path: string;
107
- /** File text, when it could be read as text. Omit for binary/oversized. */
108
- content?: string | null;
109
- title?: string;
110
- mimeType?: string | null;
111
- size?: number;
112
- mtimeMs?: number;
113
- };
114
- /** Basename of a path, used as the default card title. */
115
- declare function fileBaseName(path: string): string;
116
- /**
117
- * Build the cached display facts for a file node.
118
- *
119
- * Content is optional by design: a snapshot built without it still produces a
120
- * usable `blank` card, so a node can be created the instant a file is dropped
121
- * and enriched later without blocking on a read.
122
- */
123
- declare function buildFileSnapshot(input: BuildSnapshotInput): BoardFileSnapshotFacts;
14
+ /** `MD · 12 KB` — empty when neither fact is available. */
15
+ declare function fileMetaLine(path: string, size: number | undefined): string;
16
+ /** Map a file category onto an existing palette token. */
17
+ declare function fileCategoryAccent(category: FileCategory, palette: {
18
+ text: number;
19
+ rare: number;
20
+ epic: number;
21
+ legendary: number;
22
+ muted: number;
23
+ }): number;
124
24
  /** Whether a snapshot's cached facts still describe the file on disk. */
125
25
  declare function isFileSnapshotFresh(snapshot: BoardFileSnapshotFacts | undefined, file: {
126
26
  mtimeMs?: number;
127
27
  size?: number;
128
28
  }): boolean;
129
- /** Whether a file is small enough that fetching a text preview is worthwhile. */
130
- declare function shouldFetchFileExcerpt(input: {
131
- mimeType?: string | null;
132
- size?: number;
133
- }): boolean;
134
- /**
135
- * Whether a referenced file could be read, and if not, whether that is known to
136
- * be permanent.
137
- *
138
- * This distinction is the whole point: a board is a long-lived document, and a
139
- * card should not claim a file is gone because the network blipped. It is also
140
- * why availability is client-local transient state and never written to the
141
- * node — see the rationale in board-file-preview-source.
142
- */
143
29
  type FileAvailability = "ok" | "missing" | "unavailable";
144
30
  /**
145
31
  * Classify a failed read.
146
32
  *
147
33
  * Only a 404 (or 410 Gone) is treated as the file being absent. Everything else
148
- * — offline, 5xx, timeout, 401/403 — is `unavailable`: the file may well exist and
149
- * simply not be reachable by this client right now.
34
+ * — offline, 5xx, timeout, 401/403 — is `unavailable`.
150
35
  */
151
36
  declare function availabilityFromError(error: unknown): FileAvailability;
152
37
  /**
153
38
  * Cache key for a file within a space.
154
39
  *
155
40
  * A path only means anything relative to its space, and identical paths across
156
- * spaces are the norm ("README.md"), so every preview cache is keyed by both. The
157
- * separator is NUL because it cannot appear in a space id or a path, so no path can
158
- * be crafted to collide with another space's entry.
41
+ * spaces are the norm ("README.md"), so every preview cache is keyed by both.
159
42
  */
160
43
  declare function filePreviewScope(spaceId: string, path: string): string;
161
44
  /**
162
- * Cache key for one *version* of a file. The mtime is part of the key so a changed
163
- * file misses the cache instead of serving a stale excerpt or cover.
45
+ * Cache key for one *version* of a file. The mtime is part of the key so a
46
+ * changed file misses the cache instead of serving a stale excerpt or cover.
164
47
  */
165
48
  declare function filePreviewMemoKey(spaceId: string, path: string, mtimeMs?: number): string;
166
49
  /**
167
50
  * Fold a freshly read snapshot into the cached one.
168
51
  *
169
- * `complete` decides whether the incoming facts supersede the cached ones or are
170
- * merged over them, and the difference is not cosmetic. A snapshot omits fields the
171
- * file does not have, so merging a complete read would resurrect a cover or an
172
- * excerpt the file no longer contains — permanently, since the stale value is then
173
- * committed back. Merging is right only for an incomplete read, where an absent
174
- * field means "could not establish", not "not there".
52
+ * `complete` decides whether the incoming facts supersede the cached ones or
53
+ * are merged over them. A complete read's omissions are authoritative.
175
54
  */
176
55
  declare function mergeFileSnapshot(cached: BoardFileSnapshotFacts | undefined, incoming: BoardFileSnapshotFacts, complete: boolean): BoardFileSnapshotFacts;
177
56
  //#endregion
178
- export { BoardFileSnapshotFacts, BuildSnapshotInput, FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, FileAvailability, FilePreviewKind, ResolvedCover, availabilityFromError, buildFileExcerpt, buildFileSnapshot, fileBaseName, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, readTitleFromFrontmatter, resolveCoverRef, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
57
+ export { type BoardFileSnapshotFacts, type BuildSnapshotInput, FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, FileAvailability, type FileCategory, FilePreviewKind, type ResolvedCover, availabilityFromError, buildCodeExcerpt, buildFileExcerpt, buildFileSnapshot, fileBaseName, fileCategory, fileCategoryAccent, fileMetaLine, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileStem, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, readFrontmatterScalars, readTitleFromFrontmatter, resolveCoverRef, resolveFileTitle, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
@@ -1,37 +1,6 @@
1
+ import { FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, buildCodeExcerpt, buildFileExcerpt, buildFileSnapshot, fileBaseName, fileCategory, fileStem, readCoverFromFrontmatter, readFrontmatterScalars, readTitleFromFrontmatter, resolveCoverRef, resolveFileTitle, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter } from "./file-snapshot.js";
1
2
  //#region src/board/core/file-preview.ts
2
3
  /**
3
- * File-card preview derivation — pure, renderer-agnostic, dependency-free.
4
- *
5
- * A board file card is a *thumbnail entry point* to a workspace file, never a
6
- * second copy of it. So everything here derives display facts from file content
7
- * that the caller already has, and the resulting snapshot is treated strictly as
8
- * a cache keyed by the file's mtime: the file on disk stays the single source of
9
- * truth, and nothing in this module ever writes back to it.
10
- *
11
- * Three presentation tiers fall out of which facts are available, so an
12
- * unrecognised file degrades instead of being rejected:
13
- *
14
- * - `cover` — a cover image was declared in markdown frontmatter;
15
- * - `text` — text-like content yielded a readable excerpt;
16
- * - `blank` — anything else (binary, empty, oversized): icon, name and type.
17
- */
18
- /** Hard cap on a stored excerpt. Board cards show a few lines at most, and the
19
- * snapshot rides along in every board transaction — so this stays small. */
20
- const FILE_EXCERPT_MAX_CHARS = 480;
21
- /** Files above this size are shown as `blank`; we never pull them for a preview. */
22
- const FILE_EXCERPT_MAX_BYTES = 262144;
23
- /** Frontmatter keys checked for a cover image, in precedence order. */
24
- const COVER_KEYS = [
25
- "cover",
26
- "coverImage",
27
- "cover_image",
28
- "image",
29
- "banner",
30
- "thumbnail",
31
- "ogImage",
32
- "og:image"
33
- ];
34
- /**
35
4
  * Which tier a card renders at. Derived rather than stored: presentation follows
36
5
  * from the facts present, so there is no second piece of state to fall out of
37
6
  * sync with them.
@@ -67,156 +36,22 @@ function formatFileSize(bytes) {
67
36
  }
68
37
  return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
69
38
  }
70
- /** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
71
- function resolveSpacePath(fromFilePath, ref) {
72
- const base = ref.startsWith("/") ? [] : fromFilePath.split("/").slice(0, -1).filter(Boolean);
73
- const segments = ref.replace(/^\//, "").split("/");
74
- const out = [...base];
75
- for (const segment of segments) {
76
- if (!segment || segment === ".") continue;
77
- if (segment === "..") {
78
- out.pop();
79
- continue;
80
- }
81
- out.push(segment);
39
+ /** `MD · 12 KB` empty when neither fact is available. */
40
+ function fileMetaLine(path, size) {
41
+ const type = fileTypeLabel(path);
42
+ const formatted = formatFileSize(size);
43
+ if (type && formatted) return `${type} · ${formatted}`;
44
+ return formatted || type;
45
+ }
46
+ /** Map a file category onto an existing palette token. */
47
+ function fileCategoryAccent(category, palette) {
48
+ switch (category) {
49
+ case "doc": return palette.text;
50
+ case "code": return palette.rare;
51
+ case "data": return palette.epic;
52
+ case "media": return palette.legendary;
53
+ default: return palette.muted;
82
54
  }
83
- return out.join("/");
84
- }
85
- /**
86
- * Classify a raw cover reference from frontmatter.
87
- *
88
- * Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
89
- * The trade-off is that opening such a board issues a request to that third
90
- * party; the renderer degrades silently if it fails, and nothing is retried in a
91
- * loop.
92
- */
93
- function resolveCoverRef(fromFilePath, raw) {
94
- const value = (raw ?? "").trim();
95
- if (!value) return null;
96
- if (value.startsWith("//")) return {
97
- kind: "url",
98
- url: `https:${value}`
99
- };
100
- const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
101
- if (scheme) {
102
- if (scheme === "https") return {
103
- kind: "url",
104
- url: value
105
- };
106
- return null;
107
- }
108
- const path = resolveSpacePath(fromFilePath, value);
109
- return path ? {
110
- kind: "path",
111
- path
112
- } : null;
113
- }
114
- /**
115
- * Split leading YAML frontmatter from a markdown source. Deliberately minimal:
116
- * this only needs the raw block so a handful of scalar keys can be read, not a
117
- * YAML parser.
118
- */
119
- function splitFrontmatter(source) {
120
- if (!/^---[ \t]*\r?\n/.test(source)) return {
121
- frontmatter: null,
122
- body: source
123
- };
124
- const lines = source.split(/\r?\n/);
125
- for (let index = 1; index < lines.length; index += 1) {
126
- const line = lines[index] ?? "";
127
- if (line === "---" || line === "...") return {
128
- frontmatter: lines.slice(1, index).join("\n"),
129
- body: lines.slice(index + 1).join("\n")
130
- };
131
- }
132
- return {
133
- frontmatter: null,
134
- body: source
135
- };
136
- }
137
- function unquote(value) {
138
- const trimmed = value.trim();
139
- if (trimmed.length < 2) return trimmed;
140
- const first = trimmed[0];
141
- const last = trimmed[trimmed.length - 1];
142
- if (first === "\"" && last === "\"" || first === "'" && last === "'") return trimmed.slice(1, -1);
143
- return trimmed;
144
- }
145
- /** Read non-empty top-level scalar values from a frontmatter block. */
146
- function readFrontmatterScalars(frontmatter) {
147
- const found = /* @__PURE__ */ new Map();
148
- if (!frontmatter) return found;
149
- for (const line of frontmatter.split(/\r?\n/)) {
150
- if (!line.trim() || /^\s/.test(line) || line.trimStart().startsWith("#")) continue;
151
- const match = /^([A-Za-z0-9_:.-]+):(.*)$/.exec(line);
152
- if (!match) continue;
153
- const key = match[1];
154
- const value = unquote(match[2] ?? "");
155
- if (key && value && !found.has(key)) found.set(key, value);
156
- }
157
- return found;
158
- }
159
- /** Read the document title declared by top-level frontmatter. */
160
- function readTitleFromFrontmatter(frontmatter) {
161
- return readFrontmatterScalars(frontmatter).get("title") ?? null;
162
- }
163
- /**
164
- * Read the first cover-ish scalar from a frontmatter block.
165
- *
166
- * Only top-level keys are considered (a leading-space line is nested and skipped)
167
- * so `cover:` under some other mapping cannot be mistaken for the file's own.
168
- */
169
- function readCoverFromFrontmatter(frontmatter) {
170
- const found = readFrontmatterScalars(frontmatter);
171
- for (const key of COVER_KEYS) {
172
- const value = found.get(key);
173
- if (value) return value;
174
- }
175
- return null;
176
- }
177
- /**
178
- * Reduce source text to a short, readable excerpt.
179
- *
180
- * The goal is a card-sized hint of what the file is about, so markdown
181
- * decoration is flattened rather than rendered: fenced code is dropped whole
182
- * (it reads as noise at thumbnail size), headings and emphasis lose their
183
- * markers, links keep their text, and blank runs collapse.
184
- */
185
- function buildFileExcerpt(source, limit = 480) {
186
- if (!source) return "";
187
- const cleaned = source.replace(/```[\s\S]*?(?:```|$)/g, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/?[a-z][^>]*>/gi, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[ \t]*#{1,6}[ \t]+/gm, "").replace(/^[ \t]*>[ \t]?/gm, "").replace(/^[ \t]*[-*+][ \t]+/gm, "").replace(/^[ \t]*\d+\.[ \t]+/gm, "").replace(/^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/gm, " ").replace(/\|/g, " ").replace(/[*_`~]/g, "").replace(/\r\n?/g, "\n").replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").replace(/[ \t]*\n[ \t]*/g, "\n").trim();
188
- if (cleaned.length <= limit) return cleaned;
189
- const slice = cleaned.slice(0, limit);
190
- const lastSpace = slice.lastIndexOf(" ");
191
- return `${(lastSpace > limit * .6 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`;
192
- }
193
- /** Basename of a path, used as the default card title. */
194
- function fileBaseName(path) {
195
- return path.split("/").filter(Boolean).pop() ?? path;
196
- }
197
- /**
198
- * Build the cached display facts for a file node.
199
- *
200
- * Content is optional by design: a snapshot built without it still produces a
201
- * usable `blank` card, so a node can be created the instant a file is dropped
202
- * and enriched later without blocking on a read.
203
- */
204
- function buildFileSnapshot(input) {
205
- const snapshot = { title: input.title?.trim() || fileBaseName(input.path) };
206
- if (input.mimeType) snapshot.mimeType = input.mimeType;
207
- if (typeof input.size === "number" && Number.isFinite(input.size)) snapshot.size = input.size;
208
- if (typeof input.mtimeMs === "number" && Number.isFinite(input.mtimeMs)) snapshot.mtimeMs = input.mtimeMs;
209
- const content = input.content;
210
- if (typeof content !== "string" || content.length === 0) return snapshot;
211
- const { frontmatter, body } = splitFrontmatter(content);
212
- const frontmatterTitle = readTitleFromFrontmatter(frontmatter);
213
- if (frontmatterTitle) snapshot.title = frontmatterTitle;
214
- const cover = resolveCoverRef(input.path, readCoverFromFrontmatter(frontmatter));
215
- if (cover?.kind === "url") snapshot.coverUrl = cover.url;
216
- else if (cover?.kind === "path") snapshot.coverPath = cover.path;
217
- const excerpt = buildFileExcerpt(body);
218
- if (excerpt) snapshot.excerpt = excerpt;
219
- return snapshot;
220
55
  }
221
56
  /** Whether a snapshot's cached facts still describe the file on disk. */
222
57
  function isFileSnapshotFresh(snapshot, file) {
@@ -226,17 +61,11 @@ function isFileSnapshotFresh(snapshot, file) {
226
61
  if (snapshot.size !== void 0 && file.size !== void 0 && snapshot.size !== file.size) return false;
227
62
  return true;
228
63
  }
229
- /** Whether a file is small enough that fetching a text preview is worthwhile. */
230
- function shouldFetchFileExcerpt(input) {
231
- if (input.size !== void 0 && input.size > 262144) return false;
232
- return true;
233
- }
234
64
  /**
235
65
  * Classify a failed read.
236
66
  *
237
67
  * Only a 404 (or 410 Gone) is treated as the file being absent. Everything else
238
- * — offline, 5xx, timeout, 401/403 — is `unavailable`: the file may well exist and
239
- * simply not be reachable by this client right now.
68
+ * — offline, 5xx, timeout, 401/403 — is `unavailable`.
240
69
  */
241
70
  function availabilityFromError(error) {
242
71
  const status = typeof error === "object" && error !== null ? error.status : void 0;
@@ -248,16 +77,14 @@ function availabilityFromError(error) {
248
77
  * Cache key for a file within a space.
249
78
  *
250
79
  * A path only means anything relative to its space, and identical paths across
251
- * spaces are the norm ("README.md"), so every preview cache is keyed by both. The
252
- * separator is NUL because it cannot appear in a space id or a path, so no path can
253
- * be crafted to collide with another space's entry.
80
+ * spaces are the norm ("README.md"), so every preview cache is keyed by both.
254
81
  */
255
82
  function filePreviewScope(spaceId, path) {
256
83
  return `${spaceId}\u0000${path}`;
257
84
  }
258
85
  /**
259
- * Cache key for one *version* of a file. The mtime is part of the key so a changed
260
- * file misses the cache instead of serving a stale excerpt or cover.
86
+ * Cache key for one *version* of a file. The mtime is part of the key so a
87
+ * changed file misses the cache instead of serving a stale excerpt or cover.
261
88
  */
262
89
  function filePreviewMemoKey(spaceId, path, mtimeMs) {
263
90
  return `${filePreviewScope(spaceId, path)}@${mtimeMs ?? 0}`;
@@ -265,12 +92,8 @@ function filePreviewMemoKey(spaceId, path, mtimeMs) {
265
92
  /**
266
93
  * Fold a freshly read snapshot into the cached one.
267
94
  *
268
- * `complete` decides whether the incoming facts supersede the cached ones or are
269
- * merged over them, and the difference is not cosmetic. A snapshot omits fields the
270
- * file does not have, so merging a complete read would resurrect a cover or an
271
- * excerpt the file no longer contains — permanently, since the stale value is then
272
- * committed back. Merging is right only for an incomplete read, where an absent
273
- * field means "could not establish", not "not there".
95
+ * `complete` decides whether the incoming facts supersede the cached ones or
96
+ * are merged over them. A complete read's omissions are authoritative.
274
97
  */
275
98
  function mergeFileSnapshot(cached, incoming, complete) {
276
99
  return complete ? incoming : {
@@ -279,4 +102,4 @@ function mergeFileSnapshot(cached, incoming, complete) {
279
102
  };
280
103
  }
281
104
  //#endregion
282
- export { FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, availabilityFromError, buildFileExcerpt, buildFileSnapshot, fileBaseName, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, readTitleFromFrontmatter, resolveCoverRef, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
105
+ export { FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, availabilityFromError, buildCodeExcerpt, buildFileExcerpt, buildFileSnapshot, fileBaseName, fileCategory, fileCategoryAccent, fileMetaLine, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileStem, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, readFrontmatterScalars, readTitleFromFrontmatter, resolveCoverRef, resolveFileTitle, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
@@ -0,0 +1,106 @@
1
+ //#region src/board/core/file-snapshot.d.ts
2
+ /**
3
+ * File-card snapshot derivation — pure, renderer-agnostic, dependency-free.
4
+ *
5
+ * Whoever holds the file content calls `buildFileSnapshot`; today that is the
6
+ * web client, for cards near its viewport. Nothing here ever writes back to the
7
+ * workspace file. The file on disk stays the single source of truth, and the
8
+ * snapshot is a cache keyed by mtime so a stale card is detectable.
9
+ */
10
+ /** Hard cap on a stored excerpt. Board cards show a few lines at most. */
11
+ declare const FILE_EXCERPT_MAX_CHARS = 480;
12
+ /** Files above this size are shown as `blank`; we never pull them for a preview. */
13
+ declare const FILE_EXCERPT_MAX_BYTES: number;
14
+ type FileCategory = "doc" | "code" | "data" | "media" | "other";
15
+ type BoardFileSnapshotFacts = {
16
+ title?: string;
17
+ mimeType?: string;
18
+ size?: number;
19
+ mtimeMs?: number;
20
+ /** Cleaned leading prose, capped at FILE_EXCERPT_MAX_CHARS. */
21
+ excerpt?: string;
22
+ /** Cover declared as a path inside the space, resolved against the file's dir. */
23
+ coverPath?: string;
24
+ /** Cover declared as an absolute `https:` URL. */
25
+ coverUrl?: string;
26
+ };
27
+ type BuildSnapshotInput = {
28
+ path: string;
29
+ /** File text, when it could be read as text. Omit for binary/oversized. */
30
+ content?: string | null;
31
+ title?: string;
32
+ mimeType?: string | null;
33
+ size?: number;
34
+ mtimeMs?: number;
35
+ };
36
+ /** Classify a file so excerpt, title and colour can vary without a second source of truth. */
37
+ declare function fileCategory(path: string, mimeType?: string | null): FileCategory;
38
+ /** Basename of a path, used as a last-resort card title. */
39
+ declare function fileBaseName(path: string): string;
40
+ /** Basename with a trailing extension stripped. Dotfiles keep their full name. */
41
+ declare function fileStem(path: string): string;
42
+ type ResolvedCover = {
43
+ kind: "url";
44
+ url: string;
45
+ } | {
46
+ kind: "path";
47
+ path: string;
48
+ } | null;
49
+ /** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
50
+ declare function resolveSpacePath(fromFilePath: string, ref: string): string;
51
+ /**
52
+ * Classify a raw cover reference from frontmatter.
53
+ *
54
+ * Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
55
+ * `http:` / `data:` / `blob:` are rejected so a board never downgrades the page
56
+ * or embeds opaque bytes.
57
+ */
58
+ declare function resolveCoverRef(fromFilePath: string, raw: string | undefined | null): ResolvedCover;
59
+ type SplitSource = {
60
+ frontmatter: string | null;
61
+ body: string;
62
+ };
63
+ /**
64
+ * Split leading YAML (`---`) or TOML (`+++`) frontmatter from a source.
65
+ *
66
+ * BOM and leading blank lines are ignored. The closing fence may carry trailing
67
+ * whitespace; YAML also accepts `...`. An unterminated block is treated as body.
68
+ */
69
+ declare function splitFrontmatter(source: string): SplitSource;
70
+ /** Read non-empty top-level scalar values from a frontmatter / YAML / TOML block. */
71
+ declare function readFrontmatterScalars(frontmatter: string | null): Map<string, string>;
72
+ declare function readTitleFromFrontmatter(frontmatter: string | null): string | null;
73
+ declare function readCoverFromFrontmatter(frontmatter: string | null): string | null;
74
+ declare function resolveFileTitle(input: {
75
+ path: string;
76
+ frontmatter: string | null;
77
+ body: string;
78
+ fallback?: string;
79
+ }): {
80
+ title: string;
81
+ body: string;
82
+ };
83
+ /**
84
+ * Reduce markdown to a short, readable excerpt.
85
+ *
86
+ * Decoration is flattened rather than rendered: fenced code is dropped, headings
87
+ * and list markers go, links keep their text. Emphasis markers are only removed
88
+ * when they wrap a span, so a stray `*` does not punch holes in the prose.
89
+ */
90
+ declare function buildFileExcerpt(source: string, limit?: number): string;
91
+ declare function buildCodeExcerpt(source: string, limit?: number): string;
92
+ /**
93
+ * Build the cached display facts for a file node.
94
+ *
95
+ * Content is optional: a snapshot built without it still produces a usable
96
+ * `blank` card, so a node can be created the instant a file is dropped and
97
+ * enriched later without blocking on a read.
98
+ */
99
+ declare function buildFileSnapshot(input: BuildSnapshotInput): BoardFileSnapshotFacts;
100
+ /** Whether a file is small enough that fetching a text preview is worthwhile. */
101
+ declare function shouldFetchFileExcerpt(input: {
102
+ mimeType?: string | null;
103
+ size?: number;
104
+ }): boolean;
105
+ //#endregion
106
+ export { BoardFileSnapshotFacts, BuildSnapshotInput, FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, FileCategory, ResolvedCover, buildCodeExcerpt, buildFileExcerpt, buildFileSnapshot, fileBaseName, fileCategory, fileStem, readCoverFromFrontmatter, readFrontmatterScalars, readTitleFromFrontmatter, resolveCoverRef, resolveFileTitle, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };