@gmickel/gno 1.32.0 → 1.33.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 (33) hide show
  1. package/README.md +11 -2
  2. package/assets/skill/SKILL.md +11 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +16 -1
  9. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  10. package/src/cli/commands/publish.ts +43 -7
  11. package/src/ingestion/strip.ts +152 -26
  12. package/src/publish/artifact-asset-codec.ts +75 -0
  13. package/src/publish/artifact-asset-contract.ts +152 -0
  14. package/src/publish/artifact-asset-parse.ts +401 -0
  15. package/src/publish/artifact-asset-sniff.ts +108 -0
  16. package/src/publish/artifact-asset-validate.ts +209 -0
  17. package/src/publish/artifact-assets.ts +58 -0
  18. package/src/publish/artifact-validation.ts +32 -6
  19. package/src/publish/artifact.ts +50 -3
  20. package/src/publish/attachment-bundle.ts +145 -0
  21. package/src/publish/attachment-discover.ts +203 -0
  22. package/src/publish/attachment-load.ts +133 -0
  23. package/src/publish/attachment-obsidian.ts +45 -0
  24. package/src/publish/attachment-path.ts +334 -0
  25. package/src/publish/attachment-raster.ts +852 -0
  26. package/src/publish/attachment-resolver.ts +280 -0
  27. package/src/publish/attachment-types.ts +54 -0
  28. package/src/publish/encrypted-export.ts +121 -44
  29. package/src/publish/export-attachments.ts +224 -0
  30. package/src/publish/export-service.ts +142 -80
  31. package/src/publish/obsidian-sanitize.ts +121 -13
  32. package/src/serve/routes/api.ts +2 -1
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
package/README.md CHANGED
@@ -117,7 +117,7 @@ gno daemon --detach # headless indexing + resident MCP gateway
117
117
 
118
118
  <!-- public-truth:current-version -->
119
119
 
120
- > Current release: **v1.32.0** — see [CHANGELOG.md](./CHANGELOG.md)
120
+ > Current release: **v1.33.0** — see [CHANGELOG.md](./CHANGELOG.md)
121
121
 
122
122
  <!-- /public-truth -->
123
123
 
@@ -773,7 +773,7 @@ GNO is local-first, but sometimes you want a URL to send someone. [**gno.sh**](h
773
773
 
774
774
  ![gno.sh publish reader](./assets/screenshots/publish-reader.jpg)
775
775
 
776
- The workflow is deliberately explicit: **export locally → upload artifact → share URL**. Private and `publish: false` notes stay on your machine. Exported artifacts omit local collection paths and source URIs.
776
+ The workflow is deliberately explicit: **export locally → upload artifact → share URL**. Private and `publish: false` notes stay on your machine. Exported artifacts omit local collection paths and source URIs. Local PNG/JPEG/GIF/WebP/AVIF images (Markdown and Obsidian embeds) are bundled into the artifact with SHA-256 dedup, `gno-asset:` sentinels, and an exact 100 MiB serialized ceiling; external HTTPS images stay as URLs. Encrypted shares keep image bytes inside ciphertext only. Invite-only note text publishes today, but invite-only image delivery on gno.sh is not supported yet.
777
777
 
778
778
  ```bash
779
779
  # Export a single note
@@ -817,6 +817,15 @@ agent capabilities or manifests. Encrypted exports remain ciphertext-only.
817
817
  Reader metadata drops embedded local path or GNO/file URI tokens; canonical
818
818
  and image fields accept only uncredentialed public HTTP(S) targets.
819
819
 
820
+ Resolved local PNG, JPEG, GIF, WebP, and AVIF references are bundled,
821
+ content-addressed, and deduplicated; external public HTTPS images remain
822
+ external. The exact serialized artifact is capped at 100 MiB. Public images
823
+ use immutable snapshot/generation URLs, secret-link images are authorized on
824
+ every request, and encrypted image bytes exist only inside ciphertext before
825
+ the browser creates scoped Blob URLs. Invite-only bundled-image delivery is
826
+ currently fail-closed; use an asset-free invite, secret link, or encrypted
827
+ share when local images are required.
828
+
820
829
  Republishing a public, secret-link, or invite-only artifact updates the same URL. Encrypted shares should be replaced from a fresh local export so the server never needs your plaintext.
821
830
 
822
831
  Encrypted source-backed publish on `gno.sh` is intentionally disabled. For encrypted shares, use:
@@ -92,6 +92,17 @@ Recipe rules:
92
92
  | **Skill** | `skill install/uninstall/show/paths` | Install skill for AI agents |
93
93
  | **Admin** | `status`, `doctor`, `cleanup`, `reset`, `vec`, `completion` | Maintenance and diagnostics |
94
94
 
95
+ ### Publishing with local images
96
+
97
+ `gno publish export` bundles resolved local PNG, JPEG, GIF, WebP, and AVIF
98
+ references, deduplicates identical bytes, preserves public HTTPS images, and
99
+ enforces the 100 MiB exact serialized artifact limit. Review `assetSummary`
100
+ in `--json` output for unresolved or unsupported references. Public and
101
+ secret-link readers serve authorized hosted URLs; encrypted exports keep image
102
+ bytes inside ciphertext and create scoped Blob URLs only after browser
103
+ decryption. Hosted invite-only bundled-image delivery is currently
104
+ fail-closed, so use an asset-free invite, secret link, or encrypted share.
105
+
95
106
  ## Search Modes
96
107
 
97
108
  | Command | Speed | Best For |
@@ -887,11 +887,19 @@ export pipeline runs a sanitizer over each note's markdown. It:
887
887
  `_internal/` convention is treated as never-publish)
888
888
  - converts `[[Target|Alias]]` to the alias text, and `[[Target]]` to the
889
889
  tail segment of the target
890
- - drops `![[image.png]]` embeds (attachments are not bundled yet) with a
891
- warning so the author can migrate to `![alt](url)` or wait for bundling
890
+ - resolves local `![[image.png]]` / Markdown image refs for PNG/JPEG/GIF/WebP/
891
+ AVIF (AVIF must be AV1-decodable at producer ingress) into content-addressed
892
+ `gno-asset:<sha256>` sentinels (deduped; exact 100 MiB serialized ceiling;
893
+ ciphertext field bounds align to that envelope); preserves external HTTPS
894
+ images; skips fenced code (backtick and tilde); drops unsupported/missing/
895
+ unsafe refs with diagnostics
892
896
  - refuses to export a note whose frontmatter contains `publish: false`
893
897
  (single-note export errors; collection export silently skips)
894
898
 
899
+ Successful exports include `assetSummary` in `--json`. Encrypted visibility
900
+ keeps raster bytes inside ciphertext only. Hosted invite-only image delivery
901
+ is currently fail-closed on gno.sh.
902
+
895
903
  Every sanitizer decision surfaces in the CLI output as a "Preprocessor
896
904
  notes" section, on the `--json` response under `warnings`, and on
897
905
  `--preview` as a structured report — so nothing is silently lost.
@@ -0,0 +1 @@
1
+ c398c09680951afa0b5d436f3b92cf3dfb6e49427b9df83830ce899d3b03d7e4 gno-browser-clipper-v1.33.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.32.0"
24
+ "version": "1.33.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -178,6 +178,9 @@
178
178
  "jsonc-parser": "3.3.1",
179
179
  "lucide-react": "1.28.0",
180
180
  "markitdown-ts": "0.0.10",
181
+ "mdast-util-from-markdown": "2.0.3",
182
+ "mdast-util-gfm": "3.1.0",
183
+ "micromark-extension-gfm": "3.0.0",
181
184
  "minimatch": "10.2.6",
182
185
  "nanoid": "6.0.0",
183
186
  "node-llama-cpp": "3.19.1",
@@ -190,6 +193,7 @@
190
193
  "react-markdown": "10.1.0",
191
194
  "rehype-sanitize": "6.0.0",
192
195
  "remark-gfm": "4.0.1",
196
+ "sharp": "0.35.3",
193
197
  "shiki": "4.3.1",
194
198
  "sqlite-vec": "0.1.9",
195
199
  "streamdown": "2.5.0",
package/spec/cli.md CHANGED
@@ -1949,6 +1949,19 @@ tokens MUST be filtered. Canonical and image metadata MUST contain
1949
1949
  uncredentialed public HTTP(S) targets; local hostnames and literal loopback,
1950
1950
  private, or link-local addresses MUST be filtered.
1951
1951
 
1952
+ When local raster attachments are bundled, the V1 artifact MAY include
1953
+ `assets[]` and `requiredCapabilities: ["bundled-raster-assets@1"]`. Export
1954
+ success payloads (CLI `--json` and `POST /api/publish/export`) MUST include a
1955
+ deterministic `assetSummary` with asset/ref counts, raw/encoded/final UTF-8
1956
+ upload bytes, dedup savings, external image count, and unresolved/unsupported
1957
+ diagnostics. Exact final serialized upload bytes MUST be enforced against the
1958
+ 100 MiB ceiling before returning. Encrypted V2 ciphertext string bounds MUST
1959
+ align to that same 100 MiB final-envelope budget and MUST NOT replace exact
1960
+ final serialized measurement as the authoritative upload gate. Producer
1961
+ ingress MUST require AV1-decodable AVIF payloads (structural BMFF alone is
1962
+ insufficient). Asset-free exports omit `assets` /
1963
+ `requiredCapabilities` and report zero asset counts.
1964
+
1952
1965
  Secret-link and invite-only V1 spaces MUST NOT contain a manifest or agent
1953
1966
  capability field. Encrypted V2 spaces MUST contain only ciphertext parameters,
1954
1967
  the opaque secret token, route slug, source type, and encrypted visibility; no
@@ -1956,7 +1969,9 @@ plaintext manifest or evidence may appear outside the ciphertext. V2 builders
1956
1969
  MUST emit a closed projection, validate payload strings as non-empty bounded
1957
1970
  base64, require a positive safe-integer KDF iteration count, and bound the
1958
1971
  non-blank opaque token. Caller-supplied extension fields MUST NOT enter the
1959
- artifact.
1972
+ artifact. Encrypted export MUST place bundled assets only inside the encrypted
1973
+ client payload. The outer V2 envelope MUST contain neither plaintext note
1974
+ content, asset descriptors, source references, nor raster bytes.
1960
1975
 
1961
1976
  ---
1962
1977
 
@@ -16,6 +16,10 @@
16
16
  "egressLineage": { "$ref": "#/definitions/egressLineage" },
17
17
  "source": { "$ref": "#/definitions/slug" },
18
18
  "exportedAt": { "type": "string", "format": "date-time" },
19
+ "requiredCapabilities": {
20
+ "$ref": "#/definitions/requiredCapabilities"
21
+ },
22
+ "assets": { "$ref": "#/definitions/assets" },
19
23
  "spaces": {
20
24
  "type": "array",
21
25
  "minItems": 1,
@@ -37,6 +41,9 @@
37
41
  "egressLineage": { "$ref": "#/definitions/egressLineage" },
38
42
  "source": { "$ref": "#/definitions/slug" },
39
43
  "exportedAt": { "type": "string", "format": "date-time" },
44
+ "requiredCapabilities": {
45
+ "$ref": "#/definitions/requiredCapabilities"
46
+ },
40
47
  "spaces": {
41
48
  "type": "array",
42
49
  "minItems": 1,
@@ -44,6 +51,74 @@
44
51
  }
45
52
  }
46
53
  },
54
+ "requiredCapabilities": {
55
+ "type": "array",
56
+ "uniqueItems": true,
57
+ "items": {
58
+ "type": "string",
59
+ "minLength": 1,
60
+ "maxLength": 128
61
+ }
62
+ },
63
+ "assets": {
64
+ "type": "array",
65
+ "items": { "$ref": "#/definitions/artifactAsset" }
66
+ },
67
+ "artifactAsset": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": [
71
+ "id",
72
+ "mediaType",
73
+ "byteLength",
74
+ "sha256",
75
+ "width",
76
+ "height",
77
+ "encoding",
78
+ "data",
79
+ "references"
80
+ ],
81
+ "properties": {
82
+ "id": { "$ref": "#/definitions/hash" },
83
+ "mediaType": {
84
+ "enum": [
85
+ "image/png",
86
+ "image/jpeg",
87
+ "image/gif",
88
+ "image/webp",
89
+ "image/avif"
90
+ ]
91
+ },
92
+ "byteLength": { "type": "integer", "minimum": 1, "maximum": 104857600 },
93
+ "sha256": { "$ref": "#/definitions/hash" },
94
+ "width": { "type": "integer", "minimum": 1, "maximum": 16384 },
95
+ "height": { "type": "integer", "minimum": 1, "maximum": 16384 },
96
+ "encoding": { "const": "base64" },
97
+ "data": {
98
+ "type": "string",
99
+ "minLength": 1,
100
+ "maxLength": 139810134,
101
+ "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
102
+ },
103
+ "references": {
104
+ "type": "array",
105
+ "minItems": 1,
106
+ "items": {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "required": ["noteSlug", "sourceRef"],
110
+ "properties": {
111
+ "noteSlug": { "$ref": "#/definitions/slug" },
112
+ "sourceRef": {
113
+ "type": "string",
114
+ "minLength": 1,
115
+ "maxLength": 1024
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ },
47
122
  "slug": {
48
123
  "type": "string",
49
124
  "pattern": "^[a-z0-9](?:[a-z0-9-]{0,78}[a-z0-9])?$",
@@ -280,7 +355,7 @@
280
355
  "ciphertext": {
281
356
  "type": "string",
282
357
  "minLength": 1,
283
- "maxLength": 67108864,
358
+ "maxLength": 104857600,
284
359
  "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
285
360
  },
286
361
  "iterations": {
@@ -4,7 +4,8 @@
4
4
  * @module src/cli/commands/publish
5
5
  */
6
6
 
7
- import { mkdir, writeFile } from "node:fs/promises";
7
+ // node:fs/promises directory creation has no Bun-native equivalent.
8
+ import { mkdir } from "node:fs/promises";
8
9
  import { dirname } from "node:path";
9
10
  import { join } from "node:path";
10
11
 
@@ -12,10 +13,15 @@ import type {
12
13
  PublishArtifact,
13
14
  PublishVisibility,
14
15
  } from "../../publish/artifact";
16
+ import type { PublishAssetEgressSummary } from "../../publish/attachment-resolver";
15
17
  import type { SanitizeWarning } from "../../publish/obsidian-sanitize";
16
18
 
17
19
  import { resolveDownloadsDir } from "../../core/user-dirs";
18
- import { derivePublishArtifactFilename, slugify } from "../../publish/artifact";
20
+ import {
21
+ derivePublishArtifactFilename,
22
+ serializePublishArtifact,
23
+ slugify,
24
+ } from "../../publish/artifact";
19
25
  import { exportPublishArtifact } from "../../publish/export-service";
20
26
  import { formatSanitizeWarnings } from "../../publish/obsidian-sanitize";
21
27
  import { initStore } from "./shared";
@@ -37,6 +43,7 @@ export type PublishExportResult =
37
43
  success: true;
38
44
  data: {
39
45
  artifact: PublishArtifact;
46
+ assetSummary: PublishAssetEgressSummary;
40
47
  outPath: string;
41
48
  preview?: string;
42
49
  uploadUrl: string;
@@ -64,6 +71,15 @@ export async function buildDefaultPublishExportPath(
64
71
  );
65
72
  }
66
73
 
74
+ /** Write exactly the canonical byte sequence used by upload-size accounting. */
75
+ export async function writePublishArtifactFile(
76
+ outPath: string,
77
+ artifact: PublishArtifact
78
+ ): Promise<void> {
79
+ await mkdir(dirname(outPath), { recursive: true });
80
+ await Bun.write(outPath, serializePublishArtifact(artifact));
81
+ }
82
+
67
83
  export async function publishExport(
68
84
  target: string,
69
85
  options: PublishExportOptions
@@ -79,7 +95,7 @@ export async function publishExport(
79
95
  const { collections, store } = initResult;
80
96
 
81
97
  try {
82
- const { artifact, warnings } = await exportPublishArtifact({
98
+ const { artifact, assetSummary, warnings } = await exportPublishArtifact({
83
99
  collections,
84
100
  options: {
85
101
  routeSlug: options.slug,
@@ -104,6 +120,7 @@ export async function publishExport(
104
120
  success: true,
105
121
  data: {
106
122
  artifact,
123
+ assetSummary,
107
124
  outPath: "",
108
125
  preview,
109
126
  uploadUrl: "https://gno.sh/studio",
@@ -116,13 +133,13 @@ export async function publishExport(
116
133
  const outPath =
117
134
  options.out?.trim() || (await buildDefaultPublishExportPath(artifact));
118
135
 
119
- await mkdir(dirname(outPath), { recursive: true });
120
- await writeFile(outPath, JSON.stringify(artifact, null, 2));
136
+ await writePublishArtifactFile(outPath, artifact);
121
137
 
122
138
  return {
123
139
  success: true,
124
140
  data: {
125
141
  artifact,
142
+ assetSummary,
126
143
  outPath,
127
144
  uploadUrl: "https://gno.sh/studio",
128
145
  warnings,
@@ -159,19 +176,37 @@ export function formatPublishExport(
159
176
  return JSON.stringify(result.data, null, 2);
160
177
  }
161
178
 
162
- const { artifact, outPath, preview, uploadUrl, warningsDisplay } =
163
- result.data;
179
+ const {
180
+ artifact,
181
+ assetSummary,
182
+ outPath,
183
+ preview,
184
+ uploadUrl,
185
+ warningsDisplay,
186
+ } = result.data;
164
187
  const space = artifact.spaces[0];
165
188
  const warningsSection =
166
189
  warningsDisplay.length > 0
167
190
  ? ["", "Preprocessor notes:", ...warningsDisplay]
168
191
  : [];
192
+ const assetSection = [
193
+ "",
194
+ "Asset summary:",
195
+ ` assets=${assetSummary.assetCount} refs=${assetSummary.referenceCount} external=${assetSummary.externalCount}`,
196
+ ` rawBytes=${assetSummary.rawBytes} encodedBytes=${assetSummary.encodedBytes} finalBytes=${assetSummary.finalUploadBytes}`,
197
+ ` dedupSavedBytes=${assetSummary.dedupSavedBytes} unresolved=${assetSummary.diagnostics.length}`,
198
+ ...assetSummary.diagnostics.map(
199
+ (diagnostic) =>
200
+ ` [${diagnostic.code}] ${diagnostic.noteSlug}: ${diagnostic.sourceRef} — ${diagnostic.message}`
201
+ ),
202
+ ];
169
203
 
170
204
  if (preview !== undefined) {
171
205
  return [
172
206
  `Preview (no file written) — ${space?.sourceType ?? "artifact"}`,
173
207
  `Route slug: ${space?.routeSlug ?? slugify(artifact.source)}`,
174
208
  `Visibility: ${space?.visibility ?? "public"}`,
209
+ ...assetSection,
175
210
  ...warningsSection,
176
211
  "",
177
212
  "─── sanitized markdown ───",
@@ -185,6 +220,7 @@ export function formatPublishExport(
185
220
  `Visibility: ${space?.visibility ?? "public"}`,
186
221
  `Filename: ${derivePublishArtifactFilename(artifact)}`,
187
222
  `Next: open ${uploadUrl} and drop ${outPath} into the upload zone.`,
223
+ ...assetSection,
188
224
  ...warningsSection,
189
225
  ].join("\n");
190
226
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Identifies regions to exclude from link/tag extraction:
5
5
  * - YAML frontmatter
6
- * - Fenced code blocks
6
+ * - Fenced code blocks (CommonMark backtick and tilde fences)
7
7
  * - Inline code
8
8
  * - HTML comments
9
9
  *
@@ -39,15 +39,153 @@ export interface ExcludedRange {
39
39
  /** Frontmatter at start of file (YAML between --- delimiters) */
40
40
  const FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
41
41
 
42
- /** Fenced code blocks (``` with optional language) */
43
- const FENCED_CODE_REGEX = /^```[^\n]*\n[\s\S]*?^```/gm;
42
+ /** CommonMark fence opener: 0–3 spaces, then 3+ backticks or tildes + info. */
43
+ const FENCE_OPEN_REGEX = /^ {0,3}(`{3,}|~{3,})(.*)$/u;
44
44
 
45
- /** Inline code (backticks, non-greedy) */
46
- const INLINE_CODE_REGEX = /`[^`\n]+`/g;
45
+ /** CommonMark fence closer: matching character, length ≥ opener, trailing space/tabs only. */
46
+ const FENCE_CLOSE_REGEX = /^ {0,3}(`{3,}|~{3,})[\t ]*$/u;
47
47
 
48
48
  /** HTML comments */
49
49
  const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
50
50
 
51
+ interface OpenFence {
52
+ marker: "`" | "~";
53
+ length: number;
54
+ start: number;
55
+ }
56
+
57
+ /**
58
+ * Collect CommonMark fenced code ranges (backtick and tilde). A closer must
59
+ * use the same character and be at least as long as the opener; when omitted,
60
+ * CommonMark extends the fenced block through end of input.
61
+ */
62
+ const collectFencedCodeRanges = (markdown: string): ExcludedRange[] => {
63
+ const ranges: ExcludedRange[] = [];
64
+ let offset = 0;
65
+ let open: OpenFence | null = null;
66
+
67
+ while (offset <= markdown.length) {
68
+ const nextNl = markdown.indexOf("\n", offset);
69
+ const lineEnd = nextNl === -1 ? markdown.length : nextNl;
70
+ const rawLine = markdown.slice(offset, lineEnd);
71
+ const logical = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
72
+
73
+ if (open) {
74
+ const closeRun = FENCE_CLOSE_REGEX.exec(logical)?.[1];
75
+ if (
76
+ closeRun &&
77
+ closeRun[0] === open.marker &&
78
+ closeRun.length >= open.length
79
+ ) {
80
+ const end = nextNl === -1 ? markdown.length : nextNl + 1;
81
+ ranges.push({ start: open.start, end, kind: "fenced_code" });
82
+ open = null;
83
+ }
84
+ } else {
85
+ const openMatch = FENCE_OPEN_REGEX.exec(logical);
86
+ const run = openMatch?.[1];
87
+ const suffix = openMatch?.[2] ?? "";
88
+ // Backtick info strings cannot contain backticks (CommonMark).
89
+ if (run && !(run[0] === "`" && suffix.includes("`"))) {
90
+ open = {
91
+ marker: run[0] as OpenFence["marker"],
92
+ length: run.length,
93
+ start: offset,
94
+ };
95
+ }
96
+ }
97
+
98
+ if (nextNl === -1) break;
99
+ offset = nextNl + 1;
100
+ }
101
+
102
+ if (open) {
103
+ ranges.push({
104
+ start: open.start,
105
+ end: markdown.length,
106
+ kind: "fenced_code",
107
+ });
108
+ }
109
+
110
+ return ranges;
111
+ };
112
+
113
+ interface BacktickRun {
114
+ end: number;
115
+ length: number;
116
+ start: number;
117
+ }
118
+
119
+ /** CommonMark code spans close only on a backtick run of equal length. */
120
+ const collectInlineCodeRanges = (
121
+ markdown: string,
122
+ excludedRanges: ExcludedRange[]
123
+ ): ExcludedRange[] => {
124
+ const runs: BacktickRun[] = [];
125
+ let cursor = 0;
126
+ let excludedIndex = 0;
127
+ while (cursor < markdown.length) {
128
+ while (
129
+ excludedRanges[excludedIndex] &&
130
+ excludedRanges[excludedIndex]!.end <= cursor
131
+ ) {
132
+ excludedIndex += 1;
133
+ }
134
+ const excluded = excludedRanges[excludedIndex];
135
+ if (excluded && cursor >= excluded.start && cursor < excluded.end) {
136
+ cursor = excluded.end;
137
+ continue;
138
+ }
139
+ if (markdown[cursor] !== "`") {
140
+ cursor += 1;
141
+ continue;
142
+ }
143
+ const start = cursor;
144
+ while (markdown[cursor] === "`") cursor += 1;
145
+ let backslashes = 0;
146
+ for (let i = start - 1; i >= 0 && markdown[i] === "\\"; i -= 1) {
147
+ backslashes += 1;
148
+ }
149
+ if (backslashes % 2 === 0) {
150
+ runs.push({ start, end: cursor, length: cursor - start });
151
+ }
152
+ }
153
+
154
+ const nextMatchingRun = Array.from<number | undefined>({
155
+ length: runs.length,
156
+ });
157
+ const latestByLength = new Map<number, number>();
158
+ for (let index = runs.length - 1; index >= 0; index -= 1) {
159
+ const run = runs[index];
160
+ if (!run) continue;
161
+ nextMatchingRun[index] = latestByLength.get(run.length);
162
+ latestByLength.set(run.length, index);
163
+ }
164
+
165
+ const ranges: ExcludedRange[] = [];
166
+ let index = 0;
167
+ while (index < runs.length) {
168
+ const closeIndex = nextMatchingRun[index];
169
+ const opener = runs[index];
170
+ if (closeIndex === undefined || !opener) {
171
+ index += 1;
172
+ continue;
173
+ }
174
+ const closer = runs[closeIndex];
175
+ if (!closer) {
176
+ index += 1;
177
+ continue;
178
+ }
179
+ ranges.push({
180
+ start: opener.start,
181
+ end: closer.end,
182
+ kind: "inline_code",
183
+ });
184
+ index = closeIndex + 1;
185
+ }
186
+ return ranges;
187
+ };
188
+
51
189
  // ─────────────────────────────────────────────────────────────────────────────
52
190
  // Main Functions
53
191
  // ─────────────────────────────────────────────────────────────────────────────
@@ -70,29 +208,12 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
70
208
  });
71
209
  }
72
210
 
73
- // 2. Fenced code blocks
74
- FENCED_CODE_REGEX.lastIndex = 0;
75
- let match: RegExpExecArray | null;
76
- while ((match = FENCED_CODE_REGEX.exec(markdown)) !== null) {
77
- ranges.push({
78
- start: match.index,
79
- end: match.index + match[0].length,
80
- kind: "fenced_code",
81
- });
82
- }
83
-
84
- // 3. Inline code
85
- INLINE_CODE_REGEX.lastIndex = 0;
86
- while ((match = INLINE_CODE_REGEX.exec(markdown)) !== null) {
87
- ranges.push({
88
- start: match.index,
89
- end: match.index + match[0].length,
90
- kind: "inline_code",
91
- });
92
- }
211
+ // 2. Fenced code blocks (backtick + tilde, CommonMark matching rules)
212
+ ranges.push(...collectFencedCodeRanges(markdown));
93
213
 
94
- // 4. HTML comments
214
+ // 3. HTML comments
95
215
  HTML_COMMENT_REGEX.lastIndex = 0;
216
+ let match: RegExpExecArray | null;
96
217
  while ((match = HTML_COMMENT_REGEX.exec(markdown)) !== null) {
97
218
  ranges.push({
98
219
  start: match.index,
@@ -101,6 +222,11 @@ export function getExcludedRanges(markdown: string): ExcludedRange[] {
101
222
  });
102
223
  }
103
224
 
225
+ // 4. Inline code. Pair delimiters only in visible prose so unmatched
226
+ // backticks inside already-excluded blocks cannot consume later content.
227
+ ranges.sort((a, b) => a.start - b.start);
228
+ ranges.push(...collectInlineCodeRanges(markdown, ranges));
229
+
104
230
  // Sort by start position for efficient lookup
105
231
  ranges.sort((a, b) => a.start - b.start);
106
232
 
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Synchronous SHA-256 + base64 helpers for publish assets.
3
+ * Bun.CryptoHasher for digests; browser-safe atob for base64 decode parity with gno.sh.
4
+ *
5
+ * @module src/publish/artifact-asset-codec
6
+ */
7
+
8
+ /** Synchronous SHA-256 hex digest over raw bytes (Bun-native). */
9
+ export const sha256BytesHex = (bytes: Uint8Array): string => {
10
+ const hasher = new Bun.CryptoHasher("sha256");
11
+ hasher.update(bytes);
12
+ return hasher.digest("hex");
13
+ };
14
+
15
+ const BASE64_PATTERN =
16
+ /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/u;
17
+
18
+ /**
19
+ * Encode raw bytes to standard base64 without Node Buffer.
20
+ * Prefers Bun/Web Uint8Array.toBase64(); falls back to btoa for parity with decode.
21
+ */
22
+ export const encodeBytesToBase64 = (bytes: Uint8Array): string => {
23
+ if (typeof bytes.toBase64 === "function") {
24
+ return bytes.toBase64();
25
+ }
26
+ const CHUNK = 0x8000;
27
+ let binary = "";
28
+ for (let offset = 0; offset < bytes.length; offset += CHUNK) {
29
+ const slice = bytes.subarray(offset, offset + CHUNK);
30
+ binary += String.fromCharCode(...slice);
31
+ }
32
+ return btoa(binary);
33
+ };
34
+
35
+ /** Decode standard base64 without Buffer (uses atob). */
36
+ export const decodeBase64ToBytes = (
37
+ data: string
38
+ ): { ok: true; bytes: Uint8Array } | { ok: false } => {
39
+ if (!BASE64_PATTERN.test(data) || data.length === 0) return { ok: false };
40
+ try {
41
+ const binary = atob(data);
42
+ if (binary.length === 0 && data.replace(/=+$/u, "").length > 0) {
43
+ return { ok: false };
44
+ }
45
+ const bytes = new Uint8Array(binary.length);
46
+ for (let i = 0; i < binary.length; i += 1) {
47
+ bytes[i] = binary.charCodeAt(i);
48
+ }
49
+ return { ok: true, bytes };
50
+ } catch {
51
+ return { ok: false };
52
+ }
53
+ };
54
+
55
+ const UTF8 = new TextEncoder();
56
+
57
+ export const measureSerializedUploadBytes = (serializedBody: string): number =>
58
+ UTF8.encode(serializedBody).byteLength;
59
+
60
+ /**
61
+ * Canonical on-disk/upload serialization for publish artifacts.
62
+ *
63
+ * Byte-budget enforcement and every artifact writer must use this exact
64
+ * representation so `finalUploadBytes` describes the bytes handed to gno.sh.
65
+ */
66
+ export const serializePublishArtifact = (artifact: unknown): string => {
67
+ const serialized = JSON.stringify(artifact, null, 2);
68
+ if (serialized === undefined) {
69
+ throw new TypeError("Publish artifact is not JSON-serializable");
70
+ }
71
+ return serialized;
72
+ };
73
+
74
+ export const measureArtifactUploadBytes = (artifact: unknown): number =>
75
+ measureSerializedUploadBytes(serializePublishArtifact(artifact));