@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.
Files changed (83) hide show
  1. package/README.md +2 -2
  2. package/assets/skill/SKILL.md +26 -1
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.27.0.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +2 -2
  7. package/spec/cli.md +41 -4
  8. package/spec/db/schema.sql +12 -0
  9. package/spec/mcp.md +5 -0
  10. package/spec/output-schemas/ask.schema.json +125 -0
  11. package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
  12. package/spec/output-schemas/get.schema.json +94 -0
  13. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  14. package/spec/output-schemas/multi-get.schema.json +128 -0
  15. package/spec/output-schemas/record-import.schema.json +193 -0
  16. package/spec/output-schemas/search-result.schema.json +94 -0
  17. package/spec/output-schemas/search-results.schema.json +125 -0
  18. package/spec/project-profile.schema.json +66 -0
  19. package/src/app/context-format.ts +1 -1
  20. package/src/cli/commands/get.ts +12 -2
  21. package/src/cli/commands/index-cmd.ts +13 -0
  22. package/src/cli/commands/multi-get.ts +9 -2
  23. package/src/cli/commands/shared.ts +28 -0
  24. package/src/cli/commands/update.ts +5 -0
  25. package/src/cli/program.ts +4 -0
  26. package/src/config/project-profile.ts +2 -0
  27. package/src/config/types.ts +16 -0
  28. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  29. package/src/converters/adapters/browser-export/formats.ts +358 -0
  30. package/src/converters/adapters/email/adapter.ts +429 -0
  31. package/src/converters/adapters/email/html.ts +162 -0
  32. package/src/converters/adapters/email/mime.ts +454 -0
  33. package/src/converters/adapters/email/parameters.ts +77 -0
  34. package/src/converters/adapters/ical/adapter.ts +475 -0
  35. package/src/converters/adapters/ical/recurrence.ts +186 -0
  36. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  37. package/src/converters/adapters/jsonl/config.ts +105 -0
  38. package/src/converters/adapters/shared/html-text.ts +165 -0
  39. package/src/converters/adapters/shared/record-utils.ts +79 -0
  40. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  41. package/src/converters/adapters/transcript/adapter.ts +295 -0
  42. package/src/converters/adapters/transcript/json.ts +184 -0
  43. package/src/converters/adapters/transcript/model.ts +171 -0
  44. package/src/converters/adapters/transcript/text.ts +58 -0
  45. package/src/converters/adapters/transcript/timed.ts +152 -0
  46. package/src/converters/index.ts +11 -1
  47. package/src/converters/mime.ts +8 -0
  48. package/src/converters/pipeline.ts +15 -1
  49. package/src/converters/registry.ts +37 -1
  50. package/src/converters/types.ts +136 -0
  51. package/src/core/context-capsule-schema.ts +87 -0
  52. package/src/core/context-capsule.ts +20 -0
  53. package/src/core/context-evidence.ts +7 -1
  54. package/src/core/document-capabilities.ts +12 -0
  55. package/src/core/project-profile-apply-state.ts +5 -0
  56. package/src/core/project-profile.ts +4 -0
  57. package/src/core/record-metadata.ts +49 -0
  58. package/src/ingestion/record-adapter-canonical.ts +433 -0
  59. package/src/ingestion/record-adapter.ts +437 -0
  60. package/src/ingestion/record-container.ts +688 -0
  61. package/src/ingestion/record-path.ts +20 -0
  62. package/src/ingestion/record-sync.ts +70 -0
  63. package/src/ingestion/sync.ts +228 -36
  64. package/src/ingestion/types.ts +69 -1
  65. package/src/ingestion/walker.ts +31 -11
  66. package/src/mcp/tools/get.ts +10 -2
  67. package/src/mcp/tools/multi-get.ts +9 -2
  68. package/src/mcp/tools/workspace-write.ts +2 -0
  69. package/src/pipeline/filters.ts +1 -1
  70. package/src/pipeline/graph-retrieval.ts +7 -4
  71. package/src/pipeline/hybrid.ts +7 -4
  72. package/src/pipeline/result-context.ts +12 -4
  73. package/src/pipeline/search.ts +11 -4
  74. package/src/pipeline/types.ts +3 -0
  75. package/src/pipeline/vsearch.ts +26 -8
  76. package/src/sdk/documents.ts +13 -5
  77. package/src/serve/browse-tree.ts +4 -2
  78. package/src/serve/routes/api.ts +63 -65
  79. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  80. package/src/store/migrations/index.ts +2 -0
  81. package/src/store/sqlite/adapter.ts +114 -7
  82. package/src/store/types.ts +40 -0
  83. 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("<", "&lt;")
35
+ .replaceAll(">", "&gt;")
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
+ };