synthos 0.7.0 → 0.7.2

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.
@@ -0,0 +1,91 @@
1
+ import assert from 'assert';
2
+ import { postProcessV2 } from '../src/migrations';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // postProcessV2
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('postProcessV2', () => {
9
+ const baseV2 = `<!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <title>Test</title>
14
+ <script src="/api/theme-info.js"></script>
15
+ <link rel="stylesheet" href="/api/theme.css">
16
+ <style>
17
+ .my-class { color: red; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div class="viewer-panel">Content</div>
22
+ </body>
23
+ </html>`;
24
+
25
+ it('injects theme-info.js when missing', () => {
26
+ const html = `<html><head><title>Test</title></head><body></body></html>`;
27
+ const result = postProcessV2(html);
28
+ assert.ok(result.includes('src="/api/theme-info.js"'));
29
+ });
30
+
31
+ it('does not duplicate theme-info.js when already present', () => {
32
+ const result = postProcessV2(baseV2);
33
+ const matches = result.match(/theme-info\.js/g);
34
+ assert.strictEqual(matches?.length, 1);
35
+ });
36
+
37
+ it('injects theme.css when missing', () => {
38
+ const html = `<html><head><title>Test</title></head><body></body></html>`;
39
+ const result = postProcessV2(html);
40
+ assert.ok(result.includes('href="/api/theme.css"'));
41
+ });
42
+
43
+ it('does not duplicate theme.css when already present', () => {
44
+ const result = postProcessV2(baseV2);
45
+ const matches = result.match(/theme\.css/g);
46
+ assert.strictEqual(matches?.length, 1);
47
+ });
48
+
49
+ it('strips shared CSS selectors from style blocks', () => {
50
+ const html = `<html><head><style>
51
+ .chat-panel { background: black; }
52
+ .viewer-panel { padding: 10px; }
53
+ .my-custom { color: blue; }
54
+ </style></head><body></body></html>`;
55
+ const result = postProcessV2(html);
56
+ assert.ok(!result.includes('.chat-panel {'));
57
+ assert.ok(!result.includes('.viewer-panel {'));
58
+ assert.ok(result.includes('.my-custom'));
59
+ });
60
+
61
+ it('removes empty style blocks after stripping', () => {
62
+ const html = `<html><head><style>
63
+ .chat-panel { background: black; }
64
+ </style></head><body></body></html>`;
65
+ const result = postProcessV2(html);
66
+ // The style block should be removed since it only had shared CSS
67
+ assert.ok(!result.includes('<style>'));
68
+ });
69
+
70
+ it('removes empty script blocks (no src)', () => {
71
+ const html = `<html><head></head><body><script> </script></body></html>`;
72
+ const result = postProcessV2(html);
73
+ // Empty script should be removed
74
+ assert.ok(!result.includes('<script> </script>'));
75
+ });
76
+
77
+ it('preserves script blocks with src attribute', () => {
78
+ const html = `<html><head><script src="/api/theme-info.js"></script></head><body></body></html>`;
79
+ const result = postProcessV2(html);
80
+ assert.ok(result.includes('src="/api/theme-info.js"'));
81
+ });
82
+
83
+ it('preserves page-specific CSS', () => {
84
+ const html = `<html><head><style>
85
+ .chat-panel { background: black; }
86
+ .game-canvas { width: 100%; height: 100%; }
87
+ </style></head><body></body></html>`;
88
+ const result = postProcessV2(html);
89
+ assert.ok(result.includes('.game-canvas'));
90
+ });
91
+ });
@@ -0,0 +1,103 @@
1
+ import assert from 'assert';
2
+ import { normalizePageName, parseMetadata } from '../src/pages';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // normalizePageName
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('normalizePageName', () => {
9
+ it('lowercases the name', () => {
10
+ assert.strictEqual(normalizePageName('MyPage'), 'mypage');
11
+ });
12
+
13
+ it('replaces invalid characters with underscores', () => {
14
+ assert.strictEqual(normalizePageName('my page!'), 'my_page_');
15
+ });
16
+
17
+ it('preserves allowed special characters', () => {
18
+ // Allowed: a-z 0-9 - _ [ ] ( ) { } @ # $ % &
19
+ const input = 'test-name_[ok](yes){no}@#$%&';
20
+ assert.strictEqual(normalizePageName(input), input.toLowerCase());
21
+ });
22
+
23
+ it('returns undefined for empty string', () => {
24
+ assert.strictEqual(normalizePageName(''), undefined);
25
+ });
26
+
27
+ it('returns undefined for undefined input', () => {
28
+ assert.strictEqual(normalizePageName(undefined), undefined);
29
+ });
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // parseMetadata
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('parseMetadata', () => {
37
+ it('parses complete input', () => {
38
+ const input = {
39
+ title: 'My Page',
40
+ categories: ['Tools'],
41
+ pinned: true,
42
+ createdDate: '2025-01-01T00:00:00Z',
43
+ lastModified: '2025-06-01T00:00:00Z',
44
+ pageVersion: 2,
45
+ mode: 'locked',
46
+ };
47
+ const result = parseMetadata(input);
48
+ assert.strictEqual(result.title, 'My Page');
49
+ assert.deepStrictEqual(result.categories, ['Tools']);
50
+ assert.strictEqual(result.pinned, true);
51
+ assert.strictEqual(result.createdDate, '2025-01-01T00:00:00Z');
52
+ assert.strictEqual(result.lastModified, '2025-06-01T00:00:00Z');
53
+ assert.strictEqual(result.pageVersion, 2);
54
+ assert.strictEqual(result.mode, 'locked');
55
+ });
56
+
57
+ it('provides defaults for missing fields', () => {
58
+ const result = parseMetadata({});
59
+ assert.strictEqual(result.title, '');
60
+ assert.deepStrictEqual(result.categories, []);
61
+ assert.strictEqual(result.pinned, false);
62
+ assert.strictEqual(result.createdDate, '');
63
+ assert.strictEqual(result.lastModified, '');
64
+ assert.strictEqual(result.pageVersion, 0);
65
+ assert.strictEqual(result.mode, 'unlocked');
66
+ });
67
+
68
+ it('provides defaults for wrong types', () => {
69
+ const result = parseMetadata({
70
+ title: 42,
71
+ categories: 'not-array',
72
+ pinned: 'yes',
73
+ createdDate: 123,
74
+ lastModified: null,
75
+ pageVersion: 'two',
76
+ mode: 'invalid',
77
+ });
78
+ assert.strictEqual(result.title, '');
79
+ assert.deepStrictEqual(result.categories, []);
80
+ assert.strictEqual(result.pinned, false);
81
+ assert.strictEqual(result.createdDate, '');
82
+ assert.strictEqual(result.lastModified, '');
83
+ assert.strictEqual(result.pageVersion, 0);
84
+ assert.strictEqual(result.mode, 'unlocked');
85
+ });
86
+
87
+ it('falls back from uxVersion to pageVersion', () => {
88
+ const result = parseMetadata({ uxVersion: 1 });
89
+ assert.strictEqual(result.pageVersion, 1);
90
+ });
91
+
92
+ it('prefers pageVersion over uxVersion when both present', () => {
93
+ const result = parseMetadata({ pageVersion: 2, uxVersion: 1 });
94
+ assert.strictEqual(result.pageVersion, 2);
95
+ });
96
+
97
+ it('validates mode — only "locked" passes, everything else becomes "unlocked"', () => {
98
+ assert.strictEqual(parseMetadata({ mode: 'locked' }).mode, 'locked');
99
+ assert.strictEqual(parseMetadata({ mode: 'unlocked' }).mode, 'unlocked');
100
+ assert.strictEqual(parseMetadata({ mode: 'read-only' }).mode, 'unlocked');
101
+ assert.strictEqual(parseMetadata({ mode: 123 }).mode, 'unlocked');
102
+ });
103
+ });
@@ -0,0 +1,414 @@
1
+ import assert from 'assert';
2
+ import {
3
+ assignNodeIds,
4
+ stripNodeIds,
5
+ applyChangeList,
6
+ parseChangeList,
7
+ injectError,
8
+ deduplicateInlineScripts,
9
+ ChangeList,
10
+ } from '../src/service/transformPage';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // assignNodeIds
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe('assignNodeIds', () => {
17
+ it('assigns sequential data-node-id to every element', () => {
18
+ const html = '<html><head></head><body><div><p>Hello</p></div></body></html>';
19
+ const { html: result, nodeCount } = assignNodeIds(html);
20
+ assert.ok(result.includes('data-node-id="0"'));
21
+ assert.ok(nodeCount > 0);
22
+ // Every tag should have an id — count occurrences
23
+ const ids = result.match(/data-node-id="/g);
24
+ assert.strictEqual(ids?.length, nodeCount);
25
+ });
26
+
27
+ it('returns nodeCount matching the number of tags', () => {
28
+ const html = '<div><span>A</span><span>B</span></div>';
29
+ const { nodeCount } = assignNodeIds(html);
30
+ // cheerio wraps in <html><head></head><body>...</body> so count includes those
31
+ assert.ok(nodeCount >= 4); // html, head, body, div, span, span = 6
32
+ });
33
+
34
+ it('assigns data-node-id to script and style elements', () => {
35
+ const html = '<html><head><style>.a{color:red}</style><script>var x=1;</script></head><body><script src="/app.js"></script></body></html>';
36
+ const { html: result, nodeCount } = assignNodeIds(html);
37
+ // All 6 elements should have ids: html, head, style, script(inline), body, script(src)
38
+ const ids = result.match(/data-node-id="/g);
39
+ assert.strictEqual(ids?.length, nodeCount);
40
+ assert.strictEqual(nodeCount, 6);
41
+ // Verify the style and script tags specifically got ids
42
+ assert.ok(result.match(/<style[^>]+data-node-id="/), 'style element should have data-node-id');
43
+ assert.ok(result.match(/<script[^>]+data-node-id="/), 'script element should have data-node-id');
44
+ });
45
+ });
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // stripNodeIds
49
+ // ---------------------------------------------------------------------------
50
+
51
+ describe('stripNodeIds', () => {
52
+ it('removes all data-node-id attributes', () => {
53
+ const html = '<div data-node-id="0"><p data-node-id="1">Hi</p></div>';
54
+ const result = stripNodeIds(html);
55
+ assert.ok(!result.includes('data-node-id'));
56
+ assert.ok(result.includes('<p>Hi</p>'));
57
+ });
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // assignNodeIds -> stripNodeIds roundtrip
62
+ // ---------------------------------------------------------------------------
63
+
64
+ describe('assignNodeIds -> stripNodeIds roundtrip', () => {
65
+ it('produces HTML without data-node-id attributes', () => {
66
+ const original = '<html><head></head><body><div><p>Hello</p></div></body></html>';
67
+ const { html: annotated } = assignNodeIds(original);
68
+ assert.ok(annotated.includes('data-node-id'));
69
+ const stripped = stripNodeIds(annotated);
70
+ assert.ok(!stripped.includes('data-node-id'));
71
+ assert.ok(stripped.includes('<p>Hello</p>'));
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // applyChangeList
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe('applyChangeList', () => {
80
+ // Helper: wrap content in a minimal annotated structure
81
+ const annotated = '<html><head></head><body>' +
82
+ '<div data-node-id="10"><p data-node-id="11">Old text</p></div>' +
83
+ '</body></html>';
84
+
85
+ it('applies "update" — replaces innerHTML', () => {
86
+ const changes: ChangeList = [
87
+ { op: 'update', nodeId: '11', html: 'New text' },
88
+ ];
89
+ const result = applyChangeList(annotated, changes);
90
+ assert.ok(result.includes('<p data-node-id="11">New text</p>'));
91
+ });
92
+
93
+ it('applies "replace" — replaces outerHTML', () => {
94
+ const changes: ChangeList = [
95
+ { op: 'replace', nodeId: '11', html: '<span>Replaced</span>' },
96
+ ];
97
+ const result = applyChangeList(annotated, changes);
98
+ assert.ok(result.includes('<span>Replaced</span>'));
99
+ assert.ok(!result.includes('data-node-id="11"'));
100
+ });
101
+
102
+ it('applies "delete" — removes element', () => {
103
+ const changes: ChangeList = [
104
+ { op: 'delete', nodeId: '11' },
105
+ ];
106
+ const result = applyChangeList(annotated, changes);
107
+ assert.ok(!result.includes('data-node-id="11"'));
108
+ assert.ok(!result.includes('Old text'));
109
+ });
110
+
111
+ it('applies "insert" with position "append"', () => {
112
+ const changes: ChangeList = [
113
+ { op: 'insert', parentId: '10', position: 'append', html: '<em>Appended</em>' },
114
+ ];
115
+ const result = applyChangeList(annotated, changes);
116
+ assert.ok(result.includes('<em>Appended</em>'));
117
+ });
118
+
119
+ it('applies "insert" with position "prepend"', () => {
120
+ const changes: ChangeList = [
121
+ { op: 'insert', parentId: '10', position: 'prepend', html: '<em>Prepended</em>' },
122
+ ];
123
+ const result = applyChangeList(annotated, changes);
124
+ // Prepended element should appear before the <p>
125
+ const prependIdx = result.indexOf('<em>Prepended</em>');
126
+ const pIdx = result.indexOf('<p data-node-id="11">');
127
+ assert.ok(prependIdx < pIdx);
128
+ });
129
+
130
+ it('applies "insert" with position "before"', () => {
131
+ const changes: ChangeList = [
132
+ { op: 'insert', parentId: '11', position: 'before', html: '<em>Before</em>' },
133
+ ];
134
+ const result = applyChangeList(annotated, changes);
135
+ const beforeIdx = result.indexOf('<em>Before</em>');
136
+ const pIdx = result.indexOf('<p data-node-id="11">');
137
+ assert.ok(beforeIdx < pIdx);
138
+ });
139
+
140
+ it('applies "insert" with position "after"', () => {
141
+ const changes: ChangeList = [
142
+ { op: 'insert', parentId: '11', position: 'after', html: '<em>After</em>' },
143
+ ];
144
+ const result = applyChangeList(annotated, changes);
145
+ const afterIdx = result.indexOf('<em>After</em>');
146
+ const pIdx = result.indexOf('<p data-node-id="11">');
147
+ assert.ok(afterIdx > pIdx);
148
+ });
149
+
150
+ it('warns but does not throw on missing node for update/replace/delete', () => {
151
+ const changes: ChangeList = [
152
+ { op: 'update', nodeId: '999', html: 'Ghost' },
153
+ ];
154
+ // Should not throw
155
+ const result = applyChangeList(annotated, changes);
156
+ assert.ok(!result.includes('Ghost'));
157
+ });
158
+
159
+ it('throws on missing parent for insert', () => {
160
+ const changes: ChangeList = [
161
+ { op: 'insert', parentId: '999', position: 'append', html: '<em>Fail</em>' },
162
+ ];
163
+ assert.throws(() => applyChangeList(annotated, changes), /not found/);
164
+ });
165
+
166
+ it('applies "style-element" — sets style attribute on unlocked element', () => {
167
+ const changes: ChangeList = [
168
+ { op: 'style-element', nodeId: '11', style: 'color: red; font-size: 16px' },
169
+ ];
170
+ const result = applyChangeList(annotated, changes);
171
+ assert.ok(result.includes('style="color: red; font-size: 16px"'));
172
+ assert.ok(result.includes('data-node-id="11"'));
173
+ });
174
+
175
+ it('skips "style-element" on a data-locked element', () => {
176
+ const lockedHtml = '<html><head></head><body>' +
177
+ '<div data-node-id="10"><p data-node-id="11" data-locked>Locked text</p></div>' +
178
+ '</body></html>';
179
+ const changes: ChangeList = [
180
+ { op: 'style-element', nodeId: '11', style: 'color: red' },
181
+ ];
182
+ const result = applyChangeList(lockedHtml, changes);
183
+ assert.ok(!result.includes('style="color: red"'));
184
+ });
185
+
186
+ it('warns but does not throw on missing node for style-element', () => {
187
+ const changes: ChangeList = [
188
+ { op: 'style-element', nodeId: '999', style: 'color: red' },
189
+ ];
190
+ const result = applyChangeList(annotated, changes);
191
+ assert.ok(!result.includes('color: red'));
192
+ });
193
+
194
+ it('allows delete of unlocked child inside a data-locked parent', () => {
195
+ const lockedParentHtml = '<html><head></head><body>' +
196
+ '<div data-node-id="10" data-locked="true">' +
197
+ '<p data-node-id="11">Child message</p>' +
198
+ '<p data-node-id="12">Another child</p>' +
199
+ '</div></body></html>';
200
+ const changes: ChangeList = [
201
+ { op: 'delete', nodeId: '11' },
202
+ ];
203
+ const result = applyChangeList(lockedParentHtml, changes);
204
+ assert.ok(!result.includes('Child message'));
205
+ assert.ok(result.includes('Another child'));
206
+ });
207
+
208
+ it('blocks delete of element that itself has data-locked', () => {
209
+ const lockedHtml = '<html><head></head><body>' +
210
+ '<div data-node-id="10"><p data-node-id="11" data-locked="true">Locked</p></div>' +
211
+ '</body></html>';
212
+ const changes: ChangeList = [
213
+ { op: 'delete', nodeId: '11' },
214
+ ];
215
+ const result = applyChangeList(lockedHtml, changes);
216
+ assert.ok(result.includes('Locked'));
217
+ });
218
+
219
+ it('allows replace of unlocked child inside a data-locked parent', () => {
220
+ const lockedParentHtml = '<html><head></head><body>' +
221
+ '<div data-node-id="10" data-locked="true">' +
222
+ '<p data-node-id="11">Old child</p>' +
223
+ '</div></body></html>';
224
+ const changes: ChangeList = [
225
+ { op: 'replace', nodeId: '11', html: '<span>New child</span>' },
226
+ ];
227
+ const result = applyChangeList(lockedParentHtml, changes);
228
+ assert.ok(result.includes('<span>New child</span>'));
229
+ assert.ok(!result.includes('Old child'));
230
+ });
231
+
232
+ it('throws on unknown op', () => {
233
+ const changes = [{ op: 'explode', nodeId: '10' }] as unknown as ChangeList;
234
+ assert.throws(() => applyChangeList(annotated, changes), /Unknown change op/);
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // parseChangeList
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe('parseChangeList', () => {
243
+ it('parses raw JSON array', () => {
244
+ const input = '[{"op":"update","nodeId":"1","html":"Hi"}]';
245
+ const result = parseChangeList(input);
246
+ assert.strictEqual(result.length, 1);
247
+ assert.strictEqual(result[0].op, 'update');
248
+ });
249
+
250
+ it('parses markdown-fenced JSON', () => {
251
+ const input = '```json\n[{"op":"delete","nodeId":"2"}]\n```';
252
+ const result = parseChangeList(input);
253
+ assert.strictEqual(result.length, 1);
254
+ assert.strictEqual(result[0].op, 'delete');
255
+ });
256
+
257
+ it('extracts JSON array embedded in text', () => {
258
+ const input = 'Here are the changes:\n[{"op":"delete","nodeId":"3"}]\nDone.';
259
+ const result = parseChangeList(input);
260
+ assert.strictEqual(result.length, 1);
261
+ });
262
+
263
+ it('throws on invalid JSON', () => {
264
+ assert.throws(() => parseChangeList('not json at all'), /Failed to parse/);
265
+ });
266
+
267
+ it('throws on non-array JSON', () => {
268
+ assert.throws(() => parseChangeList('{"op":"update"}'), /Failed to parse/);
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // injectError
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe('injectError', () => {
277
+ it('injects error script block into body', () => {
278
+ const html = '<html><head></head><body><p>Page</p></body></html>';
279
+ const result = injectError(html, 'Oops', 'details here');
280
+ assert.ok(result.includes('<script id="error" type="application/json">'));
281
+ assert.ok(result.includes('"message":"Oops"'));
282
+ assert.ok(result.includes('"details":"details here"'));
283
+ });
284
+
285
+ it('replaces existing error block', () => {
286
+ const html = '<html><head></head><body><script id="error" type="application/json">{"message":"old"}</script></body></html>';
287
+ const result = injectError(html, 'New', 'new detail');
288
+ // Should have exactly one error script
289
+ const matches = result.match(/<script id="error"/g);
290
+ assert.strictEqual(matches?.length, 1);
291
+ assert.ok(result.includes('"message":"New"'));
292
+ });
293
+
294
+ it('appends to end if no body tag', () => {
295
+ const html = '<div>No body</div>';
296
+ const result = injectError(html, 'Err', 'det');
297
+ assert.ok(result.includes('<script id="error"'));
298
+ });
299
+ });
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // deduplicateInlineScripts
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe('deduplicateInlineScripts', () => {
306
+ it('removes the first of two exact-duplicate inline scripts', () => {
307
+ const script = `let count = 0;\nlet name = "test";\nfunction init() {}\nfunction render() {}`;
308
+ const html = `<html><head></head><body><script>${script}</script><script>${script}</script></body></html>`;
309
+ const result = deduplicateInlineScripts(html);
310
+ // Should keep exactly one script
311
+ const matches = result.match(/<script>/g);
312
+ assert.strictEqual(matches?.length, 1);
313
+ assert.ok(result.includes('function render'));
314
+ });
315
+
316
+ it('removes the first script when declarations overlap >= 60%', () => {
317
+ const scriptOld = `let count = 0;\nlet name = "old";\nfunction init() {}\nfunction render() {}\nfunction oldHelper() {}`;
318
+ const scriptNew = `let count = 0;\nlet name = "new";\nfunction init() {}\nfunction render() {}\nfunction newHelper() {}`;
319
+ const html = `<html><head></head><body><script>${scriptOld}</script><script>${scriptNew}</script></body></html>`;
320
+ const result = deduplicateInlineScripts(html);
321
+ const matches = result.match(/<script>/g);
322
+ assert.strictEqual(matches?.length, 1);
323
+ // Should keep the second (newer) script
324
+ assert.ok(result.includes('newHelper'));
325
+ assert.ok(!result.includes('oldHelper'));
326
+ });
327
+
328
+ it('preserves both scripts when declarations do not overlap', () => {
329
+ const scriptA = `let alpha = 1;\nlet beta = 2;\nfunction doA() {}`;
330
+ const scriptB = `let gamma = 3;\nlet delta = 4;\nfunction doB() {}`;
331
+ const html = `<html><head></head><body><script>${scriptA}</script><script>${scriptB}</script></body></html>`;
332
+ const result = deduplicateInlineScripts(html);
333
+ const matches = result.match(/<script>/g);
334
+ assert.strictEqual(matches?.length, 2);
335
+ });
336
+
337
+ it('never touches scripts with id attribute', () => {
338
+ const script = `let count = 0;\nlet name = "test";\nfunction init() {}\nfunction render() {}`;
339
+ const html = `<html><head></head><body><script id="app">${script}</script><script>${script}</script></body></html>`;
340
+ const result = deduplicateInlineScripts(html);
341
+ // Both should be preserved — the id-script is exempt from dedup
342
+ assert.ok(result.includes('id="app"'));
343
+ const scriptTags = result.match(/<script/g);
344
+ assert.strictEqual(scriptTags?.length, 2);
345
+ });
346
+
347
+ it('never touches scripts with src attribute', () => {
348
+ const html = `<html><head></head><body><script src="/app.js"></script><script src="/app.js"></script></body></html>`;
349
+ const result = deduplicateInlineScripts(html);
350
+ const matches = result.match(/<script/g);
351
+ assert.strictEqual(matches?.length, 2);
352
+ });
353
+
354
+ it('never touches scripts with type="application/json"', () => {
355
+ const json = `{"key": "value"}`;
356
+ const html = `<html><head></head><body><script type="application/json">${json}</script><script type="application/json">${json}</script></body></html>`;
357
+ const result = deduplicateInlineScripts(html);
358
+ const matches = result.match(/<script/g);
359
+ assert.strictEqual(matches?.length, 2);
360
+ });
361
+
362
+ it('does not remove scripts with fewer than 2 declarations', () => {
363
+ const scriptA = `let onlyOne = 1;`;
364
+ const scriptB = `let onlyOne = 1;`;
365
+ const html = `<html><head></head><body><script>${scriptA}</script><script>${scriptB}</script></body></html>`;
366
+ const result = deduplicateInlineScripts(html);
367
+ const matches = result.match(/<script>/g);
368
+ assert.strictEqual(matches?.length, 2);
369
+ });
370
+ });
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // deduplicateInlineScripts — ID-based dedup (Pass 1)
374
+ // ---------------------------------------------------------------------------
375
+
376
+ describe('deduplicateInlineScripts — ID-based dedup', () => {
377
+ it('removes the first of two scripts with the same id', () => {
378
+ const html = `<html><head></head><body><script id="app">let x = 1;</script><script id="app">let x = 2;</script></body></html>`;
379
+ const result = deduplicateInlineScripts(html);
380
+ const matches = result.match(/id="app"/g);
381
+ assert.strictEqual(matches?.length, 1);
382
+ assert.ok(result.includes('let x = 2'));
383
+ assert.ok(!result.includes('let x = 1'));
384
+ });
385
+
386
+ it('keeps only the last of three scripts with the same id', () => {
387
+ const html = `<html><head></head><body><script id="logic">let v = 1;</script><script id="logic">let v = 2;</script><script id="logic">let v = 3;</script></body></html>`;
388
+ const result = deduplicateInlineScripts(html);
389
+ const matches = result.match(/id="logic"/g);
390
+ assert.strictEqual(matches?.length, 1);
391
+ assert.ok(result.includes('let v = 3'));
392
+ assert.ok(!result.includes('let v = 1'));
393
+ assert.ok(!result.includes('let v = 2'));
394
+ });
395
+
396
+ it('preserves scripts with different ids', () => {
397
+ const html = `<html><head></head><body><script id="alpha">let a = 1;</script><script id="beta">let b = 2;</script></body></html>`;
398
+ const result = deduplicateInlineScripts(html);
399
+ assert.ok(result.includes('id="alpha"'));
400
+ assert.ok(result.includes('id="beta"'));
401
+ assert.ok(result.includes('let a = 1'));
402
+ assert.ok(result.includes('let b = 2'));
403
+ });
404
+
405
+ it('never removes scripts with system ids even if duplicated', () => {
406
+ const systemIds = ['page-info', 'page-helpers', 'page-script', 'error'];
407
+ for (const sysId of systemIds) {
408
+ const html = `<html><head></head><body><script id="${sysId}">content1</script><script id="${sysId}">content2</script></body></html>`;
409
+ const result = deduplicateInlineScripts(html);
410
+ const matches = result.match(new RegExp(`id="${sysId}"`, 'g'));
411
+ assert.strictEqual(matches?.length, 2, `Expected 2 scripts with id="${sysId}" to be preserved`);
412
+ }
413
+ });
414
+ });