@link-assistant/web-capture 1.1.2 → 1.7.1
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 +57 -13
- package/Dockerfile +26 -21
- package/README.md +290 -160
- package/bin/web-capture.js +622 -17
- package/package.json +15 -4
- package/src/animation.js +283 -0
- package/src/archive.js +225 -0
- package/src/batch.js +180 -0
- package/src/browser.js +150 -111
- package/src/docx.js +182 -0
- package/src/extract-images.js +120 -0
- package/src/figures.js +208 -0
- package/src/gdocs.js +335 -0
- package/src/image.js +65 -8
- package/src/index.js +15 -0
- package/src/latex.js +127 -0
- package/src/lib.js +281 -1
- package/src/localize-images.js +202 -0
- package/src/markdown.js +7 -2
- package/src/metadata.js +333 -0
- package/src/pdf.js +76 -0
- package/src/popups.js +171 -0
- package/src/postprocess.js +263 -0
- package/src/retry.js +48 -0
- package/src/themed-image.js +167 -0
- package/src/verify.js +312 -0
- package/tests/e2e/docker.test.js +51 -12
- package/tests/e2e/process.test.js +34 -10
- package/tests/fixtures/habr-articles.js +45 -0
- package/tests/integration/api-endpoints.test.js +231 -0
- package/tests/integration/habr-article.test.js +337 -0
- package/tests/mock/index.test.js +5 -3
- package/tests/unit/animation.test.js +33 -0
- package/tests/unit/batch.test.js +114 -0
- package/tests/unit/browser.test.js +38 -2
- package/tests/unit/extract-images.test.js +233 -0
- package/tests/unit/figures.test.js +74 -0
- package/tests/unit/gdocs.test.js +278 -0
- package/tests/unit/heading-image.test.js +49 -0
- package/tests/unit/latex.test.js +123 -0
- package/tests/unit/localize-images.test.js +78 -0
- package/tests/unit/metadata.test.js +140 -0
- package/tests/unit/postprocess.test.js +110 -0
- package/tests/unit/retry.test.js +111 -0
- package/tests/unit/verify.test.js +182 -0
- package/.github/workflows/release.yml +0 -294
- package/ARCHITECTURE.md +0 -982
- package/LICENSE +0 -24
- package/apply.sh +0 -166
- package/docs/market-analysis-2025-05-13.md +0 -64
- package/scripts/changeset-version.mjs +0 -38
- package/scripts/create-github-release.mjs +0 -93
- package/scripts/create-manual-changeset.mjs +0 -88
- package/scripts/format-github-release.mjs +0 -83
- package/scripts/format-release-notes.mjs +0 -218
- package/scripts/instant-version-bump.mjs +0 -121
- package/scripts/publish-to-npm.mjs +0 -128
- package/scripts/setup-npm.mjs +0 -37
- package/scripts/validate-changeset.mjs +0 -106
- package/scripts/version-and-commit.mjs +0 -237
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
postProcessMarkdown,
|
|
3
|
+
applyUnicodeNormalization,
|
|
4
|
+
applyLatexSpacingFixes,
|
|
5
|
+
applyPercentSignFix,
|
|
6
|
+
applyBoldFormattingFixes,
|
|
7
|
+
} from '../../src/postprocess.js';
|
|
8
|
+
|
|
9
|
+
describe('postprocess module', () => {
|
|
10
|
+
describe('applyUnicodeNormalization', () => {
|
|
11
|
+
it('preserves non-breaking spaces as entities', () => {
|
|
12
|
+
expect(applyUnicodeNormalization('hello\u00A0world')).toBe(
|
|
13
|
+
'hello world'
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('normalizes curly quotes to straight quotes', () => {
|
|
18
|
+
expect(applyUnicodeNormalization('\u2018hello\u2019')).toBe("'hello'");
|
|
19
|
+
expect(applyUnicodeNormalization('\u201Chello\u201D')).toBe('"hello"');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('normalizes en-dash to hyphen', () => {
|
|
23
|
+
expect(applyUnicodeNormalization('2\u20133')).toBe('2-3');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('normalizes ellipsis', () => {
|
|
27
|
+
expect(applyUnicodeNormalization('wait\u2026')).toBe('wait...');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('applyLatexSpacingFixes', () => {
|
|
32
|
+
it('adds space before formula after word character', () => {
|
|
33
|
+
const result = applyLatexSpacingFixes('where$x$is');
|
|
34
|
+
expect(result).toBe('where $x$ is');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('trims whitespace inside formula delimiters', () => {
|
|
38
|
+
const result = applyLatexSpacingFixes('$ x + y $');
|
|
39
|
+
expect(result).toBe('$x + y$');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not modify block formulas', () => {
|
|
43
|
+
const result = applyLatexSpacingFixes('$$x + y$$');
|
|
44
|
+
expect(result).toBe('$$x + y$$');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does not modify blockquote block formulas', () => {
|
|
48
|
+
const result = applyLatexSpacingFixes('> $$x + y$$');
|
|
49
|
+
expect(result).toBe('> $$x + y$$');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('handles multiple formulas on one line', () => {
|
|
53
|
+
const result = applyLatexSpacingFixes('$a$and$b$');
|
|
54
|
+
expect(result).toBe('$a$ and $b$');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('applyPercentSignFix', () => {
|
|
59
|
+
it('fixes percent sign in inline formulas', () => {
|
|
60
|
+
const result = applyPercentSignFix('$50\\%$');
|
|
61
|
+
expect(result).toBe('$50\\\\%$');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('fixes \\text{%} notation', () => {
|
|
65
|
+
const result = applyPercentSignFix('$50\\text{%}$');
|
|
66
|
+
expect(result).toBe('$50\\\\%$');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('applyBoldFormattingFixes', () => {
|
|
71
|
+
it('removes empty bold markers', () => {
|
|
72
|
+
const result = applyBoldFormattingFixes('hello ** ** world');
|
|
73
|
+
expect(result).toBe('hello world');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('trims content inside bold markers', () => {
|
|
77
|
+
const result = applyBoldFormattingFixes('** hello **');
|
|
78
|
+
expect(result).toBe('**hello**');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('adds space between word and bold', () => {
|
|
82
|
+
const result = applyBoldFormattingFixes('word**bold**next');
|
|
83
|
+
expect(result).toBe('word **bold** next');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('postProcessMarkdown', () => {
|
|
88
|
+
it('applies all transformations by default', () => {
|
|
89
|
+
const input = 'hello\u00A0$x$world';
|
|
90
|
+
const result = postProcessMarkdown(input);
|
|
91
|
+
expect(result).toContain('hello');
|
|
92
|
+
expect(result).toContain('$x$');
|
|
93
|
+
expect(result).not.toContain('\u00A0');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('respects option flags', () => {
|
|
97
|
+
const input = 'hello\u00A0world';
|
|
98
|
+
const result = postProcessMarkdown(input, {
|
|
99
|
+
normalizeUnicode: false,
|
|
100
|
+
});
|
|
101
|
+
expect(result).toContain('\u00A0');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('removes stray standalone $ signs', () => {
|
|
105
|
+
const input = 'line1\n$\nline2';
|
|
106
|
+
const result = postProcessMarkdown(input);
|
|
107
|
+
expect(result).toBe('line1\n\nline2');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { retry } from '../../src/retry.js';
|
|
3
|
+
|
|
4
|
+
jest.setTimeout(30000);
|
|
5
|
+
|
|
6
|
+
describe('retry', () => {
|
|
7
|
+
it('returns result on first success', async () => {
|
|
8
|
+
const fn = jest.fn().mockResolvedValue('ok');
|
|
9
|
+
const result = await retry(fn, { retries: 3 });
|
|
10
|
+
expect(result).toBe('ok');
|
|
11
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('retries on failure and succeeds eventually', async () => {
|
|
15
|
+
let calls = 0;
|
|
16
|
+
const fn = jest.fn().mockImplementation(async () => {
|
|
17
|
+
calls++;
|
|
18
|
+
if (calls < 3) {
|
|
19
|
+
throw new Error('fail');
|
|
20
|
+
}
|
|
21
|
+
return 'success';
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const result = await retry(fn, { retries: 3, baseDelay: 50 });
|
|
25
|
+
expect(result).toBe('success');
|
|
26
|
+
expect(fn).toHaveBeenCalledTimes(3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws after exhausting retries', async () => {
|
|
30
|
+
const fn = jest.fn().mockRejectedValue(new Error('persistent failure'));
|
|
31
|
+
|
|
32
|
+
await expect(retry(fn, { retries: 2, baseDelay: 50 })).rejects.toThrow(
|
|
33
|
+
'persistent failure'
|
|
34
|
+
);
|
|
35
|
+
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('calls onRetry callback before each retry', async () => {
|
|
39
|
+
let calls = 0;
|
|
40
|
+
const fn = jest.fn().mockImplementation(async () => {
|
|
41
|
+
calls++;
|
|
42
|
+
if (calls < 2) {
|
|
43
|
+
throw new Error('fail');
|
|
44
|
+
}
|
|
45
|
+
return 'ok';
|
|
46
|
+
});
|
|
47
|
+
const onRetry = jest.fn();
|
|
48
|
+
|
|
49
|
+
await retry(fn, { retries: 3, baseDelay: 50, onRetry });
|
|
50
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(onRetry).toHaveBeenCalledWith(expect.any(Error), 1, 50);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('uses exponential backoff delays', async () => {
|
|
55
|
+
const delays = [];
|
|
56
|
+
const onRetry = jest.fn((err, attempt, delay) => {
|
|
57
|
+
delays.push(delay);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const fn = jest.fn().mockRejectedValue(new Error('fail'));
|
|
61
|
+
|
|
62
|
+
await expect(
|
|
63
|
+
retry(fn, {
|
|
64
|
+
retries: 3,
|
|
65
|
+
baseDelay: 100,
|
|
66
|
+
factor: 2,
|
|
67
|
+
maxDelay: 1000,
|
|
68
|
+
onRetry,
|
|
69
|
+
})
|
|
70
|
+
).rejects.toThrow();
|
|
71
|
+
|
|
72
|
+
// Delays should be: 100, 200, 400
|
|
73
|
+
expect(delays).toEqual([100, 200, 400]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('caps delay at maxDelay', async () => {
|
|
77
|
+
const delays = [];
|
|
78
|
+
const onRetry = jest.fn((err, attempt, delay) => {
|
|
79
|
+
delays.push(delay);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const fn = jest.fn().mockRejectedValue(new Error('fail'));
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
retry(fn, {
|
|
86
|
+
retries: 3,
|
|
87
|
+
baseDelay: 100,
|
|
88
|
+
factor: 10,
|
|
89
|
+
maxDelay: 500,
|
|
90
|
+
onRetry,
|
|
91
|
+
})
|
|
92
|
+
).rejects.toThrow();
|
|
93
|
+
|
|
94
|
+
// Delays: min(100, 500)=100, min(1000, 500)=500, min(10000, 500)=500
|
|
95
|
+
expect(delays).toEqual([100, 500, 500]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('passes attempt number to fn', async () => {
|
|
99
|
+
const attempts = [];
|
|
100
|
+
const fn = jest.fn().mockImplementation(async (attempt) => {
|
|
101
|
+
attempts.push(attempt);
|
|
102
|
+
if (attempt < 2) {
|
|
103
|
+
throw new Error('fail');
|
|
104
|
+
}
|
|
105
|
+
return 'ok';
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await retry(fn, { retries: 3, baseDelay: 50 });
|
|
109
|
+
expect(attempts).toEqual([0, 1, 2]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeText,
|
|
3
|
+
normalizeCode,
|
|
4
|
+
verifyMarkdownContent,
|
|
5
|
+
} from '../../src/verify.js';
|
|
6
|
+
|
|
7
|
+
describe('verify module', () => {
|
|
8
|
+
describe('normalizeText', () => {
|
|
9
|
+
it('lowercases text', () => {
|
|
10
|
+
expect(normalizeText('Hello World')).toBe('hello world');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('normalizes unicode spaces', () => {
|
|
14
|
+
expect(normalizeText('hello\u00A0world')).toBe('hello world');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('normalizes curly quotes', () => {
|
|
18
|
+
expect(normalizeText('\u201Chello\u201D')).toBe('"hello"');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('removes LaTeX delimiters', () => {
|
|
22
|
+
expect(normalizeText('$x + y$')).toBe('x + y');
|
|
23
|
+
expect(normalizeText('$$x + y$$')).toBe('x + y');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('normalizes arrows', () => {
|
|
27
|
+
expect(normalizeText('\u2192')).toBe('->');
|
|
28
|
+
expect(normalizeText('\\to')).toBe('->');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('removes \\displaystyle', () => {
|
|
32
|
+
expect(normalizeText('\\displaystyle x')).toBe('x');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extracts text from \\text{}', () => {
|
|
36
|
+
expect(normalizeText('\\text{hello}')).toBe('hello');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('normalizeCode', () => {
|
|
41
|
+
it('lowercases and collapses whitespace', () => {
|
|
42
|
+
expect(normalizeCode(' Hello World ')).toBe('hello world');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('normalizes multiplication sign', () => {
|
|
46
|
+
expect(normalizeCode('2\u00D73')).toBe('2x3');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('verifyMarkdownContent', () => {
|
|
51
|
+
it('verifies title presence', () => {
|
|
52
|
+
const webContent = {
|
|
53
|
+
title: 'My Article',
|
|
54
|
+
headings: [],
|
|
55
|
+
paragraphs: [],
|
|
56
|
+
codeBlocks: [],
|
|
57
|
+
formulas: [],
|
|
58
|
+
blockquoteFormulas: [],
|
|
59
|
+
listItems: [],
|
|
60
|
+
links: [],
|
|
61
|
+
figures: [],
|
|
62
|
+
};
|
|
63
|
+
const markdown = '# My Article\n\nSome content here.';
|
|
64
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
65
|
+
expect(result.missing.title).toBe(false);
|
|
66
|
+
expect(result.passedChecks).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('detects missing title', () => {
|
|
70
|
+
const webContent = {
|
|
71
|
+
title: 'Missing Title',
|
|
72
|
+
headings: [],
|
|
73
|
+
paragraphs: [],
|
|
74
|
+
codeBlocks: [],
|
|
75
|
+
formulas: [],
|
|
76
|
+
blockquoteFormulas: [],
|
|
77
|
+
listItems: [],
|
|
78
|
+
links: [],
|
|
79
|
+
figures: [],
|
|
80
|
+
};
|
|
81
|
+
const markdown = '# Different Title\n\nContent.';
|
|
82
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
83
|
+
expect(result.missing.title).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('verifies headings', () => {
|
|
87
|
+
const webContent = {
|
|
88
|
+
title: '',
|
|
89
|
+
headings: [
|
|
90
|
+
{ level: 'h2', text: 'Introduction' },
|
|
91
|
+
{ level: 'h3', text: 'Background' },
|
|
92
|
+
],
|
|
93
|
+
paragraphs: [],
|
|
94
|
+
codeBlocks: [],
|
|
95
|
+
formulas: [],
|
|
96
|
+
blockquoteFormulas: [],
|
|
97
|
+
listItems: [],
|
|
98
|
+
links: [],
|
|
99
|
+
figures: [],
|
|
100
|
+
};
|
|
101
|
+
const markdown = '## Introduction\n\n### Background\n\nText.';
|
|
102
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
103
|
+
expect(result.missing.headings).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('detects missing headings', () => {
|
|
107
|
+
const webContent = {
|
|
108
|
+
title: '',
|
|
109
|
+
headings: [{ level: 'h2', text: 'Unique Heading XYZ' }],
|
|
110
|
+
paragraphs: [],
|
|
111
|
+
codeBlocks: [],
|
|
112
|
+
formulas: [],
|
|
113
|
+
blockquoteFormulas: [],
|
|
114
|
+
listItems: [],
|
|
115
|
+
links: [],
|
|
116
|
+
figures: [],
|
|
117
|
+
};
|
|
118
|
+
const markdown = '## Different Heading\n\nText.';
|
|
119
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
120
|
+
expect(result.missing.headings).toContain('Unique Heading XYZ');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('verifies code blocks with fuzzy matching', () => {
|
|
124
|
+
const webContent = {
|
|
125
|
+
title: '',
|
|
126
|
+
headings: [],
|
|
127
|
+
paragraphs: [],
|
|
128
|
+
codeBlocks: [
|
|
129
|
+
'function hello() {\n console.log("hello");\n return true;\n}',
|
|
130
|
+
],
|
|
131
|
+
formulas: [],
|
|
132
|
+
blockquoteFormulas: [],
|
|
133
|
+
listItems: [],
|
|
134
|
+
links: [],
|
|
135
|
+
figures: [],
|
|
136
|
+
};
|
|
137
|
+
const markdown =
|
|
138
|
+
'```javascript\nfunction hello() {\n console.log("hello");\n return true;\n}\n```';
|
|
139
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
140
|
+
expect(result.missing.codeBlocks).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('calculates pass rate', () => {
|
|
144
|
+
const webContent = {
|
|
145
|
+
title: 'Test',
|
|
146
|
+
headings: [{ level: 'h2', text: 'Found' }],
|
|
147
|
+
paragraphs: [],
|
|
148
|
+
codeBlocks: [],
|
|
149
|
+
formulas: [],
|
|
150
|
+
blockquoteFormulas: [],
|
|
151
|
+
listItems: [],
|
|
152
|
+
links: [],
|
|
153
|
+
figures: [],
|
|
154
|
+
};
|
|
155
|
+
const markdown = '# Test\n\n## Found\n\nContent.';
|
|
156
|
+
const result = verifyMarkdownContent(webContent, markdown);
|
|
157
|
+
expect(result.passRate).toBe(1.0);
|
|
158
|
+
expect(result.success).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('checks figure images when hasLocalImages is set', () => {
|
|
162
|
+
const webContent = {
|
|
163
|
+
title: '',
|
|
164
|
+
headings: [],
|
|
165
|
+
paragraphs: [],
|
|
166
|
+
codeBlocks: [],
|
|
167
|
+
formulas: [],
|
|
168
|
+
blockquoteFormulas: [],
|
|
169
|
+
listItems: [],
|
|
170
|
+
links: [],
|
|
171
|
+
figures: [1, 2, 3],
|
|
172
|
+
};
|
|
173
|
+
const markdown =
|
|
174
|
+
'\n\n';
|
|
175
|
+
const result = verifyMarkdownContent(webContent, markdown, {
|
|
176
|
+
hasLocalImages: true,
|
|
177
|
+
expectedFigures: 3,
|
|
178
|
+
});
|
|
179
|
+
expect(result.missing.images).toBe(0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
name: Checks and release
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
pull_request:
|
|
8
|
-
types: [opened, synchronize, reopened]
|
|
9
|
-
# Manual release support - consolidated here to work with npm trusted publishing
|
|
10
|
-
# npm only allows ONE workflow file as trusted publisher, so all publishing
|
|
11
|
-
# must go through this workflow (release.yml)
|
|
12
|
-
workflow_dispatch:
|
|
13
|
-
inputs:
|
|
14
|
-
release_mode:
|
|
15
|
-
description: 'Manual release mode'
|
|
16
|
-
required: true
|
|
17
|
-
type: choice
|
|
18
|
-
default: 'instant'
|
|
19
|
-
options:
|
|
20
|
-
- instant
|
|
21
|
-
- changeset-pr
|
|
22
|
-
bump_type:
|
|
23
|
-
description: 'Manual release type'
|
|
24
|
-
required: true
|
|
25
|
-
type: choice
|
|
26
|
-
options:
|
|
27
|
-
- patch
|
|
28
|
-
- minor
|
|
29
|
-
- major
|
|
30
|
-
description:
|
|
31
|
-
description: 'Manual release description (optional)'
|
|
32
|
-
required: false
|
|
33
|
-
type: string
|
|
34
|
-
|
|
35
|
-
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|
36
|
-
|
|
37
|
-
jobs:
|
|
38
|
-
# Changeset check - only runs on PRs
|
|
39
|
-
changeset-check:
|
|
40
|
-
name: Check for Changesets
|
|
41
|
-
runs-on: ubuntu-latest
|
|
42
|
-
if: github.event_name == 'pull_request'
|
|
43
|
-
steps:
|
|
44
|
-
- uses: actions/checkout@v4
|
|
45
|
-
with:
|
|
46
|
-
fetch-depth: 0
|
|
47
|
-
|
|
48
|
-
- name: Setup Node.js
|
|
49
|
-
uses: actions/setup-node@v4
|
|
50
|
-
with:
|
|
51
|
-
node-version: '20.x'
|
|
52
|
-
|
|
53
|
-
- name: Install dependencies
|
|
54
|
-
run: npm install
|
|
55
|
-
|
|
56
|
-
- name: Check for changesets
|
|
57
|
-
run: |
|
|
58
|
-
# Skip changeset check for automated version PRs
|
|
59
|
-
if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then
|
|
60
|
-
echo "Skipping changeset check for automated release PR"
|
|
61
|
-
exit 0
|
|
62
|
-
fi
|
|
63
|
-
|
|
64
|
-
# Run changeset validation script
|
|
65
|
-
node scripts/validate-changeset.mjs
|
|
66
|
-
|
|
67
|
-
# Linting and formatting - runs after changeset check on PRs, immediately on main
|
|
68
|
-
lint:
|
|
69
|
-
name: Lint and Format Check
|
|
70
|
-
runs-on: ubuntu-latest
|
|
71
|
-
needs: [changeset-check]
|
|
72
|
-
if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
|
|
73
|
-
steps:
|
|
74
|
-
- uses: actions/checkout@v4
|
|
75
|
-
|
|
76
|
-
- name: Setup Node.js
|
|
77
|
-
uses: actions/setup-node@v4
|
|
78
|
-
with:
|
|
79
|
-
node-version: '20.x'
|
|
80
|
-
|
|
81
|
-
- name: Install dependencies
|
|
82
|
-
run: npm install
|
|
83
|
-
|
|
84
|
-
- name: Run ESLint
|
|
85
|
-
run: npm run lint
|
|
86
|
-
|
|
87
|
-
- name: Check formatting
|
|
88
|
-
run: npm run format:check
|
|
89
|
-
|
|
90
|
-
- name: Check code duplication
|
|
91
|
-
run: npm run check:duplication
|
|
92
|
-
|
|
93
|
-
# Test matrix: Node.js on Ubuntu with unit tests and e2e tests
|
|
94
|
-
test:
|
|
95
|
-
name: Test (Node.js on ${{ matrix.os }})
|
|
96
|
-
runs-on: ${{ matrix.os }}
|
|
97
|
-
needs: [changeset-check]
|
|
98
|
-
if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success')
|
|
99
|
-
strategy:
|
|
100
|
-
fail-fast: false
|
|
101
|
-
matrix:
|
|
102
|
-
os: [ubuntu-latest]
|
|
103
|
-
steps:
|
|
104
|
-
- uses: actions/checkout@v4
|
|
105
|
-
|
|
106
|
-
- name: Setup Node.js
|
|
107
|
-
uses: actions/setup-node@v4
|
|
108
|
-
with:
|
|
109
|
-
node-version: '22.x'
|
|
110
|
-
|
|
111
|
-
- name: Install dependencies
|
|
112
|
-
run: npm install
|
|
113
|
-
|
|
114
|
-
- name: Install Playwright browsers
|
|
115
|
-
run: npx playwright install --with-deps chromium
|
|
116
|
-
|
|
117
|
-
- name: Install Puppeteer browsers
|
|
118
|
-
run: npx puppeteer browsers install chrome
|
|
119
|
-
|
|
120
|
-
- name: Run unit, integration, and e2e process tests
|
|
121
|
-
run: npm test -- --testPathIgnorePatterns="docker.test.js"
|
|
122
|
-
|
|
123
|
-
- name: Build Docker image for e2e tests
|
|
124
|
-
run: docker compose build
|
|
125
|
-
|
|
126
|
-
- name: Run e2e Docker tests
|
|
127
|
-
run: npm test -- tests/e2e/docker.test.js
|
|
128
|
-
|
|
129
|
-
# Release - only runs on main after tests pass (for push events)
|
|
130
|
-
release:
|
|
131
|
-
name: Release
|
|
132
|
-
needs: [lint, test]
|
|
133
|
-
# Use always() to ensure this job runs even if changeset-check was skipped
|
|
134
|
-
# This is needed because lint/test jobs have a transitive dependency on changeset-check
|
|
135
|
-
if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success'
|
|
136
|
-
runs-on: ubuntu-latest
|
|
137
|
-
# Permissions required for npm OIDC trusted publishing
|
|
138
|
-
permissions:
|
|
139
|
-
contents: write
|
|
140
|
-
pull-requests: write
|
|
141
|
-
id-token: write
|
|
142
|
-
steps:
|
|
143
|
-
- uses: actions/checkout@v4
|
|
144
|
-
with:
|
|
145
|
-
fetch-depth: 0
|
|
146
|
-
|
|
147
|
-
- name: Setup Node.js
|
|
148
|
-
uses: actions/setup-node@v4
|
|
149
|
-
with:
|
|
150
|
-
node-version: '22.x'
|
|
151
|
-
registry-url: 'https://registry.npmjs.org'
|
|
152
|
-
|
|
153
|
-
- name: Install dependencies
|
|
154
|
-
run: npm install
|
|
155
|
-
|
|
156
|
-
- name: Update npm for OIDC trusted publishing
|
|
157
|
-
run: node scripts/setup-npm.mjs
|
|
158
|
-
|
|
159
|
-
- name: Check for changesets
|
|
160
|
-
id: check_changesets
|
|
161
|
-
run: |
|
|
162
|
-
# Count changeset files (excluding README.md and config.json)
|
|
163
|
-
CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l)
|
|
164
|
-
echo "Found $CHANGESET_COUNT changeset file(s)"
|
|
165
|
-
echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
|
|
166
|
-
|
|
167
|
-
- name: Version packages and commit to main
|
|
168
|
-
if: steps.check_changesets.outputs.has_changesets == 'true'
|
|
169
|
-
id: version
|
|
170
|
-
run: node scripts/version-and-commit.mjs --mode changeset
|
|
171
|
-
|
|
172
|
-
- name: Publish to npm
|
|
173
|
-
# Run if version was committed OR if a previous attempt already committed (for re-runs)
|
|
174
|
-
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
|
|
175
|
-
id: publish
|
|
176
|
-
run: node scripts/publish-to-npm.mjs --should-pull
|
|
177
|
-
|
|
178
|
-
- name: Create GitHub Release
|
|
179
|
-
if: steps.publish.outputs.published == 'true'
|
|
180
|
-
env:
|
|
181
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
182
|
-
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
|
|
183
|
-
|
|
184
|
-
- name: Format GitHub release notes
|
|
185
|
-
if: steps.publish.outputs.published == 'true'
|
|
186
|
-
env:
|
|
187
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
188
|
-
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
|
|
189
|
-
|
|
190
|
-
# Manual Instant Release - triggered via workflow_dispatch with instant mode
|
|
191
|
-
# This job is in release.yml because npm trusted publishing
|
|
192
|
-
# only allows one workflow file to be registered as a trusted publisher
|
|
193
|
-
instant-release:
|
|
194
|
-
name: Instant Release
|
|
195
|
-
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant'
|
|
196
|
-
runs-on: ubuntu-latest
|
|
197
|
-
# Permissions required for npm OIDC trusted publishing
|
|
198
|
-
permissions:
|
|
199
|
-
contents: write
|
|
200
|
-
pull-requests: write
|
|
201
|
-
id-token: write
|
|
202
|
-
steps:
|
|
203
|
-
- uses: actions/checkout@v4
|
|
204
|
-
with:
|
|
205
|
-
fetch-depth: 0
|
|
206
|
-
|
|
207
|
-
- name: Setup Node.js
|
|
208
|
-
uses: actions/setup-node@v4
|
|
209
|
-
with:
|
|
210
|
-
node-version: '22.x'
|
|
211
|
-
registry-url: 'https://registry.npmjs.org'
|
|
212
|
-
|
|
213
|
-
- name: Install dependencies
|
|
214
|
-
run: npm install
|
|
215
|
-
|
|
216
|
-
- name: Update npm for OIDC trusted publishing
|
|
217
|
-
run: node scripts/setup-npm.mjs
|
|
218
|
-
|
|
219
|
-
- name: Version packages and commit to main
|
|
220
|
-
id: version
|
|
221
|
-
run: node scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
|
|
222
|
-
|
|
223
|
-
- name: Publish to npm
|
|
224
|
-
# Run if version was committed OR if a previous attempt already committed (for re-runs)
|
|
225
|
-
if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true'
|
|
226
|
-
id: publish
|
|
227
|
-
run: node scripts/publish-to-npm.mjs
|
|
228
|
-
|
|
229
|
-
- name: Create GitHub Release
|
|
230
|
-
if: steps.publish.outputs.published == 'true'
|
|
231
|
-
env:
|
|
232
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
233
|
-
run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}"
|
|
234
|
-
|
|
235
|
-
- name: Format GitHub release notes
|
|
236
|
-
if: steps.publish.outputs.published == 'true'
|
|
237
|
-
env:
|
|
238
|
-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
239
|
-
run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}"
|
|
240
|
-
|
|
241
|
-
# Manual Changeset PR - creates a pull request with the changeset for review
|
|
242
|
-
changeset-pr:
|
|
243
|
-
name: Create Changeset PR
|
|
244
|
-
if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr'
|
|
245
|
-
runs-on: ubuntu-latest
|
|
246
|
-
permissions:
|
|
247
|
-
contents: write
|
|
248
|
-
pull-requests: write
|
|
249
|
-
steps:
|
|
250
|
-
- uses: actions/checkout@v4
|
|
251
|
-
with:
|
|
252
|
-
fetch-depth: 0
|
|
253
|
-
|
|
254
|
-
- name: Setup Node.js
|
|
255
|
-
uses: actions/setup-node@v4
|
|
256
|
-
with:
|
|
257
|
-
node-version: '22.x'
|
|
258
|
-
|
|
259
|
-
- name: Install dependencies
|
|
260
|
-
run: npm install
|
|
261
|
-
|
|
262
|
-
- name: Create changeset file
|
|
263
|
-
run: node scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}"
|
|
264
|
-
|
|
265
|
-
- name: Format changeset with Prettier
|
|
266
|
-
run: |
|
|
267
|
-
# Run Prettier on the changeset file to ensure it matches project style
|
|
268
|
-
npx prettier --write ".changeset/*.md" || true
|
|
269
|
-
|
|
270
|
-
echo "Formatted changeset files"
|
|
271
|
-
|
|
272
|
-
- name: Create Pull Request
|
|
273
|
-
uses: peter-evans/create-pull-request@v7
|
|
274
|
-
with:
|
|
275
|
-
token: ${{ secrets.GITHUB_TOKEN }}
|
|
276
|
-
commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release'
|
|
277
|
-
branch: changeset-manual-release-${{ github.run_id }}
|
|
278
|
-
delete-branch: true
|
|
279
|
-
title: 'chore: manual ${{ github.event.inputs.bump_type }} release'
|
|
280
|
-
body: |
|
|
281
|
-
## Manual Release Request
|
|
282
|
-
|
|
283
|
-
This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release.
|
|
284
|
-
|
|
285
|
-
### Release Details
|
|
286
|
-
- **Type:** ${{ github.event.inputs.bump_type }}
|
|
287
|
-
- **Description:** ${{ github.event.inputs.description || 'Manual release' }}
|
|
288
|
-
- **Triggered by:** @${{ github.actor }}
|
|
289
|
-
|
|
290
|
-
### Next Steps
|
|
291
|
-
1. Review the changeset in this PR
|
|
292
|
-
2. Merge this PR to main
|
|
293
|
-
3. The automated release workflow will create a version PR
|
|
294
|
-
4. Merge the version PR to publish to npm and create a GitHub release
|