tungsten 0.2.25 → 0.3.0

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.
Files changed (83) hide show
  1. package/dist/ContentNode.d.ts +7 -0
  2. package/dist/ContentNode.d.ts.map +1 -1
  3. package/dist/Instance/Instance.d.ts.map +1 -1
  4. package/dist/Instance/Instance.js +7 -5
  5. package/dist/Instance/gotoRedirect.sxyt.d.ts +2 -0
  6. package/dist/Instance/gotoRedirect.sxyt.d.ts.map +1 -0
  7. package/dist/Instance/gotoRedirect.sxyt.js +148 -0
  8. package/dist/Servers/DevServer.d.ts +7 -0
  9. package/dist/Servers/DevServer.d.ts.map +1 -1
  10. package/dist/Servers/DevServer.js +38 -1
  11. package/dist/Servers/routing.d.ts.map +1 -1
  12. package/dist/Servers/routing.js +10 -1
  13. package/dist/build/compileJavascript.d.ts.map +1 -1
  14. package/dist/build/compileJavascript.js +86 -1
  15. package/dist/build/compileStyles.d.ts +1 -1
  16. package/dist/build/compileStyles.d.ts.map +1 -1
  17. package/dist/build/compileStyles.js +10 -4
  18. package/dist/build/compileUserApp.d.ts.map +1 -1
  19. package/dist/build/compileUserApp.js +7 -2
  20. package/dist/components/RawHtml.d.ts +36 -0
  21. package/dist/components/RawHtml.d.ts.map +1 -0
  22. package/dist/components/RawHtml.js +30 -0
  23. package/dist/components/RawHtml.sxyt.d.ts +2 -0
  24. package/dist/components/RawHtml.sxyt.d.ts.map +1 -0
  25. package/dist/components/RawHtml.sxyt.js +104 -0
  26. package/dist/dev/liveReloadHub.d.ts +6 -0
  27. package/dist/dev/liveReloadHub.d.ts.map +1 -0
  28. package/dist/dev/liveReloadHub.js +12 -0
  29. package/dist/hydrate/hydrate.d.ts.map +1 -1
  30. package/dist/hydrate/hydrate.js +11 -4
  31. package/dist/hydrate/reconcileHydratedAttributes.d.ts +24 -0
  32. package/dist/hydrate/reconcileHydratedAttributes.d.ts.map +1 -0
  33. package/dist/hydrate/reconcileHydratedAttributes.js +73 -0
  34. package/dist/hydrate/reconcileHydratedAttributes.sxyt.d.ts +2 -0
  35. package/dist/hydrate/reconcileHydratedAttributes.sxyt.d.ts.map +1 -0
  36. package/dist/hydrate/reconcileHydratedAttributes.sxyt.js +173 -0
  37. package/dist/indexInner.d.ts +4 -0
  38. package/dist/indexInner.d.ts.map +1 -1
  39. package/dist/indexInner.js +3 -0
  40. package/dist/innerHtmlFromProps.d.ts +16 -0
  41. package/dist/innerHtmlFromProps.d.ts.map +1 -0
  42. package/dist/innerHtmlFromProps.js +31 -0
  43. package/dist/innerHtmlFromProps.sxyt.d.ts +2 -0
  44. package/dist/innerHtmlFromProps.sxyt.d.ts.map +1 -0
  45. package/dist/innerHtmlFromProps.sxyt.js +99 -0
  46. package/dist/rawHtml/HtmlAllowList.d.ts +17 -0
  47. package/dist/rawHtml/HtmlAllowList.d.ts.map +1 -0
  48. package/dist/rawHtml/HtmlAllowList.js +1 -0
  49. package/dist/rawHtml/decodeHtmlEntities.d.ts +8 -0
  50. package/dist/rawHtml/decodeHtmlEntities.d.ts.map +1 -0
  51. package/dist/rawHtml/decodeHtmlEntities.js +105 -0
  52. package/dist/rawHtml/htmlParsingTags.d.ts +23 -0
  53. package/dist/rawHtml/htmlParsingTags.d.ts.map +1 -0
  54. package/dist/rawHtml/htmlParsingTags.js +55 -0
  55. package/dist/rawHtml/parseHtml.d.ts +35 -0
  56. package/dist/rawHtml/parseHtml.d.ts.map +1 -0
  57. package/dist/rawHtml/parseHtml.js +214 -0
  58. package/dist/rawHtml/parseHtml.sxyt.d.ts +2 -0
  59. package/dist/rawHtml/parseHtml.sxyt.d.ts.map +1 -0
  60. package/dist/rawHtml/parseHtml.sxyt.js +239 -0
  61. package/dist/rawHtml/safeHtmlAllowList.d.ts +18 -0
  62. package/dist/rawHtml/safeHtmlAllowList.d.ts.map +1 -0
  63. package/dist/rawHtml/safeHtmlAllowList.js +47 -0
  64. package/dist/rawHtml/tokenizeHtml.d.ts +25 -0
  65. package/dist/rawHtml/tokenizeHtml.d.ts.map +1 -0
  66. package/dist/rawHtml/tokenizeHtml.js +148 -0
  67. package/dist/render/processHead.d.ts.map +1 -1
  68. package/dist/render/processHead.js +10 -0
  69. package/dist/render/processProps.d.ts.map +1 -1
  70. package/dist/render/processProps.js +14 -2
  71. package/dist/render/processProps.sxyt.d.ts +2 -0
  72. package/dist/render/processProps.sxyt.d.ts.map +1 -0
  73. package/dist/render/processProps.sxyt.js +90 -0
  74. package/dist/render/renderingTreeToHtml.d.ts.map +1 -1
  75. package/dist/render/renderingTreeToHtml.js +8 -0
  76. package/dist/routing/RouteTypes.d.ts +1 -1
  77. package/dist/routing/RouteTypes.d.ts.map +1 -1
  78. package/dist/routing/routeSymbols.d.ts +9 -0
  79. package/dist/routing/routeSymbols.d.ts.map +1 -0
  80. package/dist/routing/routeSymbols.js +4 -0
  81. package/dist/update/integrateUpdateVdom.d.ts.map +1 -1
  82. package/dist/update/integrateUpdateVdom.js +28 -7
  83. package/package.json +3 -3
@@ -0,0 +1,239 @@
1
+ import { describe } from 'sxy-test-runner';
2
+ import { expect } from 'chai';
3
+ import { parseHtml } from "./parseHtml.js";
4
+ import { safeHtmlAllowList } from "./safeHtmlAllowList.js";
5
+ import { decodeHtmlEntities } from "./decodeHtmlEntities.js";
6
+ import { tokenizeHtml } from "./tokenizeHtml.js";
7
+ import { renderingNodeToHtml } from "../render/renderingTreeToHtml.js";
8
+ import { $props, $type } from "../contentNodeSymbols.js";
9
+ // `found: true` short-circuits the <head> handling these tests do not need.
10
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
11
+ const noHeadData = {
12
+ found: true
13
+ };
14
+ // Renders a parsed tree back to markup. The renderer takes a RenderingNode,
15
+ // which for plain HTML content is structurally what parseHtml returns.
16
+ const render = async content => renderingNodeToHtml(
17
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
18
+ content, false, noHeadData, null);
19
+ const roundTrip = html => render(parseHtml(html));
20
+ describe('decodeHtmlEntities()', ({
21
+ it
22
+ }) => {
23
+ it('decodes named, decimal and hex references', () => {
24
+ expect(decodeHtmlEntities('&pound;100 &amp; more')).to.equal('£100 & more');
25
+ expect(decodeHtmlEntities('&#65;&#66;')).to.equal('AB');
26
+ expect(decodeHtmlEntities('&#x41;&#X42;')).to.equal('AB');
27
+ });
28
+ it('leaves unknown and malformed references exactly as written', () => {
29
+ expect(decodeHtmlEntities('&notareal; &amp')).to.equal('&notareal; &amp');
30
+ expect(decodeHtmlEntities('a & b')).to.equal('a & b');
31
+ });
32
+ it('does not throw on out-of-range numeric references', () => {
33
+ expect(decodeHtmlEntities('&#xFFFFFFFF;')).to.equal('&#xFFFFFFFF;');
34
+ expect(decodeHtmlEntities('&#0;')).to.equal('&#0;');
35
+ expect(decodeHtmlEntities('&#xD800;')).to.equal('&#xD800;');
36
+ });
37
+ });
38
+ describe('tokenizeHtml()', ({
39
+ it
40
+ }) => {
41
+ it('reads attributes in every quoting style', () => {
42
+ const [token] = tokenizeHtml('<a href="/x" title=\'y\' data-n=3 hidden>');
43
+ expect(token?.kind).to.equal('open');
44
+ expect(token?.kind === 'open' && token.attributes).to.deep.equal({
45
+ href: '/x',
46
+ title: 'y',
47
+ 'data-n': '3',
48
+ hidden: true
49
+ });
50
+ });
51
+ it('drops comments and doctypes', () => {
52
+ expect(tokenizeHtml('<!doctype html><!-- hi --><b>x</b>').map(token => token.kind)).to.deep.equal(['open', 'text', 'close']);
53
+ });
54
+ it('treats a bare < as text', () => {
55
+ expect(tokenizeHtml('5 < 6 and 7 > 2')).to.deep.equal([{
56
+ kind: 'text',
57
+ text: '5 < 6 and 7 > 2'
58
+ }]);
59
+ });
60
+ it('takes script content as literal text, not markup', () => {
61
+ expect(tokenizeHtml('<script>if (a<b) x()</script>').map(token => token.kind)).to.deep.equal(['open', 'text', 'close']);
62
+ });
63
+ // scanning for the first > would cut this tag short and build a tree the
64
+ // browser disagrees with
65
+ it('does not end a tag at a > inside a quoted attribute value', () => {
66
+ const [token] = tokenizeHtml('<a title="a > b" href="/x">');
67
+ expect(token?.kind === 'open' && token.attributes).to.deep.equal({
68
+ title: 'a > b',
69
+ href: '/x'
70
+ });
71
+ });
72
+ });
73
+ describe('parseHtml()', ({
74
+ it
75
+ }) => {
76
+ it('returns null for empty and non-string input', () => {
77
+ expect(parseHtml('')).to.equal(null);
78
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
79
+ expect(parseHtml(undefined)).to.equal(null);
80
+ });
81
+ it('builds an element tree with decoded text', async () => {
82
+ const content = parseHtml('<p>Bonus &pound;100</p>');
83
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
84
+ const node = content;
85
+ expect(node[$type]).to.equal('p');
86
+ expect(node[$props].children).to.equal('Bonus £100');
87
+ });
88
+ it('maps class to className and style to css, which the renderer expects', async () => {
89
+ expect(await roundTrip('<p class="a b" style="color: red">x</p>')).to.equal('<p class="a b" style="color: red">x</p>');
90
+ });
91
+ it('keeps sibling paragraphs as siblings rather than nesting them', async () => {
92
+ expect(await roundTrip('<p>one</p><p>two</p>')).to.equal('<p>one</p><p>two</p>');
93
+ });
94
+ // The rule that broke the dashboard: a browser closes an open <p> at the
95
+ // next block-level start tag, so the parser must too, or the tree and the
96
+ // DOM disagree and hydration walks off the end.
97
+ it('applies the implied end tag that closes a p at a block element', async () => {
98
+ expect(await roundTrip('<p>one<div>two</div>')).to.equal('<p>one</p><div>two</div>');
99
+ });
100
+ it('closes list items and table cells implicitly', async () => {
101
+ expect(await roundTrip('<ul><li>a<li>b</ul>')).to.equal('<ul><li>a</li><li>b</li></ul>');
102
+ expect(await roundTrip('<table><tbody><tr><td>a<td>b</tbody></table>')).to.equal('<table><tbody><tr><td>a</td><td>b</td></tr></tbody></table>');
103
+ });
104
+ // Parsers disagree over whether whitespace survives inside a table, so the
105
+ // markup carries none — the tree and the DOM then agree either way.
106
+ it('drops whitespace between table tags', async () => {
107
+ expect(await roundTrip('<table>\n<tbody>\n<tr><td>a</td></tr>\n</tbody>\n</table>')).to.equal('<table><tbody><tr><td>a</td></tr></tbody></table>');
108
+ });
109
+ it('foster-parents real text out to just before the table', async () => {
110
+ expect(await roundTrip('<table><tbody>stray<tr><td>a</td></tr></tbody></table>')).to.equal('stray<table><tbody><tr><td>a</td></tr></tbody></table>');
111
+ });
112
+ it('leaves stray text alone in lists and selects, which do not relocate it', async () => {
113
+ expect(await roundTrip('<ul>loose<li>a</li></ul>')).to.equal('<ul>loose<li>a</li></ul>');
114
+ });
115
+ it('handles void and self-closing elements', async () => {
116
+ expect(await roundTrip('<p>a<br>b<img src="/x" />c</p>')).to.equal('<p>a<br />b<img src="/x" />c</p>');
117
+ });
118
+ });
119
+ describe('parseHtml() resilience', ({
120
+ it
121
+ }) => {
122
+ // "Safe" here means it always produces something renderable, whatever it
123
+ // is handed. None of these may throw.
124
+ const malformed = ['<p>unclosed', '</p>stray end tag', '<p><b>crossed</p></b>', '<<>><p>x</p>', '<p class="unterminated>x</p>', '<!-- never closed', '<p', '<>', '<p><p><p><p>deep', '&&&;&#;&#x;', '<div><span></div></span>', '<TABLE><TR><TD>caps</TD></TR></TABLE>'];
125
+ for (const html of malformed) {
126
+ it(`does not throw on ${JSON.stringify(html)}`, async () => {
127
+ const content = parseHtml(html);
128
+ const rendered = await render(content);
129
+ expect(rendered).to.be.a('string');
130
+ });
131
+ }
132
+ it('keeps the text of an unclosed element', async () => {
133
+ expect(await roundTrip('<p>unclosed')).to.equal('<p>unclosed</p>');
134
+ });
135
+ it('ignores a stray end tag but keeps its text', async () => {
136
+ expect(await roundTrip('</p>after')).to.equal('after');
137
+ });
138
+ });
139
+ describe('parseHtml() sanitizing', ({
140
+ it
141
+ }) => {
142
+ it('drops script elements together with their contents', async () => {
143
+ expect(await roundTrip('<p>a</p><script>alert(1)</script><p>b</p>')).to.equal('<p>a</p><p>b</p>');
144
+ });
145
+ it('drops on* handler attributes', async () => {
146
+ expect(await roundTrip('<p onclick="alert(1)" onerror="x()" title="keep">a</p>')).to.equal('<p title="keep">a</p>');
147
+ });
148
+ it('drops javascript: urls, including obfuscated ones', async () => {
149
+ expect(await roundTrip('<a href="javascript:alert(1)">x</a>')).to.equal('<a>x</a>');
150
+ expect(await roundTrip('<a href=" JaVaScRiPt:alert(1)">x</a>')).to.equal('<a>x</a>');
151
+ // browsers ignore control characters inside the scheme, so this runs
152
+ expect(await roundTrip('<a href="java\tscript:alert(1)">x</a>')).to.equal('<a>x</a>');
153
+ expect(await roundTrip('<a href="vbscript:x">x</a>')).to.equal('<a>x</a>');
154
+ expect(await roundTrip('<a href="/safe">x</a>')).to.equal('<a href="/safe">x</a>');
155
+ expect(await roundTrip('<a href="https://x.test/">x</a>')).to.equal('<a href="https://x.test/">x</a>');
156
+ });
157
+ it('bars data: urls from navigation but allows them as image sources', async () => {
158
+ expect(await roundTrip('<a href="data:text/html,hello">x</a>')).to.equal('<a>x</a>');
159
+ expect(await roundTrip('<img src="data:image/png;base64,AAAA" />')).to.equal('<img src="data:image/png;base64,AAAA" />');
160
+ });
161
+ it('keeps handler attributes out even when sanitizing is off', async () => {
162
+ // Tungsten reads on* props as event listeners, so a string there is a
163
+ // hydration fault, not just a security question.
164
+ const content = parseHtml('<p onclick="alert(1)">a</p>', {
165
+ sanitize: false
166
+ });
167
+ expect(await render(content)).to.equal('<p>a</p>');
168
+ });
169
+ it('keeps scripts when sanitizing is turned off', async () => {
170
+ const content = parseHtml('<div><script>x()</script></div>', {
171
+ sanitize: false
172
+ });
173
+ expect(await render(content)).to.equal('<div><script>x()</script></div>');
174
+ });
175
+ });
176
+ describe('parseHtml() allow lists', ({
177
+ it
178
+ }) => {
179
+ const withList = (html, allowList) => render(parseHtml(html, {
180
+ allowList
181
+ }));
182
+ const onlyEmphasis = {
183
+ tags: ['p', 'em'],
184
+ attributes: []
185
+ };
186
+ it('keeps listed tags and unwraps unlisted ones, preserving their text', async () => {
187
+ expect(await withList('<p>keep <em>this</em> and <b>that</b></p>', onlyEmphasis)).to.equal('<p>keep <em>this</em> and that</p>');
188
+ });
189
+ it('unwraps an unlisted element without losing its listed descendants', async () => {
190
+ expect(await withList('<div><p>inner</p></div>', onlyEmphasis)).to.equal('<p>inner</p>');
191
+ });
192
+ it('drops program-text elements entirely rather than unwrapping them', async () => {
193
+ // unwrapping <style> would put its CSS on the page as visible text
194
+ expect(await withList('<p>a</p><style>p { color: red }</style>', onlyEmphasis)).to.equal('<p>a</p>');
195
+ });
196
+ it('keeps only listed attributes', async () => {
197
+ const list = {
198
+ tags: ['p'],
199
+ attributes: ['title']
200
+ };
201
+ expect(await withList('<p title="t" id="i" data-x="1">a</p>', list)).to.equal('<p title="t">a</p>');
202
+ });
203
+ it('applies per-tag attributes only to their own tag', async () => {
204
+ const list = {
205
+ tags: ['a', 'p'],
206
+ attributes: [],
207
+ tagAttributes: {
208
+ a: ['href']
209
+ }
210
+ };
211
+ expect(await withList('<a href="/x">link</a><p href="/y">para</p>', list)).to.equal('<a href="/x">link</a><p>para</p>');
212
+ });
213
+ });
214
+ describe('safeHtmlAllowList', ({
215
+ it
216
+ }) => {
217
+ const safely = html => render(parseHtml(html, {
218
+ allowList: safeHtmlAllowList
219
+ }));
220
+ it('keeps ordinary authored rich text intact', async () => {
221
+ const html = '<h2>Title</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>' + '<ul><li>one</li><li>two</li></ul>';
222
+ expect(await safely(html)).to.equal(html);
223
+ });
224
+ it('keeps tables with their layout attributes', async () => {
225
+ const html = '<table><tbody><tr><th align="left">H</th><td colspan="2">C</td></tr></tbody></table>';
226
+ expect(await safely(html)).to.equal(html);
227
+ });
228
+ it('keeps links and images with their own attributes', async () => {
229
+ expect(await safely('<a href="/x" target="_blank" rel="noopener">go</a>')).to.equal('<a href="/x" target="_blank" rel="noopener">go</a>');
230
+ expect(await safely('<img src="/a.png" alt="a" width="10" />')).to.equal('<img src="/a.png" alt="a" width="10" />');
231
+ });
232
+ it('strips style attributes, which can disguise the page without any script', async () => {
233
+ expect(await safely('<p style="position:fixed;top:0">a</p>')).to.equal('<p>a</p>');
234
+ });
235
+ it('removes embedding and form elements but keeps their text', async () => {
236
+ expect(await safely('<iframe src="//evil"></iframe><p>a</p>')).to.equal('<p>a</p>');
237
+ expect(await safely('<form><p>a</p></form>')).to.equal('<p>a</p>');
238
+ });
239
+ });
@@ -0,0 +1,18 @@
1
+ import type { HtmlAllowList } from './HtmlAllowList.js';
2
+ /**
3
+ * A conservative allow list for authored rich text — the shape a CMS or WYSIWYG
4
+ * field produces: headings, paragraphs, lists, tables, links, images, emphasis.
5
+ *
6
+ * Reach for this via `<RawHtml safe />`, or import it and spread it into your
7
+ * own list where an application needs more.
8
+ *
9
+ * Deliberately absent:
10
+ *
11
+ * - `style`, on any element. Attacker-controlled CSS can reposition and
12
+ * disguise page furniture, so it is a real vector even without script.
13
+ * - `script`, `iframe`, `object`, `embed`, `form`, `input`, `button`.
14
+ * - `target` without `rel`: `target="_blank"` hands the opener window to the
15
+ * linked page, so `rel` is allowed alongside it to be set to `noopener`.
16
+ */
17
+ export declare const safeHtmlAllowList: HtmlAllowList;
18
+ //# sourceMappingURL=safeHtmlAllowList.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safeHtmlAllowList.d.ts","sourceRoot":"","sources":["../../src/rawHtml/safeHtmlAllowList.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAEvD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,iBAAiB,EAAE,aAmC/B,CAAA"}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A conservative allow list for authored rich text — the shape a CMS or WYSIWYG
3
+ * field produces: headings, paragraphs, lists, tables, links, images, emphasis.
4
+ *
5
+ * Reach for this via `<RawHtml safe />`, or import it and spread it into your
6
+ * own list where an application needs more.
7
+ *
8
+ * Deliberately absent:
9
+ *
10
+ * - `style`, on any element. Attacker-controlled CSS can reposition and
11
+ * disguise page furniture, so it is a real vector even without script.
12
+ * - `script`, `iframe`, `object`, `embed`, `form`, `input`, `button`.
13
+ * - `target` without `rel`: `target="_blank"` hands the opener window to the
14
+ * linked page, so `rel` is allowed alongside it to be set to `noopener`.
15
+ */
16
+ export const safeHtmlAllowList = {
17
+ tags: [
18
+ // text flow
19
+ 'p', 'br', 'hr', 'span', 'div', 'blockquote', 'pre', 'code',
20
+ // headings
21
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
22
+ // emphasis
23
+ 'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'sub', 'sup', 'small', 'mark',
24
+ // lists
25
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
26
+ // tables
27
+ 'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
28
+ // media and links
29
+ 'a', 'img', 'figure', 'figcaption'],
30
+ attributes: ['class', 'id', 'title', 'dir', 'lang'],
31
+ tagAttributes: {
32
+ a: ['href', 'target', 'rel', 'name'],
33
+ img: ['src', 'alt', 'width', 'height', 'loading'],
34
+ td: ['colspan', 'rowspan', 'align', 'valign', 'scope', 'headers'],
35
+ th: ['colspan', 'rowspan', 'align', 'valign', 'scope', 'headers', 'abbr'],
36
+ tr: ['align', 'valign'],
37
+ table: ['align', 'summary'],
38
+ thead: ['align', 'valign'],
39
+ tbody: ['align', 'valign'],
40
+ tfoot: ['align', 'valign'],
41
+ col: ['span', 'align', 'valign', 'width'],
42
+ colgroup: ['span', 'align', 'valign', 'width'],
43
+ ol: ['start', 'type', 'reversed'],
44
+ ul: ['type'],
45
+ li: ['value']
46
+ }
47
+ };
@@ -0,0 +1,25 @@
1
+ export type HtmlAttributes = Record<string, string | true>;
2
+ export type HtmlToken = {
3
+ kind: 'text';
4
+ text: string;
5
+ } | {
6
+ kind: 'open';
7
+ name: string;
8
+ attributes: HtmlAttributes;
9
+ selfClosing: boolean;
10
+ } | {
11
+ kind: 'close';
12
+ name: string;
13
+ };
14
+ /**
15
+ * Splits raw markup into text, start and end tokens.
16
+ *
17
+ * Never throws. Anything that is not well-formed markup — a stray `<`, an
18
+ * unterminated tag, a comment that is never closed — is emitted as text or
19
+ * skipped, matching what a browser does with the same input.
20
+ *
21
+ * Comments and doctypes are dropped: Tungsten uses comment nodes as its own
22
+ * component markers, so carrying authored ones through would confuse hydration.
23
+ */
24
+ export declare function tokenizeHtml(html: string): HtmlToken[];
25
+ //# sourceMappingURL=tokenizeHtml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenizeHtml.d.ts","sourceRoot":"","sources":["../../src/rawHtml/tokenizeHtml.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAA;AAE1D,MAAM,MAAM,SAAS,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,cAAc,CAAC;IAAC,WAAW,EAAE,OAAO,CAAA;CAAE,GAChF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAoDrC;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,CA4FtD"}
@@ -0,0 +1,148 @@
1
+ import { decodeHtmlEntities } from "./decodeHtmlEntities.js";
2
+ import { htmlRawTextTags } from "./htmlParsingTags.js";
3
+ // REGEX: < then optional / then a letter -- the only thing that starts a tag.
4
+ // Anything else after a < is literal text, which is how browsers treat it too.
5
+ const tagStart = /<\/?[a-zA-Z]/;
6
+ // REGEX:
7
+ // optional whitespace, then an attribute name (anything but whitespace, /, >, =),
8
+ // then optionally = and a value: "double quoted", 'single quoted', or bare.
9
+ const attributePattern = /\s*([^\s/>=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g;
10
+ /**
11
+ * Index of the `>` that ends a tag, or -1.
12
+ *
13
+ * A `>` inside a quoted attribute value is ordinary text, so scanning for the
14
+ * first one would cut tags such as `<a title="a > b">` short and build a tree
15
+ * the browser does not agree with.
16
+ */
17
+ function findTagEnd(source) {
18
+ let quote = '';
19
+ for (let i = 1; i < source.length; i++) {
20
+ const character = source[i];
21
+ if (quote !== '') {
22
+ if (character === quote) quote = '';
23
+ } else if (character === '"' || character === '\'') {
24
+ quote = character;
25
+ } else if (character === '>') {
26
+ return i;
27
+ }
28
+ }
29
+ return -1;
30
+ }
31
+ function parseAttributes(source) {
32
+ const attributes = {};
33
+ attributePattern.lastIndex = 0;
34
+ let match;
35
+ while ((match = attributePattern.exec(source)) !== null) {
36
+ // A zero-length match cannot advance lastIndex, so bail rather than spin.
37
+ if (match[0] === '') break;
38
+ const name = match[1];
39
+ if (name === undefined || name === '') continue;
40
+ const value = match[2] ?? match[3] ?? match[4];
41
+ attributes[name] = value === undefined ? true : decodeHtmlEntities(value);
42
+ }
43
+ return attributes;
44
+ }
45
+ /**
46
+ * Splits raw markup into text, start and end tokens.
47
+ *
48
+ * Never throws. Anything that is not well-formed markup — a stray `<`, an
49
+ * unterminated tag, a comment that is never closed — is emitted as text or
50
+ * skipped, matching what a browser does with the same input.
51
+ *
52
+ * Comments and doctypes are dropped: Tungsten uses comment nodes as its own
53
+ * component markers, so carrying authored ones through would confuse hydration.
54
+ */
55
+ export function tokenizeHtml(html) {
56
+ const tokens = [];
57
+ let rest = html;
58
+ const pushText = text => {
59
+ if (text !== '') tokens.push({
60
+ kind: 'text',
61
+ text: decodeHtmlEntities(text)
62
+ });
63
+ };
64
+ while (rest !== '') {
65
+ const next = rest.search(tagStart);
66
+ // no more tags: the remainder is all text
67
+ if (next === -1) {
68
+ const comment = rest.indexOf('<!');
69
+ if (comment === -1) {
70
+ pushText(rest);
71
+ break;
72
+ }
73
+ pushText(rest.slice(0, comment));
74
+ rest = rest.slice(comment);
75
+ } else if (next > 0) {
76
+ const markup = rest.indexOf('<!');
77
+ if (markup !== -1 && markup < next) {
78
+ pushText(rest.slice(0, markup));
79
+ rest = rest.slice(markup);
80
+ } else {
81
+ pushText(rest.slice(0, next));
82
+ rest = rest.slice(next);
83
+ }
84
+ }
85
+ // comment or doctype - skip it
86
+ if (rest.startsWith('<!')) {
87
+ if (rest.startsWith('<!--')) {
88
+ const end = rest.indexOf('-->', 4);
89
+ // never closed: the rest of the document is inside the comment
90
+ if (end === -1) break;
91
+ rest = rest.slice(end + 3);
92
+ } else {
93
+ const end = rest.indexOf('>');
94
+ if (end === -1) break;
95
+ rest = rest.slice(end + 1);
96
+ }
97
+ continue;
98
+ }
99
+ if (!rest.startsWith('<')) continue;
100
+ const end = findTagEnd(rest);
101
+ // unterminated tag: treat what is left as text, as a browser does
102
+ if (end === -1) {
103
+ pushText(rest);
104
+ break;
105
+ }
106
+ const inner = rest.slice(1, end);
107
+ rest = rest.slice(end + 1);
108
+ if (inner.startsWith('/')) {
109
+ tokens.push({
110
+ kind: 'close',
111
+ name: inner.slice(1).trim().toLowerCase()
112
+ });
113
+ continue;
114
+ }
115
+ const selfClosing = inner.endsWith('/');
116
+ const body = selfClosing ? inner.slice(0, -1) : inner;
117
+ // REGEX: leading run of tag-name characters
118
+ const nameMatch = /^[^\s/>]+/.exec(body);
119
+ const name = (nameMatch?.[0] ?? '').toLowerCase();
120
+ if (name === '') continue;
121
+ tokens.push({
122
+ kind: 'open',
123
+ name,
124
+ attributes: parseAttributes(body.slice(name.length)),
125
+ selfClosing
126
+ });
127
+ // raw-text elements hold text, not markup, up to their closing tag
128
+ if (name in htmlRawTextTags && !selfClosing) {
129
+ const closeTag = `</${name}`;
130
+ const closeAt = rest.toLowerCase().indexOf(closeTag);
131
+ const text = closeAt === -1 ? rest : rest.slice(0, closeAt);
132
+ if (text !== '') {
133
+ // script and style hold literal program text; textarea and
134
+ // title hold ordinary text, so their entities are decoded.
135
+ tokens.push({
136
+ kind: 'text',
137
+ text: htmlRawTextTags[name] === 'escapable' ? decodeHtmlEntities(text) : text
138
+ });
139
+ }
140
+ if (closeAt === -1) {
141
+ rest = '';
142
+ break;
143
+ }
144
+ rest = rest.slice(closeAt);
145
+ }
146
+ }
147
+ return tokens;
148
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"processHead.d.ts","sourceRoot":"","sources":["../../src/render/processHead.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAK7C,OAAO,EAAE,oBAAoB,EAAqB,MAAM,qBAAqB,CAAA;AAO7E,wBAAgB,WAAW,CACvB,QAAQ,EAAE,oBAAoB,EAC9B,QAAQ,EAAE,QAAQ,QAoGrB"}
1
+ {"version":3,"file":"processHead.d.ts","sourceRoot":"","sources":["../../src/render/processHead.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAK7C,OAAO,EAAE,oBAAoB,EAAqB,MAAM,qBAAqB,CAAA;AAO7E,wBAAgB,WAAW,CACvB,QAAQ,EAAE,oBAAoB,EAC9B,QAAQ,EAAE,QAAQ,QAmHrB"}
@@ -59,6 +59,16 @@ export function processHead(headNode, headData) {
59
59
  };
60
60
  cjsHelpersScriptNode[$props].type = 'module';
61
61
  headNode[$props].children.unshift(cjsHelpersScriptNode);
62
+ // auto-reload the page whenever the dev build finishes recompiling.
63
+ // this is a plain full-page reload signal, not module-level hot patching.
64
+ const liveReloadScriptNode = {
65
+ [$type]: 'script',
66
+ [$props]: {
67
+ 'data--t-internal': undefined,
68
+ children: `(function(){` + `var es=new EventSource('${siteBasePath}/__tungsten_live_reload__');` + `es.addEventListener('js',function(){location.reload()});` + `es.addEventListener('css',function(){location.reload()});` + `})();`
69
+ }
70
+ };
71
+ headNode[$props].children.unshift(liveReloadScriptNode);
62
72
  }
63
73
  const {
64
74
  content: _,
@@ -1 +1 @@
1
- {"version":3,"file":"processProps.d.ts","sourceRoot":"","sources":["../../src/render/processProps.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAA;AAuBjE,wBAAgB,YAAY,CAAC,KAAK,EAAE,6BAA6B,iCA2ChE"}
1
+ {"version":3,"file":"processProps.d.ts","sourceRoot":"","sources":["../../src/render/processProps.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAA;AAoBjE,wBAAgB,YAAY,CAAC,KAAK,EAAE,6BAA6B,iCA8ChE"}
@@ -1,10 +1,19 @@
1
1
  import { objects } from 'sxy-lib';
2
2
  import { jssToCss } from "../style/jssToCss.js";
3
- const omitProps = ['ref', 'onClick', 'onChange', 'onSubmit', 'onKeyDown', 'onKeyUp', 'onKeyPress', 'onMouseDown', 'onMouseUp', 'onMouseMove', 'onMouseEnter', 'onMouseLeave', 'onMouseOver', 'onMouseOut', '__zoneHtml', '__opaque'];
3
+ import { StringIsHtmlEvent } from "../htmlEvents.js";
4
+ // Props that are instructions to Tungsten rather than HTML attributes.
5
+ //
6
+ // Event handlers are not listed here: they are recognised with
7
+ // StringIsHtmlEvent, the same test the client uses. This list previously named
8
+ // a dozen of them by hand and so let the rest — onInput, onFocus, onBlur,
9
+ // onScroll and the rest of htmlEvents — through into the markup as attributes
10
+ // holding a stringified function, and left the server and client disagreeing
11
+ // about which props are attributes at all.
12
+ const omitProps = ['ref', '__zoneHtml', '__opaque', 'dangerouslySetInnerHTML'];
4
13
  //// !!!! We need to handle bind here
5
14
  export function processProps(props) {
6
15
  props = objects.filter(props, key => {
7
- return !omitProps.includes(key);
16
+ return !omitProps.includes(key) && !StringIsHtmlEvent(key);
8
17
  });
9
18
  // if ('className' in props) {
10
19
  // if ('class' in props) {
@@ -36,6 +45,9 @@ export function processProps(props) {
36
45
  } else {
37
46
  props.style = props.css;
38
47
  }
48
+ // css is Tungsten's raw-string form of style, not an HTML attribute.
49
+ // It cannot go in omitProps, which is applied before it is read here.
50
+ delete props.css;
39
51
  }
40
52
  //delete props.bind
41
53
  return props;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=processProps.sxyt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"processProps.sxyt.d.ts","sourceRoot":"","sources":["../../src/render/processProps.sxyt.ts"],"names":[],"mappings":""}
@@ -0,0 +1,90 @@
1
+ import { describe } from 'sxy-test-runner';
2
+ import { expect } from 'chai';
3
+ import { processProps } from "./processProps.js";
4
+ import { htmlEvents } from "../htmlEvents.js";
5
+ describe('processProps()', ({
6
+ it
7
+ }) => {
8
+ it('keeps ordinary attributes', () => {
9
+ expect(processProps({
10
+ name: 'a',
11
+ title: 't',
12
+ 'data-x': '1'
13
+ })).to.deep.equal({
14
+ name: 'a',
15
+ title: 't',
16
+ 'data-x': '1'
17
+ });
18
+ });
19
+ // The list of handlers used to be written out by hand and missed most of
20
+ // them, so anything not named leaked into the markup as an attribute
21
+ // holding a stringified function.
22
+ it('strips every event handler Tungsten recognises, not a hand-picked few', () => {
23
+ const handlers = Object.fromEntries(Object.keys(htmlEvents).map(name => [name, () => undefined]));
24
+ expect(processProps({
25
+ ...handlers,
26
+ name: 'a'
27
+ })).to.deep.equal({
28
+ name: 'a'
29
+ });
30
+ });
31
+ it('strips the handlers that were missing from the old list', () => {
32
+ const kept = processProps({
33
+ onInput: () => undefined,
34
+ onFocus: () => undefined,
35
+ onBlur: () => undefined,
36
+ onScroll: () => undefined,
37
+ onPaste: () => undefined,
38
+ name: 'a'
39
+ });
40
+ expect(Object.keys(kept)).to.deep.equal(['name']);
41
+ });
42
+ it('strips props that are instructions to Tungsten rather than attributes', () => {
43
+ const kept = processProps({
44
+ ref: {
45
+ current: null
46
+ },
47
+ __zoneHtml: '<b>x</b>',
48
+ __opaque: true,
49
+ dangerouslySetInnerHTML: {
50
+ __html: '<b>x</b>'
51
+ },
52
+ name: 'a'
53
+ });
54
+ expect(Object.keys(kept)).to.deep.equal(['name']);
55
+ });
56
+ it('renames className to class', () => {
57
+ expect(processProps({
58
+ className: 'a b'
59
+ })).to.deep.equal({
60
+ class: 'a b'
61
+ });
62
+ // a raw class prop is dropped; className is the one that reaches HTML
63
+ expect(processProps({
64
+ class: 'ignored'
65
+ })).to.deep.equal({});
66
+ });
67
+ it('turns a style object into a css string', () => {
68
+ expect(processProps({
69
+ style: {
70
+ backgroundColor: 'red'
71
+ }
72
+ }).style).to.equal('background-color: red;');
73
+ });
74
+ it('folds css into style and does not leave css behind as an attribute', () => {
75
+ const kept = processProps({
76
+ css: 'color: red'
77
+ });
78
+ expect(kept.style).to.equal('color: red');
79
+ expect('css' in kept).to.equal(false);
80
+ });
81
+ it('does not mutate the props it is given', () => {
82
+ const props = {
83
+ className: 'a',
84
+ onClick: () => undefined,
85
+ name: 'n'
86
+ };
87
+ processProps(props);
88
+ expect(Object.keys(props).sort()).to.deep.equal(['className', 'name', 'onClick']);
89
+ });
90
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"renderingTreeToHtml.d.ts","sourceRoot":"","sources":["../../src/render/renderingTreeToHtml.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAY7C,OAAO,EACH,KAAK,aAAa,EAAE,KAAK,iBAAiB,EAG7C,MAAM,qBAAqB,CAAA;AAS5B,wBAAsB,mBAAmB,CACrC,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAC1D,UAAU,EAAE,iBAAiB,GAAG,IAAI,GACrC,OAAO,CAAC,MAAM,CAAC,CA4IjB"}
1
+ {"version":3,"file":"renderingTreeToHtml.d.ts","sourceRoot":"","sources":["../../src/render/renderingTreeToHtml.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAY7C,OAAO,EACH,KAAK,aAAa,EAAE,KAAK,iBAAiB,EAG7C,MAAM,qBAAqB,CAAA;AAU5B,wBAAsB,mBAAmB,CACrC,IAAI,EAAE,aAAa,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAC1D,UAAU,EAAE,iBAAiB,GAAG,IAAI,GACrC,OAAO,CAAC,MAAM,CAAC,CAwJjB"}