@gmickel/gno 1.27.0 → 1.28.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 +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.0.zip.sha256 +0 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RecordAdapter,
|
|
3
|
+
RecordAdapterEvent,
|
|
4
|
+
RecordAdapterInput,
|
|
5
|
+
RecordAdapterRecord,
|
|
6
|
+
RecordMetadata,
|
|
7
|
+
} from "../../types";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
adapterLineByteLimit,
|
|
11
|
+
canonicalJson,
|
|
12
|
+
hashRecordValue,
|
|
13
|
+
safeInlineText,
|
|
14
|
+
safeMarkdownText,
|
|
15
|
+
scalarList,
|
|
16
|
+
scalarText,
|
|
17
|
+
sourceNamespace,
|
|
18
|
+
} from "../shared/record-utils";
|
|
19
|
+
import { readBoundedUtf8Lines } from "../shared/utf8-lines";
|
|
20
|
+
import {
|
|
21
|
+
type JsonlFieldMapping,
|
|
22
|
+
parseJsonlFieldMapping,
|
|
23
|
+
resolveJsonlField,
|
|
24
|
+
} from "./config";
|
|
25
|
+
|
|
26
|
+
const ADAPTER_ID = "adapter/jsonl";
|
|
27
|
+
const ADAPTER_VERSION = "1.0.0";
|
|
28
|
+
const DEFAULT_ID_SELECTORS = ["/id", "/externalId", "/external_id"];
|
|
29
|
+
const DEFAULT_BODY_SELECTORS = ["/text", "/content", "/body"];
|
|
30
|
+
const DEFAULT_TITLE_SELECTORS = ["/title", "/name"];
|
|
31
|
+
const DEFAULT_AUTHOR_SELECTORS = ["/author", "/creator"];
|
|
32
|
+
|
|
33
|
+
const failure = (
|
|
34
|
+
code: "MALFORMED_RECORD" | "MISSING_ID" | "RECORD_TOO_LARGE",
|
|
35
|
+
lineNumber: number,
|
|
36
|
+
retryable = false
|
|
37
|
+
): RecordAdapterEvent => ({
|
|
38
|
+
type: "failure",
|
|
39
|
+
failure: {
|
|
40
|
+
code,
|
|
41
|
+
message: "JSONL record could not be converted.",
|
|
42
|
+
retryable,
|
|
43
|
+
sourceLocator: `line:${lineNumber}`,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const requireScalar = (
|
|
48
|
+
value: unknown
|
|
49
|
+
): { ok: true; value?: string } | { ok: false } => {
|
|
50
|
+
if (value === undefined || value === null) return { ok: true };
|
|
51
|
+
const scalar = scalarText(value);
|
|
52
|
+
return scalar ? { ok: true, value: scalar } : { ok: false };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const markdownBody = (value: unknown): string => {
|
|
56
|
+
const scalar = scalarText(value);
|
|
57
|
+
if (scalar !== undefined) return safeMarkdownText(scalar);
|
|
58
|
+
return safeMarkdownText(canonicalJson(value));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const buildMetadata = (
|
|
62
|
+
source: Record<string, unknown>,
|
|
63
|
+
mapping: JsonlFieldMapping
|
|
64
|
+
): { ok: true; metadata: RecordMetadata } | { ok: false } => {
|
|
65
|
+
const author = requireScalar(resolveJsonlField(source, mapping.author));
|
|
66
|
+
const sessionId = requireScalar(resolveJsonlField(source, mapping.sessionId));
|
|
67
|
+
const threadId = requireScalar(resolveJsonlField(source, mapping.threadId));
|
|
68
|
+
if (!(author.ok && sessionId.ok && threadId.ok)) return { ok: false };
|
|
69
|
+
|
|
70
|
+
const participantsValue = resolveJsonlField(source, mapping.participants);
|
|
71
|
+
const categoriesValue = resolveJsonlField(source, mapping.categories);
|
|
72
|
+
const participants = scalarList(participantsValue);
|
|
73
|
+
const categories = scalarList(categoriesValue);
|
|
74
|
+
if (
|
|
75
|
+
(participantsValue !== undefined && !participants) ||
|
|
76
|
+
(categoriesValue !== undefined && !categories)
|
|
77
|
+
) {
|
|
78
|
+
return { ok: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dateFields: Record<string, string> = {};
|
|
82
|
+
for (const [name, selector] of Object.entries(mapping.dateFields ?? {})) {
|
|
83
|
+
const raw = resolveJsonlField(source, selector);
|
|
84
|
+
const value = requireScalar(raw);
|
|
85
|
+
if (!value.ok) return { ok: false };
|
|
86
|
+
if (value.value) dateFields[name] = value.value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
metadata: {
|
|
92
|
+
author: author.value,
|
|
93
|
+
participants,
|
|
94
|
+
categories,
|
|
95
|
+
dateFields: Object.keys(dateFields).length > 0 ? dateFields : undefined,
|
|
96
|
+
sessionId: sessionId.value,
|
|
97
|
+
threadId: threadId.value,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const recordFromLine = (
|
|
103
|
+
source: Record<string, unknown>,
|
|
104
|
+
lineNumber: number,
|
|
105
|
+
input: RecordAdapterInput,
|
|
106
|
+
mapping: JsonlFieldMapping,
|
|
107
|
+
allowConventionalId: boolean
|
|
108
|
+
): RecordAdapterEvent => {
|
|
109
|
+
const canonicalSource = canonicalJson(source);
|
|
110
|
+
const container = sourceNamespace(input);
|
|
111
|
+
const configuredId = resolveJsonlField(
|
|
112
|
+
source,
|
|
113
|
+
mapping.id ?? (allowConventionalId ? DEFAULT_ID_SELECTORS : undefined)
|
|
114
|
+
);
|
|
115
|
+
const id = requireScalar(configuredId);
|
|
116
|
+
if (!id.ok) return failure("MALFORMED_RECORD", lineNumber);
|
|
117
|
+
if (mapping.id && !id.value) return failure("MISSING_ID", lineNumber);
|
|
118
|
+
|
|
119
|
+
const configuredBody = resolveJsonlField(
|
|
120
|
+
source,
|
|
121
|
+
mapping.body ?? DEFAULT_BODY_SELECTORS
|
|
122
|
+
);
|
|
123
|
+
if (mapping.body && configuredBody === undefined) {
|
|
124
|
+
return failure("MALFORMED_RECORD", lineNumber);
|
|
125
|
+
}
|
|
126
|
+
const body = configuredBody ?? source;
|
|
127
|
+
const titleValue = requireScalar(
|
|
128
|
+
resolveJsonlField(source, mapping.title ?? DEFAULT_TITLE_SELECTORS)
|
|
129
|
+
);
|
|
130
|
+
if (!titleValue.ok) return failure("MALFORMED_RECORD", lineNumber);
|
|
131
|
+
const authorMapping = mapping.author
|
|
132
|
+
? mapping
|
|
133
|
+
: { ...mapping, author: DEFAULT_AUTHOR_SELECTORS };
|
|
134
|
+
const metadata = buildMetadata(source, authorMapping);
|
|
135
|
+
if (!metadata.ok) return failure("MALFORMED_RECORD", lineNumber);
|
|
136
|
+
|
|
137
|
+
const identity = id.value
|
|
138
|
+
? `id:${hashRecordValue("gno-jsonl-external-id-v1", id.value)}`
|
|
139
|
+
: `content:${hashRecordValue("gno-jsonl-content-id-v1", canonicalSource)}`;
|
|
140
|
+
const title = titleValue.value ?? `JSONL record ${lineNumber}`;
|
|
141
|
+
const record: RecordAdapterRecord = {
|
|
142
|
+
stableId: `jsonl:${container}:${identity}`,
|
|
143
|
+
sourceLocator: `line:${lineNumber}`,
|
|
144
|
+
sourceHash: hashRecordValue("gno-jsonl-source-v1", canonicalSource),
|
|
145
|
+
title,
|
|
146
|
+
markdown: `# ${safeInlineText(title)}\n\n${markdownBody(body)}`,
|
|
147
|
+
metadata: metadata.metadata,
|
|
148
|
+
anchors: [{ kind: "line", value: String(lineNumber) }],
|
|
149
|
+
};
|
|
150
|
+
return { type: "record", record };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const createJsonlAdapter = (
|
|
154
|
+
fieldMapping?: JsonlFieldMapping
|
|
155
|
+
): RecordAdapter => {
|
|
156
|
+
const mapping = parseJsonlFieldMapping(fieldMapping);
|
|
157
|
+
const allowConventionalId = fieldMapping === undefined;
|
|
158
|
+
return {
|
|
159
|
+
id: ADAPTER_ID,
|
|
160
|
+
version: ADAPTER_VERSION,
|
|
161
|
+
configurationFingerprint: hashRecordValue(
|
|
162
|
+
"gno-jsonl-config-v1",
|
|
163
|
+
canonicalJson(mapping)
|
|
164
|
+
),
|
|
165
|
+
canHandle: (mime, ext) =>
|
|
166
|
+
mime === "application/x-ndjson" ||
|
|
167
|
+
mime === "application/jsonl" ||
|
|
168
|
+
ext === ".jsonl" ||
|
|
169
|
+
ext === ".ndjson",
|
|
170
|
+
records: async function* (
|
|
171
|
+
input: RecordAdapterInput
|
|
172
|
+
): AsyncGenerator<RecordAdapterEvent> {
|
|
173
|
+
let hadFailure = false;
|
|
174
|
+
try {
|
|
175
|
+
const lines = readBoundedUtf8Lines(
|
|
176
|
+
input.open(),
|
|
177
|
+
adapterLineByteLimit(input)
|
|
178
|
+
);
|
|
179
|
+
for await (const line of lines) {
|
|
180
|
+
if (!line.ok) {
|
|
181
|
+
hadFailure = true;
|
|
182
|
+
yield failure(
|
|
183
|
+
line.reason === "line_too_large"
|
|
184
|
+
? "RECORD_TOO_LARGE"
|
|
185
|
+
: "MALFORMED_RECORD",
|
|
186
|
+
line.lineNumber,
|
|
187
|
+
!line.terminated
|
|
188
|
+
);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (line.text.trim() === "") continue;
|
|
192
|
+
let parsed: unknown;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(line.text);
|
|
195
|
+
} catch {
|
|
196
|
+
hadFailure = true;
|
|
197
|
+
yield failure(
|
|
198
|
+
"MALFORMED_RECORD",
|
|
199
|
+
line.lineNumber,
|
|
200
|
+
!line.terminated
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
205
|
+
hadFailure = true;
|
|
206
|
+
yield failure("MALFORMED_RECORD", line.lineNumber);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const event = recordFromLine(
|
|
210
|
+
parsed as Record<string, unknown>,
|
|
211
|
+
line.lineNumber,
|
|
212
|
+
input,
|
|
213
|
+
mapping,
|
|
214
|
+
allowConventionalId
|
|
215
|
+
);
|
|
216
|
+
hadFailure ||= event.type === "failure";
|
|
217
|
+
yield event;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
hadFailure = true;
|
|
221
|
+
yield {
|
|
222
|
+
type: "failure",
|
|
223
|
+
failure: {
|
|
224
|
+
code: "ADAPTER_FAILURE",
|
|
225
|
+
message: "JSONL source could not be read completely.",
|
|
226
|
+
retryable: true,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
yield {
|
|
231
|
+
type: "snapshot",
|
|
232
|
+
state: hadFailure ? "partial" : "complete",
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const jsonlAdapter = createJsonlAdapter();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const FORBIDDEN_POINTER_SEGMENTS = new Set([
|
|
4
|
+
"__proto__",
|
|
5
|
+
"constructor",
|
|
6
|
+
"prototype",
|
|
7
|
+
]);
|
|
8
|
+
const POINTER_PATTERN = /^(?:\/(?:[^~/]|~[01])*)*$/;
|
|
9
|
+
const FIELD_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
10
|
+
|
|
11
|
+
const JsonPointerSchema = z
|
|
12
|
+
.string()
|
|
13
|
+
.max(512)
|
|
14
|
+
.refine((value) => POINTER_PATTERN.test(value), "Invalid JSON Pointer")
|
|
15
|
+
.refine(
|
|
16
|
+
(value) => value.split("/").length <= 33,
|
|
17
|
+
"JSON Pointer exceeds 32 segments"
|
|
18
|
+
)
|
|
19
|
+
.refine(
|
|
20
|
+
(value) =>
|
|
21
|
+
value
|
|
22
|
+
.split("/")
|
|
23
|
+
.slice(1)
|
|
24
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
25
|
+
.every((segment) => !FORBIDDEN_POINTER_SEGMENTS.has(segment)),
|
|
26
|
+
"JSON Pointer contains a forbidden property"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const FieldSelectorSchema = z.union([
|
|
30
|
+
JsonPointerSchema,
|
|
31
|
+
z.array(JsonPointerSchema).min(1).max(8),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const JsonlFieldMappingSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
id: FieldSelectorSchema.optional(),
|
|
37
|
+
body: FieldSelectorSchema.optional(),
|
|
38
|
+
title: FieldSelectorSchema.optional(),
|
|
39
|
+
author: FieldSelectorSchema.optional(),
|
|
40
|
+
participants: FieldSelectorSchema.optional(),
|
|
41
|
+
categories: FieldSelectorSchema.optional(),
|
|
42
|
+
sessionId: FieldSelectorSchema.optional(),
|
|
43
|
+
threadId: FieldSelectorSchema.optional(),
|
|
44
|
+
dateFields: z
|
|
45
|
+
.record(z.string().regex(FIELD_NAME_PATTERN), FieldSelectorSchema)
|
|
46
|
+
.superRefine((fields, context) => {
|
|
47
|
+
if (Object.keys(fields).length > 16) {
|
|
48
|
+
context.addIssue({
|
|
49
|
+
code: "custom",
|
|
50
|
+
message: "JSONL date-field mapping exceeds 16 entries",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.optional(),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
|
|
58
|
+
export type JsonlFieldSelector = z.infer<typeof FieldSelectorSchema>;
|
|
59
|
+
export type JsonlFieldMapping = z.infer<typeof JsonlFieldMappingSchema>;
|
|
60
|
+
|
|
61
|
+
export const parseJsonlFieldMapping = (
|
|
62
|
+
mapping: JsonlFieldMapping | undefined
|
|
63
|
+
): JsonlFieldMapping => JsonlFieldMappingSchema.parse(mapping ?? {});
|
|
64
|
+
|
|
65
|
+
const pointerSegments = (pointer: string): string[] =>
|
|
66
|
+
pointer
|
|
67
|
+
.split("/")
|
|
68
|
+
.slice(1)
|
|
69
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
70
|
+
|
|
71
|
+
export const resolveJsonPointer = (
|
|
72
|
+
value: unknown,
|
|
73
|
+
pointer: string
|
|
74
|
+
): unknown => {
|
|
75
|
+
let current = value;
|
|
76
|
+
for (const segment of pointerSegments(pointer)) {
|
|
77
|
+
if (Array.isArray(current)) {
|
|
78
|
+
if (!/^(?:0|[1-9]\d*)$/.test(segment)) return undefined;
|
|
79
|
+
current = current[Number(segment)];
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
!current ||
|
|
84
|
+
typeof current !== "object" ||
|
|
85
|
+
!Object.hasOwn(current, segment)
|
|
86
|
+
) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
current = (current as Record<string, unknown>)[segment];
|
|
90
|
+
}
|
|
91
|
+
return current;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const resolveJsonlField = (
|
|
95
|
+
value: unknown,
|
|
96
|
+
selector: JsonlFieldSelector | undefined
|
|
97
|
+
): unknown => {
|
|
98
|
+
if (selector === undefined) return undefined;
|
|
99
|
+
const pointers = Array.isArray(selector) ? selector : [selector];
|
|
100
|
+
for (const pointer of pointers) {
|
|
101
|
+
const resolved = resolveJsonPointer(value, pointer);
|
|
102
|
+
if (resolved !== undefined && resolved !== null) return resolved;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const NAMED_ENTITIES = new Map<string, string>([
|
|
2
|
+
["amp", "&"],
|
|
3
|
+
["apos", "'"],
|
|
4
|
+
["gt", ">"],
|
|
5
|
+
["lt", "<"],
|
|
6
|
+
["quot", '"'],
|
|
7
|
+
]);
|
|
8
|
+
const MAX_ENTITY_CHARS = 16;
|
|
9
|
+
|
|
10
|
+
const isAsciiWhitespace = (value: string): boolean =>
|
|
11
|
+
value === " " ||
|
|
12
|
+
value === "\t" ||
|
|
13
|
+
value === "\n" ||
|
|
14
|
+
value === "\r" ||
|
|
15
|
+
value === "\f";
|
|
16
|
+
|
|
17
|
+
const isTagNameCharacter = (value: string): boolean => {
|
|
18
|
+
const code = value.charCodeAt(0);
|
|
19
|
+
return (
|
|
20
|
+
(code >= 48 && code <= 57) ||
|
|
21
|
+
(code >= 65 && code <= 90) ||
|
|
22
|
+
(code >= 97 && code <= 122) ||
|
|
23
|
+
value === "-"
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isHexDigit = (value: string): boolean => {
|
|
28
|
+
const code = value.charCodeAt(0);
|
|
29
|
+
return (
|
|
30
|
+
(code >= 48 && code <= 57) ||
|
|
31
|
+
(code >= 65 && code <= 70) ||
|
|
32
|
+
(code >= 97 && code <= 102)
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const hasOnlyDigits = (value: string, hexadecimal: boolean): boolean => {
|
|
37
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
38
|
+
const digit = value[index] ?? "";
|
|
39
|
+
if (hexadecimal ? !isHexDigit(digit) : digit < "0" || digit > "9")
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return value.length > 0;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Decode recognized entities from the original input exactly once. */
|
|
46
|
+
export const decodeHtmlEntitiesOnce = (value: string): string => {
|
|
47
|
+
let output = "";
|
|
48
|
+
let cursor = 0;
|
|
49
|
+
while (cursor < value.length) {
|
|
50
|
+
if (value[cursor] !== "&") {
|
|
51
|
+
output += value[cursor];
|
|
52
|
+
cursor += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const entityEnd = Math.min(value.length, cursor + MAX_ENTITY_CHARS + 2);
|
|
56
|
+
const semicolon = value.slice(cursor + 1, entityEnd).indexOf(";");
|
|
57
|
+
const absoluteSemicolon = semicolon < 0 ? -1 : cursor + semicolon + 1;
|
|
58
|
+
if (
|
|
59
|
+
absoluteSemicolon < 0 ||
|
|
60
|
+
absoluteSemicolon - cursor - 1 <= 0 ||
|
|
61
|
+
absoluteSemicolon - cursor - 1 > MAX_ENTITY_CHARS
|
|
62
|
+
) {
|
|
63
|
+
output += "&";
|
|
64
|
+
cursor += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const entity = value.slice(cursor + 1, absoluteSemicolon);
|
|
68
|
+
const named = NAMED_ENTITIES.get(entity.toLowerCase());
|
|
69
|
+
let decoded = named;
|
|
70
|
+
if (!decoded && entity.startsWith("#")) {
|
|
71
|
+
const hexadecimal =
|
|
72
|
+
entity.length > 2 && (entity[1] === "x" || entity[1] === "X");
|
|
73
|
+
const digits = entity.slice(hexadecimal ? 2 : 1);
|
|
74
|
+
const base = hexadecimal ? 16 : 10;
|
|
75
|
+
const codePoint = hasOnlyDigits(digits, hexadecimal)
|
|
76
|
+
? Number.parseInt(digits, base)
|
|
77
|
+
: Number.NaN;
|
|
78
|
+
if (
|
|
79
|
+
Number.isSafeInteger(codePoint) &&
|
|
80
|
+
codePoint > 0 &&
|
|
81
|
+
codePoint <= 0x10ffff &&
|
|
82
|
+
!(codePoint >= 0xd800 && codePoint <= 0xdfff)
|
|
83
|
+
) {
|
|
84
|
+
decoded = String.fromCodePoint(codePoint);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (decoded === undefined) {
|
|
88
|
+
output += value.slice(cursor, absoluteSemicolon + 1);
|
|
89
|
+
} else {
|
|
90
|
+
output += decoded;
|
|
91
|
+
}
|
|
92
|
+
cursor = absoluteSemicolon + 1;
|
|
93
|
+
}
|
|
94
|
+
return output;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
interface HtmlTag {
|
|
98
|
+
closing: boolean;
|
|
99
|
+
end: number;
|
|
100
|
+
name: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const readTag = (source: string, start: number): HtmlTag | undefined => {
|
|
104
|
+
const end = source.indexOf(">", start + 1);
|
|
105
|
+
if (end < 0) return undefined;
|
|
106
|
+
let cursor = start + 1;
|
|
107
|
+
while (cursor < end && isAsciiWhitespace(source[cursor] ?? "")) cursor += 1;
|
|
108
|
+
const closing = source[cursor] === "/";
|
|
109
|
+
if (closing) {
|
|
110
|
+
cursor += 1;
|
|
111
|
+
while (cursor < end && isAsciiWhitespace(source[cursor] ?? "")) cursor += 1;
|
|
112
|
+
}
|
|
113
|
+
const nameStart = cursor;
|
|
114
|
+
while (cursor < end && isTagNameCharacter(source[cursor] ?? "")) cursor += 1;
|
|
115
|
+
return {
|
|
116
|
+
closing,
|
|
117
|
+
end,
|
|
118
|
+
name: source.slice(nameStart, cursor).toLowerCase(),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reduce a bounded HTML fragment to inert text with a linear scanner.
|
|
124
|
+
* Script/style contents and comments are discarded; entities decode once.
|
|
125
|
+
*/
|
|
126
|
+
export const htmlFragmentToText = (source: string): string => {
|
|
127
|
+
let output = "";
|
|
128
|
+
let cursor = 0;
|
|
129
|
+
let blockedName: "script" | "style" | undefined;
|
|
130
|
+
let blockedDepth = 0;
|
|
131
|
+
while (cursor < source.length) {
|
|
132
|
+
if (source[cursor] !== "<") {
|
|
133
|
+
if (!blockedName) output += source[cursor];
|
|
134
|
+
cursor += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (source.startsWith("<!--", cursor)) {
|
|
138
|
+
const commentEnd = source.indexOf("-->", cursor + 4);
|
|
139
|
+
if (commentEnd < 0) break;
|
|
140
|
+
cursor = commentEnd + 3;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const tag = readTag(source, cursor);
|
|
144
|
+
if (!tag) {
|
|
145
|
+
if (!blockedName) output += source.slice(cursor);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
cursor = tag.end + 1;
|
|
149
|
+
if (blockedName) {
|
|
150
|
+
if (tag.name === blockedName) {
|
|
151
|
+
if (tag.closing) blockedDepth -= 1;
|
|
152
|
+
else blockedDepth += 1;
|
|
153
|
+
if (blockedDepth === 0) blockedName = undefined;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!tag.closing && (tag.name === "script" || tag.name === "style")) {
|
|
158
|
+
blockedName = tag.name;
|
|
159
|
+
blockedDepth = 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (!tag.closing && tag.name === "br") output += "\n";
|
|
163
|
+
}
|
|
164
|
+
return decodeHtmlEntitiesOnce(output);
|
|
165
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { RecordAdapterInput } from "../../types";
|
|
2
|
+
|
|
3
|
+
const CONTROL_PATTERN = new RegExp(
|
|
4
|
+
`[${String.fromCharCode(0)}-${String.fromCharCode(8)}${String.fromCharCode(11)}${String.fromCharCode(12)}${String.fromCharCode(14)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`,
|
|
5
|
+
"g"
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export const hashRecordValue = (domain: string, value: string): string => {
|
|
9
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
10
|
+
hasher.update(`${domain}\0${value}`);
|
|
11
|
+
return hasher.digest("hex");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const canonicalJson = (value: unknown): string => {
|
|
15
|
+
const normalize = (candidate: unknown): unknown => {
|
|
16
|
+
if (Array.isArray(candidate)) return candidate.map(normalize);
|
|
17
|
+
if (candidate && typeof candidate === "object") {
|
|
18
|
+
const record = candidate as Record<string, unknown>;
|
|
19
|
+
return Object.fromEntries(
|
|
20
|
+
Object.keys(record)
|
|
21
|
+
.sort()
|
|
22
|
+
.map((key) => [key, normalize(record[key])])
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return candidate;
|
|
26
|
+
};
|
|
27
|
+
return JSON.stringify(normalize(value));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const safeMarkdownText = (value: string): string =>
|
|
31
|
+
value
|
|
32
|
+
.normalize("NFC")
|
|
33
|
+
.replace(CONTROL_PATTERN, "")
|
|
34
|
+
.replaceAll("<", "<")
|
|
35
|
+
.replaceAll(">", ">")
|
|
36
|
+
.replace(/([\\`*_[\]#!])/g, "\\$1");
|
|
37
|
+
|
|
38
|
+
export const safeInlineText = (value: string): string =>
|
|
39
|
+
safeMarkdownText(value).replaceAll(/\s+/g, " ").trim();
|
|
40
|
+
|
|
41
|
+
export const sourceNamespace = (input: RecordAdapterInput): string =>
|
|
42
|
+
hashRecordValue(
|
|
43
|
+
"gno-record-container-v1",
|
|
44
|
+
canonicalJson({
|
|
45
|
+
collection: input.collection.normalize("NFC"),
|
|
46
|
+
relativePath: input.relativePath.replaceAll("\\", "/").normalize("NFC"),
|
|
47
|
+
})
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const adapterLineByteLimit = (input: RecordAdapterInput): number => {
|
|
51
|
+
const characterBudget =
|
|
52
|
+
input.limits.maxRecordChars + input.limits.maxMetadataChars;
|
|
53
|
+
const utf8Budget = Math.min(
|
|
54
|
+
Number.MAX_SAFE_INTEGER,
|
|
55
|
+
Math.max(1, characterBudget) * 4
|
|
56
|
+
);
|
|
57
|
+
return Math.max(1, Math.min(input.limits.maxSourceBytes, utf8Budget));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const scalarText = (value: unknown): string | undefined => {
|
|
61
|
+
if (
|
|
62
|
+
typeof value !== "string" &&
|
|
63
|
+
typeof value !== "number" &&
|
|
64
|
+
typeof value !== "boolean"
|
|
65
|
+
) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
const text = String(value).normalize("NFC").trim();
|
|
69
|
+
return text || undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const scalarList = (value: unknown): string[] | undefined => {
|
|
73
|
+
if (value === undefined || value === null) return undefined;
|
|
74
|
+
const candidates = Array.isArray(value) ? value : [value];
|
|
75
|
+
const result = candidates
|
|
76
|
+
.map(scalarText)
|
|
77
|
+
.filter((item): item is string => Boolean(item));
|
|
78
|
+
return result.length > 0 ? result : undefined;
|
|
79
|
+
};
|