@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 +15 -0
- package/jest.config.mjs +1 -0
- package/package.json +1 -1
- package/src/kreuzberg.js +13 -1
- package/tests/ci/detect-code-changes.test.js +174 -0
- package/tests/ci/rust-dockerfile.test.js +54 -0
- package/tests/ci/workflow-policy.test.js +63 -0
- package/tests/unit/kreuzberg.test.js +40 -0
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
package/package.json
CHANGED
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
|
-
|
|
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
|
+
});
|