@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.
- package/dist/board/core/file-preview.d.ts +18 -139
- package/dist/board/core/file-preview.js +23 -200
- package/dist/board/core/file-snapshot.d.ts +106 -0
- package/dist/board/core/file-snapshot.js +503 -0
- package/dist/board/index.d.ts +3 -2
- package/dist/board/index.js +3 -2
- package/dist/board/render/renderers/file-card-renderer.js +92 -18
- package/dist/chunks/http.d.ts +34 -3
- package/dist/chunks/websocket.d.ts +3 -0
- package/dist/chunks/websocket.js +97 -26
- package/dist/index.d.ts +57 -1
- package/dist/index.js +349 -20
- package/docs/app-runtime-guide.md +25 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
170
|
-
* merged over them
|
|
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
|
-
/**
|
|
71
|
-
function
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
269
|
-
* merged over them
|
|
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 };
|