@link-assistant/web-capture 1.7.25 → 1.7.27

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.7.27
4
+
5
+ ### Patch Changes
6
+
7
+ - 9cbb4c7: Pin the default `--format archive` layout contract so the zip always contains exactly `document.md` + `document.html` + `images/` (issue #113). The markdown-format archive endpoint previously bundled only `document.md` and `images/`, omitting the reference `document.html`; it now includes it on every path. Adds a reusable `buildArchiveFromHtml(html, baseUrl)` helper and an integration test that pins the layout so a future refactor cannot regress it.
8
+
9
+ ## 1.7.26
10
+
11
+ ### Patch Changes
12
+
13
+ - 112f2f9: Fix `stripBase64Images` (used by `--keep-original-links`) dropping every base64 image with empty alt text instead of leaving a visible placeholder. Google Docs HTML exports emit `<img alt="" src="data:image/png;base64,...">` for every image, so the previous behaviour silently deleted all images from the rendered markdown. Empty-alt now renders as `![]()` (a valid empty image reference); non-empty alt continues to render as `*[image: <alt>]*`.
14
+
3
15
  ## 1.7.25
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.7.25",
3
+ "version": "1.7.27",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -65,7 +65,8 @@
65
65
  "markdown-it": "^14.1.1",
66
66
  "nock": "^13.5.4",
67
67
  "prettier": "^3.6.2",
68
- "supertest": "^6.3.4"
68
+ "supertest": "^6.3.4",
69
+ "unzipper": "^0.12.3"
69
70
  },
70
71
  "type": "module",
71
72
  "keywords": [
package/src/archive.js CHANGED
@@ -170,14 +170,17 @@ export async function archiveHandler(req, res) {
170
170
  }
171
171
 
172
172
  if (!embedImages && !keepOriginalLinks) {
173
- const b64 = extractBase64ToBuffers(markdown, 'images');
174
- markdown = b64.markdown;
175
- for (const img of b64.images) {
176
- archive.append(img.buffer, { name: `images/${img.filename}` });
177
- }
173
+ appendMarkdownAndImages(archive, markdown);
174
+ } else {
175
+ archive.append(markdown, { name: 'document.md' });
178
176
  }
179
177
 
180
- archive.append(markdown, { name: 'document.md' });
178
+ // document.html — the source the markdown was derived from, for
179
+ // reference only, so the default archive layout (document.md +
180
+ // document.html + images/) is identical across capture paths (issue #113).
181
+ archive.append(prettyPrintHtml(convertRelativeUrls(html, absoluteUrl)), {
182
+ name: 'document.html',
183
+ });
181
184
  }
182
185
 
183
186
  // Download and add images if local mode
@@ -207,6 +210,57 @@ export async function archiveHandler(req, res) {
207
210
  }
208
211
  }
209
212
 
213
+ /**
214
+ * Extract base64 images from `markdown` into the archive's `images/` folder and
215
+ * append the rewritten markdown as `document.md`. Shared by the archive
216
+ * endpoint and {@link buildArchiveFromHtml} so the default layout stays
217
+ * identical across capture paths.
218
+ *
219
+ * @param {import('archiver').Archiver} archive - The archive being built
220
+ * @param {string} markdown - Markdown content (may contain base64 data URIs)
221
+ */
222
+ function appendMarkdownAndImages(archive, markdown) {
223
+ const { markdown: rewritten, images } = extractBase64ToBuffers(
224
+ markdown,
225
+ 'images'
226
+ );
227
+ for (const img of images) {
228
+ archive.append(img.buffer, { name: `images/${img.filename}` });
229
+ }
230
+ archive.append(rewritten, { name: 'document.md' });
231
+ }
232
+
233
+ /**
234
+ * Build a self-contained ZIP archive (as a Buffer) from raw HTML, matching the
235
+ * default `--format archive` layout contract (issue #113):
236
+ *
237
+ * - `document.md` — markdown referencing images by relative `images/` path
238
+ * - `document.html` — the source HTML the markdown was derived from (reference)
239
+ * - `images/` — every inline base64 image as a separate file
240
+ *
241
+ * @param {string} html - Source HTML to convert
242
+ * @param {string} baseUrl - Base URL for resolving relative links
243
+ * @returns {Promise<Buffer>} the finalized ZIP archive bytes
244
+ */
245
+ export async function buildArchiveFromHtml(html, baseUrl) {
246
+ const archive = archiver('zip', { zlib: { level: 9 } });
247
+ const chunks = [];
248
+ archive.on('data', (chunk) => chunks.push(chunk));
249
+ const finished = new Promise((resolve, reject) => {
250
+ archive.on('end', resolve);
251
+ archive.on('error', reject);
252
+ });
253
+
254
+ appendMarkdownAndImages(archive, convertHtmlToMarkdown(html, baseUrl));
255
+ archive.append(prettyPrintHtml(convertRelativeUrls(html, baseUrl)), {
256
+ name: 'document.html',
257
+ });
258
+
259
+ await archive.finalize();
260
+ await finished;
261
+ return Buffer.concat(chunks);
262
+ }
263
+
210
264
  function guessImageExtension(url) {
211
265
  const pathname = new URL(url).pathname.toLowerCase();
212
266
  if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) {
@@ -95,9 +95,16 @@ export function extractBase64ToBuffers(markdown, imagesDir = 'images') {
95
95
  }
96
96
 
97
97
  /**
98
- * Strip base64 data URI images from markdown, leaving only the alt text
99
- * as a placeholder. Used when keepOriginalLinks is enabled — base64 images
100
- * have no original URL to restore, so we remove the heavy data URI.
98
+ * Strip base64 data URI images from markdown, leaving a visible placeholder.
99
+ * Used when keepOriginalLinks is enabled — base64 images have no original URL
100
+ * to restore, so we remove the heavy data URI but still leave a marker so the
101
+ * reader can see that an image was here.
102
+ *
103
+ * Non-empty alt becomes `*[image: <alt>]*`. Empty alt — common for Google Docs
104
+ * HTML exports, which emit `<img alt="" src="data:...">` for every image —
105
+ * becomes `![]()`, an empty markdown image reference that renderers still
106
+ * surface as a slot. Emitting `''` for empty-alt would silently delete every
107
+ * image in the document (see issue #117).
101
108
  *
102
109
  * @param {string} markdown - Markdown content with data URI images
103
110
  * @returns {{markdown: string, stripped: number}}
@@ -108,7 +115,7 @@ export function stripBase64Images(markdown) {
108
115
  BASE64_IMAGE_REGEX,
109
116
  (_match, altText) => {
110
117
  stripped++;
111
- return altText ? `*[image: ${altText}]*` : '';
118
+ return altText ? `*[image: ${altText}]*` : '![]()';
112
119
  }
113
120
  );
114
121
  return { markdown: updatedMarkdown, stripped };
@@ -9,6 +9,7 @@
9
9
  import { jest } from '@jest/globals';
10
10
  import request from 'supertest';
11
11
  import nock from 'nock';
12
+ import unzipper from 'unzipper';
12
13
  import { app } from '../../src/index.js';
13
14
 
14
15
  jest.setTimeout(60000);
@@ -95,6 +96,14 @@ describe('API Endpoint Tests', () => {
95
96
  // ZIP signature: PK (0x50 0x4B)
96
97
  expect(res.body[0]).toBe(0x50);
97
98
  expect(res.body[1]).toBe(0x4b);
99
+
100
+ // Default archive layout contract (issue #113): the markdown archive must
101
+ // bundle document.md AND document.html for reference.
102
+ const dir = await unzipper.Open.buffer(res.body);
103
+ const names = dir.files.map((f) => f.path);
104
+ expect(names).toEqual(
105
+ expect.arrayContaining(['document.md', 'document.html'])
106
+ );
98
107
  });
99
108
 
100
109
  it('returns a ZIP archive with HTML format', async () => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pin the default `--format archive` layout contract (issue #113):
3
+ * the zip contains exactly `document.md` + `document.html` + `images/`.
4
+ */
5
+ import unzipper from 'unzipper';
6
+ import { buildArchiveFromHtml } from '../../src/archive.js';
7
+
8
+ const TINY_PNG =
9
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
10
+ const FIXTURE_HTML = `<!doctype html><html><body>
11
+ <h1>Hi</h1><p>Para 1.</p>
12
+ <p><img alt="" src="data:image/png;base64,${TINY_PNG}"></p>
13
+ <p>Para 2.</p>
14
+ </body></html>`;
15
+
16
+ it('default archive contains document.md, document.html, and images/ folder', async () => {
17
+ const buf = await buildArchiveFromHtml(
18
+ FIXTURE_HTML,
19
+ 'https://example.invalid/'
20
+ );
21
+ const dir = await unzipper.Open.buffer(buf);
22
+ const names = dir.files.map((f) => f.path);
23
+
24
+ expect(names).toEqual(
25
+ expect.arrayContaining(['document.md', 'document.html'])
26
+ );
27
+ expect(names.some((n) => n.startsWith('images/'))).toBe(true);
28
+
29
+ const mdEntry = dir.files.find((f) => f.path === 'document.md');
30
+ const md = (await mdEntry.buffer()).toString();
31
+ expect(md).toMatch(/images\//);
32
+ expect(md).not.toMatch(/data:image/);
33
+
34
+ const htmlEntry = dir.files.find((f) => f.path === 'document.html');
35
+ const html = (await htmlEntry.buffer()).toString();
36
+ expect(html).toMatch(/<h1>/);
37
+ });
@@ -204,12 +204,18 @@ describe('extract-images module', () => {
204
204
  expect(result.markdown).not.toContain('data:image');
205
205
  });
206
206
 
207
- it('strips base64 images with empty alt text', () => {
208
- const md = `![](data:image/png;base64,${TINY_PNG})`;
207
+ it('leaves a visible placeholder when stripping a base64 image with empty alt', () => {
208
+ // Google Docs HTML exports emit `<img alt="" src="data:image/png;base64,...">`,
209
+ // which renders as `![](data:...)`. If stripping drops the markdown image
210
+ // entirely, every image in the doc vanishes from the output with no
211
+ // indication that anything was lost. Leave a visible placeholder so the
212
+ // reader can see that an image was here.
213
+ const md = `Hi.\n\n![](data:image/png;base64,${TINY_PNG})\n\nBye.\n`;
209
214
  const result = stripBase64Images(md);
210
215
 
211
216
  expect(result.stripped).toBe(1);
212
- expect(result.markdown).toBe('');
217
+ expect(result.markdown).not.toMatch(/data:image/);
218
+ expect(result.markdown).toMatch(/!\[|\[image/);
213
219
  });
214
220
 
215
221
  it('preserves remote image URLs', () => {