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.
- package/default-themes/nebula-dawn.css +682 -0
- package/default-themes/nebula-dawn.json +19 -0
- package/default-themes/nebula-dusk.css +674 -0
- package/default-themes/nebula-dusk.json +19 -0
- package/package.json +4 -1
- package/page-scripts/helpers-v2.js +121 -0
- package/page-scripts/page-v2.js +615 -0
- package/tests/README.md +12 -0
- package/tests/migrations.spec.ts +91 -0
- package/tests/pages.spec.ts +103 -0
- package/tests/transformPage.spec.ts +414 -0
|
@@ -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
|
+
});
|