@link-assistant/web-capture 1.10.7 → 1.10.9

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,20 @@
1
1
  # @link-assistant/web-capture
2
2
 
3
+ ## 1.10.9
4
+
5
+ ### Patch Changes
6
+
7
+ - Auto-release unreleased changes
8
+
9
+ ## 1.10.8
10
+
11
+ ### Patch Changes
12
+
13
+ - 8cee5e4: Keep JS/Rust kreuzberg parity for the html-to-markdown 3.6 image dimensions
14
+ change (issue #137). Export `normalizeStructuredKeys` and add regression tests
15
+ asserting inline image `dimensions` are exposed as `{ width, height }`, mirroring
16
+ the Rust `inline_image_to_json` fix.
17
+
3
18
  ## 1.10.7
4
19
 
5
20
  ### Patch Changes
package/jest.config.mjs CHANGED
@@ -9,6 +9,7 @@ export default {
9
9
  testEnvironment: 'node',
10
10
  verbose: true,
11
11
  testMatch: [
12
+ '**/tests/ci/**/*.test.js',
12
13
  '**/tests/unit/**/*.test.js',
13
14
  '**/tests/mock/**/*.test.js',
14
15
  '**/tests/e2e/**/*.test.js',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/web-capture",
3
- "version": "1.10.7",
3
+ "version": "1.10.9",
4
4
  "description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/kreuzberg.js CHANGED
@@ -39,7 +39,19 @@ function toSnakeCase(key) {
39
39
  return key.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`);
40
40
  }
41
41
 
42
- function normalizeStructuredKeys(value) {
42
+ /**
43
+ * Recursively normalize object keys to snake_case.
44
+ *
45
+ * Used to give the structured `metadata`, `tables`, and `images` payloads a
46
+ * stable shape regardless of the casing the native binding emits. In
47
+ * particular, inline image `dimensions` are exposed as `{ width, height }` —
48
+ * mirroring the Rust `inline_image_to_json` mapping after the
49
+ * html-to-markdown 3.6 `ImageDimensions` change (see issue #137).
50
+ *
51
+ * @param {*} value - Value to normalize
52
+ * @returns {*} The value with all object keys converted to snake_case
53
+ */
54
+ export function normalizeStructuredKeys(value) {
43
55
  if (Array.isArray(value)) {
44
56
  return value.map(normalizeStructuredKeys);
45
57
  }
@@ -0,0 +1,174 @@
1
+ import { execFileSync } from 'child_process';
2
+ import {
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'fs';
9
+ import { tmpdir } from 'os';
10
+ import { dirname, join, resolve } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const repoRoot = resolve(__dirname, '../../..');
16
+ const detectorPath = resolve(repoRoot, 'scripts/detect-code-changes.mjs');
17
+
18
+ function run(command, args, cwd, options = {}) {
19
+ return execFileSync(command, args, {
20
+ cwd,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'pipe'],
23
+ ...options,
24
+ });
25
+ }
26
+
27
+ function git(repoPath, args) {
28
+ return run('git', args, repoPath);
29
+ }
30
+
31
+ function writeTrackedFile(repoPath, relativePath, contents) {
32
+ const filePath = join(repoPath, relativePath);
33
+ mkdirSync(dirname(filePath), { recursive: true });
34
+ writeFileSync(filePath, contents);
35
+ }
36
+
37
+ function commitAll(repoPath, message) {
38
+ git(repoPath, ['add', '.']);
39
+ git(repoPath, ['commit', '-m', message]);
40
+ }
41
+
42
+ function createMergedFeatureRepo() {
43
+ const repoPath = mkdtempSync(join(tmpdir(), 'web-capture-detect-'));
44
+
45
+ initializeRepo(repoPath);
46
+
47
+ writeTrackedFile(repoPath, 'README.md', '# fixture\n');
48
+ commitAll(repoPath, 'initial commit');
49
+
50
+ git(repoPath, ['checkout', '-b', 'feature']);
51
+ writeTrackedFile(repoPath, 'rust/src/lib.rs', 'pub fn capture() {}\n');
52
+ commitAll(repoPath, 'add rust code');
53
+
54
+ writeTrackedFile(repoPath, 'docs/notes.md', '# docs only tail commit\n');
55
+ commitAll(repoPath, 'document rust change');
56
+
57
+ git(repoPath, ['checkout', 'main']);
58
+ git(repoPath, ['merge', '--no-ff', 'feature', '-m', 'merge feature']);
59
+
60
+ return repoPath;
61
+ }
62
+
63
+ function createSingleCommitRepo() {
64
+ return createSingleCommitRepoWithFiles({
65
+ 'js/src/index.js': 'export const ok = true;\n',
66
+ });
67
+ }
68
+
69
+ function createSingleCommitRepoWithFiles(files) {
70
+ const repoPath = mkdtempSync(join(tmpdir(), 'web-capture-detect-'));
71
+
72
+ initializeRepo(repoPath);
73
+ for (const [relativePath, contents] of Object.entries(files)) {
74
+ writeTrackedFile(repoPath, relativePath, contents);
75
+ }
76
+ commitAll(repoPath, 'initial commit');
77
+
78
+ return repoPath;
79
+ }
80
+
81
+ function initializeRepo(repoPath) {
82
+ git(repoPath, ['init']);
83
+ git(repoPath, ['config', 'user.email', 'ci@example.test']);
84
+ git(repoPath, ['config', 'user.name', 'CI Test']);
85
+ git(repoPath, ['checkout', '-b', 'main']);
86
+ }
87
+
88
+ function parseOutputs(outputFile) {
89
+ return Object.fromEntries(
90
+ readFileSync(outputFile, 'utf8')
91
+ .trim()
92
+ .split('\n')
93
+ .filter(Boolean)
94
+ .map((line) => {
95
+ const separatorIndex = line.indexOf('=');
96
+ return [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
97
+ })
98
+ );
99
+ }
100
+
101
+ function runDetector(repoPath, eventName) {
102
+ const outputFile = join(repoPath, `github-output-${eventName}.txt`);
103
+ const env = {
104
+ ...process.env,
105
+ GITHUB_EVENT_NAME: eventName,
106
+ GITHUB_OUTPUT: outputFile,
107
+ };
108
+ delete env.GITHUB_BASE_SHA;
109
+ delete env.GITHUB_HEAD_SHA;
110
+
111
+ run(process.execPath, [detectorPath], repoPath, { env });
112
+
113
+ return parseOutputs(outputFile);
114
+ }
115
+
116
+ describe('detect-code-changes', () => {
117
+ let repoPath;
118
+
119
+ afterEach(() => {
120
+ if (repoPath) {
121
+ rmSync(repoPath, { recursive: true, force: true });
122
+ repoPath = undefined;
123
+ }
124
+ });
125
+
126
+ test('detects code introduced by a real merge commit pushed to main', () => {
127
+ repoPath = createMergedFeatureRepo();
128
+
129
+ const outputs = runDetector(repoPath, 'push');
130
+
131
+ expect(outputs['rust-code-changed']).toBe('true');
132
+ expect(outputs['rust-changed']).toBe('true');
133
+ expect(outputs['any-rust-code-changed']).toBe('true');
134
+ expect(outputs['any-code-changed']).toBe('true');
135
+ expect(outputs['docs-changed']).toBe('true');
136
+ });
137
+
138
+ test('keeps pull request merge commits scoped to the PR head commit', () => {
139
+ repoPath = createMergedFeatureRepo();
140
+
141
+ const outputs = runDetector(repoPath, 'pull_request');
142
+
143
+ expect(outputs['rust-code-changed']).toBe('false');
144
+ expect(outputs['rust-changed']).toBe('false');
145
+ expect(outputs['any-rust-code-changed']).toBe('false');
146
+ expect(outputs['any-code-changed']).toBe('false');
147
+ expect(outputs['docs-changed']).toBe('true');
148
+ });
149
+
150
+ test('detects files when a push only has one commit', () => {
151
+ repoPath = createSingleCommitRepo();
152
+
153
+ const outputs = runDetector(repoPath, 'push');
154
+
155
+ expect(outputs['js-code-changed']).toBe('true');
156
+ expect(outputs['js-changed']).toBe('true');
157
+ expect(outputs['any-js-code-changed']).toBe('true');
158
+ expect(outputs['any-code-changed']).toBe('true');
159
+ });
160
+
161
+ test('runs JS checks for CI tests without requiring a changeset', () => {
162
+ repoPath = createSingleCommitRepoWithFiles({
163
+ 'js/jest.config.mjs': 'export default {};\n',
164
+ 'js/tests/ci/workflow-policy.test.js': 'test("ok", () => {});\n',
165
+ });
166
+
167
+ const outputs = runDetector(repoPath, 'push');
168
+
169
+ expect(outputs['js-code-changed']).toBe('false');
170
+ expect(outputs['js-changed']).toBe('true');
171
+ expect(outputs['any-js-code-changed']).toBe('true');
172
+ expect(outputs['any-code-changed']).toBe('true');
173
+ });
174
+ });
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const repoRoot = resolve(__dirname, '../../..');
8
+
9
+ function parseVersion(version) {
10
+ return version.split('.').map((part) => Number(part));
11
+ }
12
+
13
+ function compareVersions(left, right) {
14
+ const leftParts = parseVersion(left);
15
+ const rightParts = parseVersion(right);
16
+ const maxLength = Math.max(leftParts.length, rightParts.length);
17
+
18
+ for (let index = 0; index < maxLength; index += 1) {
19
+ const leftPart = leftParts[index] || 0;
20
+ const rightPart = rightParts[index] || 0;
21
+
22
+ if (leftPart !== rightPart) {
23
+ return leftPart - rightPart;
24
+ }
25
+ }
26
+
27
+ return 0;
28
+ }
29
+
30
+ describe('Rust Dockerfile', () => {
31
+ test('uses a Rust builder image that satisfies Cargo.toml rust-version', () => {
32
+ const cargoToml = readFileSync(
33
+ resolve(repoRoot, 'rust/Cargo.toml'),
34
+ 'utf8'
35
+ );
36
+ const dockerfile = readFileSync(
37
+ resolve(repoRoot, 'rust/Dockerfile'),
38
+ 'utf8'
39
+ );
40
+
41
+ const requiredRustVersion = cargoToml.match(
42
+ /^rust-version = "([^"]+)"/m
43
+ )?.[1];
44
+ const dockerRustVersion = dockerfile.match(
45
+ /^FROM rust:(\d+(?:\.\d+){1,2})[-\s]/m
46
+ )?.[1];
47
+
48
+ expect(requiredRustVersion).toBeDefined();
49
+ expect(dockerRustVersion).toBeDefined();
50
+ expect(
51
+ compareVersions(dockerRustVersion, requiredRustVersion)
52
+ ).toBeGreaterThanOrEqual(0);
53
+ });
54
+ });
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const repoRoot = resolve(__dirname, '../../..');
8
+
9
+ const workflowFiles = [
10
+ '.github/workflows/js.yml',
11
+ '.github/workflows/rust.yml',
12
+ '.github/workflows/parity.yml',
13
+ ];
14
+
15
+ function readRootFile(relativePath) {
16
+ return readFileSync(resolve(repoRoot, relativePath), 'utf8');
17
+ }
18
+
19
+ function readWorkflow(relativePath) {
20
+ return readRootFile(relativePath);
21
+ }
22
+
23
+ describe('workflow policy', () => {
24
+ test.each(workflowFiles)(
25
+ '%s uses release-safe concurrency cancellation',
26
+ (workflowFile) => {
27
+ const workflow = readWorkflow(workflowFile);
28
+
29
+ expect(workflow).toContain(
30
+ "cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}"
31
+ );
32
+ expect(workflow).not.toContain(
33
+ "cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}"
34
+ );
35
+ }
36
+ );
37
+
38
+ test.each(workflowFiles)(
39
+ '%s uses the current checkout action',
40
+ (workflowFile) => {
41
+ const workflow = readWorkflow(workflowFile);
42
+
43
+ expect(workflow).toContain('uses: actions/checkout@v6');
44
+ expect(workflow).not.toContain('uses: actions/checkout@v4');
45
+ }
46
+ );
47
+
48
+ test('Rust workflow uses the current cache action', () => {
49
+ const workflow = readWorkflow('.github/workflows/rust.yml');
50
+
51
+ expect(workflow).toContain('uses: actions/cache@v5');
52
+ expect(workflow).not.toContain('uses: actions/cache@v4');
53
+ });
54
+
55
+ test('parity check ignores repository-level CI tests', () => {
56
+ const parityScript = readRootFile('scripts/check-js-rust-parity.mjs');
57
+
58
+ expect(parityScript).toContain('"js/tests/ci/"');
59
+ expect(parityScript).toContain(
60
+ 'matchingFiles(files, JS_PARITY_PATHS, JS_PARITY_IGNORE_PATHS)'
61
+ );
62
+ });
63
+ });
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  convertWithKreuzberg,
3
3
  isKreuzbergAvailable,
4
+ normalizeStructuredKeys,
4
5
  } from '../../src/kreuzberg.js';
5
6
 
6
7
  let available;
@@ -207,3 +208,42 @@ describe('kreuzberg html-to-markdown integration', () => {
207
208
  expect(result.content).toContain('Item 3');
208
209
  });
209
210
  });
211
+
212
+ describe('kreuzberg structured key normalization (issue #137 parity)', () => {
213
+ // Mirrors the Rust `inline_image_to_json` regression tests: after the
214
+ // html-to-markdown 3.6 change, inline image dimensions are a structured
215
+ // `{ width, height }` value rather than a tuple. The normalization pipeline
216
+ // that backs `result.images` must preserve those `width`/`height` keys.
217
+ it('preserves width/height keys on inline image dimensions', () => {
218
+ const images = normalizeStructuredKeys([
219
+ {
220
+ format: 'png',
221
+ filename: 'pixel.png',
222
+ description: 'one pixel',
223
+ dimensions: { width: 800, height: 600 },
224
+ source: 'ImgDataUri',
225
+ },
226
+ ]);
227
+
228
+ expect(images).toHaveLength(1);
229
+ expect(images[0].dimensions).toEqual({ width: 800, height: 600 });
230
+ expect(images[0].format).toBe('png');
231
+ expect(images[0].filename).toBe('pixel.png');
232
+ });
233
+
234
+ it('leaves images without dimensions untouched', () => {
235
+ const images = normalizeStructuredKeys([
236
+ { format: 'svg', source: 'SvgElement', dimensions: null },
237
+ ]);
238
+
239
+ expect(images[0].dimensions).toBeNull();
240
+ });
241
+
242
+ it('snake_cases nested structured keys', () => {
243
+ const normalized = normalizeStructuredKeys({
244
+ openGraph: { siteName: 'Example' },
245
+ });
246
+
247
+ expect(normalized).toEqual({ open_graph: { site_name: 'Example' } });
248
+ });
249
+ });