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,99 @@
1
+ import { describe } from 'sxy-test-runner';
2
+ import { expect } from 'chai';
3
+ import { innerHtmlFromProps } from "./innerHtmlFromProps.js";
4
+ import { renderingNodeToHtml } from "./render/renderingTreeToHtml.js";
5
+ import { $props, $type } from "./contentNodeSymbols.js";
6
+ const props = value => value;
7
+ // `found: true` short-circuits the <head> handling, which is all these
8
+ // element-level tests need from HeadData.
9
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
10
+ const noHeadData = {
11
+ found: true
12
+ };
13
+ const renderHtml = (type, nodeProps) => renderingNodeToHtml(
14
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
15
+ {
16
+ [$type]: type,
17
+ [$props]: nodeProps
18
+ }, false, noHeadData, null);
19
+ describe('innerHtmlFromProps()', ({
20
+ it
21
+ }) => {
22
+ it('returns null when the element does not use dangerouslySetInnerHTML', () => {
23
+ expect(innerHtmlFromProps('div', props({
24
+ children: 'hello'
25
+ }), true)).to.equal(null);
26
+ });
27
+ it('returns the raw markup', () => {
28
+ expect(innerHtmlFromProps('div', props({
29
+ dangerouslySetInnerHTML: {
30
+ __html: '<b>hi</b>'
31
+ }
32
+ }), false)).to.equal('<b>hi</b>');
33
+ });
34
+ it('rejects an element carrying both raw markup and children', () => {
35
+ expect(() => innerHtmlFromProps('div', props({
36
+ dangerouslySetInnerHTML: {
37
+ __html: '<b>hi</b>'
38
+ },
39
+ children: 'hello'
40
+ }), true)).to.throw(/both dangerouslySetInnerHTML and children/);
41
+ });
42
+ it('rejects a prop that is not of the form { __html }', () => {
43
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
44
+ const bad = {
45
+ dangerouslySetInnerHTML: '<b>hi</b>'
46
+ };
47
+ expect(() => innerHtmlFromProps('div', bad, false)).to.throw(/{ __html: '...' }/);
48
+ });
49
+ it('rejects a null or undefined __html', () => {
50
+ // eslint-disable-next-line no-type-assertion/no-type-assertion
51
+ const nulled = {
52
+ dangerouslySetInnerHTML: {
53
+ __html: null
54
+ }
55
+ };
56
+ expect(() => innerHtmlFromProps('div', nulled, false)).to.throw(/__html is null/);
57
+ });
58
+ });
59
+ describe('dangerouslySetInnerHTML rendering', ({
60
+ it
61
+ }) => {
62
+ it('writes the markup into the element unescaped', async () => {
63
+ expect(await renderHtml('div', {
64
+ dangerouslySetInnerHTML: {
65
+ __html: '<p>Bonuses.</p>'
66
+ }
67
+ })).to.equal('<div><p>Bonuses.</p></div>');
68
+ });
69
+ it('does not leak the prop into the markup as an attribute', async () => {
70
+ const html = await renderHtml('div', {
71
+ id: 'tip',
72
+ dangerouslySetInnerHTML: {
73
+ __html: '<b>hi</b>'
74
+ }
75
+ });
76
+ expect(html).to.not.include('dangerouslySetInnerHTML');
77
+ expect(html).to.not.include('[object Object]');
78
+ expect(html).to.equal('<div id="tip"><b>hi</b></div>');
79
+ });
80
+ it('escapes ordinary children, so the switch is per element', async () => {
81
+ expect(await renderHtml('div', {
82
+ children: '<p>Bonuses.</p>'
83
+ })).to.equal('<div>&lt;p&gt;Bonuses.&lt;/p&gt;</div>');
84
+ });
85
+ it('rejects raw markup on a void element', async () => {
86
+ let thrown = null;
87
+ try {
88
+ await renderHtml('br', {
89
+ dangerouslySetInnerHTML: {
90
+ __html: '<b>hi</b>'
91
+ }
92
+ });
93
+ } catch (error) {
94
+ thrown = error;
95
+ }
96
+ expect(thrown).to.be.an('error');
97
+ expect(thrown.message).to.include('void type');
98
+ });
99
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What raw markup is permitted to contain.
3
+ *
4
+ * Anything outside the list is dropped. A disallowed *element* is unwrapped
5
+ * rather than deleted — its children take its place — so filtering never
6
+ * silently loses someone's words. The exception is elements whose content is
7
+ * program text rather than prose (`script`, `style`), which go entirely.
8
+ */
9
+ export type HtmlAllowList = {
10
+ /** Tag names that may be rendered. */
11
+ tags: readonly string[];
12
+ /** Attributes allowed on any allowed tag. */
13
+ attributes: readonly string[];
14
+ /** Further attributes allowed only on the named tags. */
15
+ tagAttributes?: Readonly<Record<string, readonly string[]>> | undefined;
16
+ };
17
+ //# sourceMappingURL=HtmlAllowList.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HtmlAllowList.d.ts","sourceRoot":"","sources":["../../src/rawHtml/HtmlAllowList.ts"],"names":[],"mappings":"AACA;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG;IACxB,sCAAsC;IACtC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IACvB,6CAA6C;IAC7C,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,yDAAyD;IACzD,aAAa,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,GAAG,SAAS,CAAA;CAC1E,CAAA"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Decodes HTML character references in text taken from raw markup.
3
+ *
4
+ * Never throws. An entity this does not recognise is left as written rather
5
+ * than dropped, so no content is ever silently lost.
6
+ */
7
+ export declare function decodeHtmlEntities(text: string): string;
8
+ //# sourceMappingURL=decodeHtmlEntities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"decodeHtmlEntities.d.ts","sourceRoot":"","sources":["../../src/rawHtml/decodeHtmlEntities.ts"],"names":[],"mappings":"AA0CA;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOvD"}
@@ -0,0 +1,105 @@
1
+ // The named entities that turn up in real authored content. This is not the
2
+ // full HTML5 table — that is thousands of entries — and it does not need to be:
3
+ // anything unrecognised is left exactly as written, which is what a reader
4
+ // sees today anyway.
5
+ const namedEntities = {
6
+ amp: '&',
7
+ lt: '<',
8
+ gt: '>',
9
+ quot: '"',
10
+ apos: '\'',
11
+ nbsp: '\u00a0',
12
+ ensp: '\u2002',
13
+ emsp: '\u2003',
14
+ thinsp: '\u2009',
15
+ shy: '\u00ad',
16
+ pound: '£',
17
+ euro: '€',
18
+ yen: '¥',
19
+ cent: '¢',
20
+ curren: '¤',
21
+ copy: '©',
22
+ reg: '®',
23
+ trade: '™',
24
+ sect: '§',
25
+ para: '¶',
26
+ dagger: '†',
27
+ Dagger: '‡',
28
+ deg: '°',
29
+ plusmn: '±',
30
+ micro: 'µ',
31
+ middot: '·',
32
+ bull: '•',
33
+ hellip: '…',
34
+ mdash: '—',
35
+ ndash: '–',
36
+ lsquo: '‘',
37
+ rsquo: '’',
38
+ sbquo: '‚',
39
+ ldquo: '“',
40
+ rdquo: '”',
41
+ bdquo: '„',
42
+ laquo: '«',
43
+ raquo: '»',
44
+ times: '×',
45
+ divide: '÷',
46
+ minus: '−',
47
+ frac12: '½',
48
+ frac14: '¼',
49
+ frac34: '¾',
50
+ sup1: '¹',
51
+ sup2: '²',
52
+ sup3: '³',
53
+ ordm: 'º',
54
+ ordf: 'ª',
55
+ larr: '←',
56
+ uarr: '↑',
57
+ rarr: '→',
58
+ darr: '↓',
59
+ harr: '↔',
60
+ ne: '≠',
61
+ le: '≤',
62
+ ge: '≥',
63
+ infin: '∞',
64
+ asymp: '≈',
65
+ prime: '′',
66
+ Prime: '″',
67
+ alpha: 'α',
68
+ beta: 'β',
69
+ gamma: 'γ',
70
+ delta: 'δ',
71
+ pi: 'π',
72
+ mu: 'μ',
73
+ omega: 'ω'
74
+ };
75
+ // REGEX:
76
+ // &
77
+ // then one of
78
+ // # followed by digits -- decimal numeric reference
79
+ // #x followed by hex digits (either case) -- hex numeric reference
80
+ // a letter followed by letters/digits -- named reference
81
+ // then ;
82
+ const entityPattern = /&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
83
+ function decodeNumeric(body) {
84
+ const hex = body[1] === 'x' || body[1] === 'X';
85
+ const codePoint = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
86
+ if (Number.isNaN(codePoint)) return null;
87
+ // Surrogates and out-of-range values make fromCodePoint throw, and a
88
+ // malformed entity must never take the page down.
89
+ if (codePoint <= 0 || codePoint > 0x10ffff) return null;
90
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) return null;
91
+ return String.fromCodePoint(codePoint);
92
+ }
93
+ /**
94
+ * Decodes HTML character references in text taken from raw markup.
95
+ *
96
+ * Never throws. An entity this does not recognise is left as written rather
97
+ * than dropped, so no content is ever silently lost.
98
+ */
99
+ export function decodeHtmlEntities(text) {
100
+ if (!text.includes('&')) return text;
101
+ return text.replace(entityPattern, (match, body) => {
102
+ if (body.startsWith('#')) return decodeNumeric(body) ?? match;
103
+ return namedEntities[body] ?? match;
104
+ });
105
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Elements whose content is text, not markup, up to their closing tag.
3
+ *
4
+ * `escapable` ones hold ordinary text, so their character references are
5
+ * decoded; `literal` ones hold program text, which is taken exactly as written.
6
+ */
7
+ export declare const htmlRawTextTags: Record<string, 'literal' | 'escapable'>;
8
+ /**
9
+ * Table-structural elements, which may not hold text directly.
10
+ *
11
+ * A browser moves text out of these to just before the table it is in — the
12
+ * spec calls it foster parenting — so the parser has to do the same, or the
13
+ * tree and the DOM built from the rendered markup disagree. Whitespace alone
14
+ * is left where it is, which is also what a browser does.
15
+ *
16
+ * Only tables behave this way. `ul`, `ol`, `dl` and `select` keep stray text
17
+ * exactly where it was written.
18
+ */
19
+ export declare const htmlTableStructureTags: Record<string, true>;
20
+ export declare const htmlImpliedEndTags: Record<string, string[]>;
21
+ /** Whether an open `openTag` is implicitly closed by a start tag `startTag`. */
22
+ export declare function impliedEndTag(openTag: string, startTag: string): boolean;
23
+ //# sourceMappingURL=htmlParsingTags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"htmlParsingTags.d.ts","sourceRoot":"","sources":["../../src/rawHtml/htmlParsingTags.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,GAAG,WAAW,CAKnE,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAMvD,CAAA;AAYD,eAAO,MAAM,kBAAkB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAcvD,CAAA;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAExE"}
@@ -0,0 +1,55 @@
1
+ // Tag data the raw-HTML parser needs, kept together because the rules only
2
+ // make sense as a set.
3
+ /**
4
+ * Elements whose content is text, not markup, up to their closing tag.
5
+ *
6
+ * `escapable` ones hold ordinary text, so their character references are
7
+ * decoded; `literal` ones hold program text, which is taken exactly as written.
8
+ */
9
+ export const htmlRawTextTags = {
10
+ script: 'literal',
11
+ style: 'literal',
12
+ textarea: 'escapable',
13
+ title: 'escapable'
14
+ };
15
+ /**
16
+ * Table-structural elements, which may not hold text directly.
17
+ *
18
+ * A browser moves text out of these to just before the table it is in — the
19
+ * spec calls it foster parenting — so the parser has to do the same, or the
20
+ * tree and the DOM built from the rendered markup disagree. Whitespace alone
21
+ * is left where it is, which is also what a browser does.
22
+ *
23
+ * Only tables behave this way. `ul`, `ol`, `dl` and `select` keep stray text
24
+ * exactly where it was written.
25
+ */
26
+ export const htmlTableStructureTags = {
27
+ table: true,
28
+ thead: true,
29
+ tbody: true,
30
+ tfoot: true,
31
+ tr: true
32
+ };
33
+ // Elements a start tag implicitly closes. The browser applies these rules
34
+ // whatever the markup says, so the parser has to as well — otherwise the tree
35
+ // nests where the DOM does not, which is the mismatch that breaks hydration.
36
+ const closedByBlock = ['address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul'];
37
+ export const htmlImpliedEndTags = {
38
+ p: closedByBlock,
39
+ li: ['li'],
40
+ dt: ['dt', 'dd'],
41
+ dd: ['dt', 'dd'],
42
+ option: ['option', 'optgroup'],
43
+ optgroup: ['optgroup'],
44
+ tr: ['tr', 'tbody', 'tfoot', 'thead'],
45
+ td: ['td', 'th', 'tr', 'tbody', 'tfoot', 'thead'],
46
+ th: ['td', 'th', 'tr', 'tbody', 'tfoot', 'thead'],
47
+ thead: ['tbody', 'tfoot'],
48
+ tbody: ['tbody', 'tfoot'],
49
+ tfoot: ['tbody'],
50
+ a: ['a']
51
+ };
52
+ /** Whether an open `openTag` is implicitly closed by a start tag `startTag`. */
53
+ export function impliedEndTag(openTag, startTag) {
54
+ return htmlImpliedEndTags[openTag]?.includes(startTag) ?? false;
55
+ }
@@ -0,0 +1,35 @@
1
+ import type { ContentNode } from '../ContentNode.js';
2
+ import type { HtmlAllowList } from './HtmlAllowList.js';
3
+ export type ParseHtmlOptions = {
4
+ /**
5
+ * Drop `<script>` elements and `javascript:` URLs. On by default, because
6
+ * raw markup usually comes from outside the application.
7
+ *
8
+ * `on*` handler attributes are always dropped, sanitizing or not: Tungsten
9
+ * reads those props as event listeners, and a string where a function is
10
+ * expected breaks hydration.
11
+ */
12
+ sanitize?: boolean | undefined;
13
+ /**
14
+ * Keep only the tags and attributes on this list. Off by default — without
15
+ * one, everything parsed is kept, subject to `sanitize`.
16
+ *
17
+ * See `safeHtmlAllowList` for a ready-made list for authored rich text.
18
+ */
19
+ allowList?: HtmlAllowList | undefined;
20
+ };
21
+ /**
22
+ * Parses raw HTML into a Tungsten content tree.
23
+ *
24
+ * Never throws, whatever it is given. Unclosed tags are closed, stray end tags
25
+ * are ignored, and anything that is not markup is kept as text — the result is
26
+ * always a renderable tree.
27
+ *
28
+ * The point of parsing rather than injecting the markup is that the result is
29
+ * an ordinary tree: it renders, hydrates and reconciles like any other content,
30
+ * with no opaque region the framework cannot see into. That means the parser
31
+ * has to apply the browser's own nesting rules, or the tree it produces and the
32
+ * DOM the browser builds from the rendered markup would disagree.
33
+ */
34
+ export declare function parseHtml(html: string, options?: ParseHtmlOptions): ContentNode;
35
+ //# sourceMappingURL=parseHtml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parseHtml.d.ts","sourceRoot":"","sources":["../../src/rawHtml/parseHtml.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAA8B,MAAM,mBAAmB,CAAA;AAKhF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAEvD,MAAM,MAAM,gBAAgB,GAAG;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,aAAa,GAAG,SAAS,CAAA;CACxC,CAAA;AAsID;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,WAAW,CAqGnF"}
@@ -0,0 +1,214 @@
1
+ import { $props, $type } from "../contentNodeSymbols.js";
2
+ import { htmlVoidTags } from "../render/htmlVoidTags.js";
3
+ import { tokenizeHtml } from "./tokenizeHtml.js";
4
+ import { htmlRawTextTags, htmlTableStructureTags, impliedEndTag } from "./htmlParsingTags.js";
5
+ const allowEverything = {
6
+ tag: () => true,
7
+ attribute: () => true
8
+ };
9
+ function makeAllowCheck(allowList) {
10
+ if (allowList === undefined) return allowEverything;
11
+ const tags = new Set(allowList.tags);
12
+ const attributes = new Set(allowList.attributes);
13
+ const perTag = new Map();
14
+ for (const tag in allowList.tagAttributes) {
15
+ perTag.set(tag, new Set(allowList.tagAttributes[tag]));
16
+ }
17
+ return {
18
+ tag: name => tags.has(name),
19
+ attribute: (tag, name) => attributes.has(name) || (perTag.get(tag)?.has(name) ?? false)
20
+ };
21
+ }
22
+ // REGEX: on followed by at least one letter, e.g. onclick, onerror
23
+ const eventAttribute = /^on[a-z]/i;
24
+ const urlAttributes = {
25
+ href: true,
26
+ src: true,
27
+ action: true,
28
+ formaction: true,
29
+ srcset: true,
30
+ background: true
31
+ };
32
+ /** URL attributes that navigate, where a document-producing scheme is a risk. */
33
+ const navigationAttributes = {
34
+ href: true,
35
+ action: true,
36
+ formaction: true
37
+ };
38
+ const executableSchemes = {
39
+ javascript: true,
40
+ vbscript: true
41
+ };
42
+ /**
43
+ * The scheme of a URL as a browser would read it, or `''` if it has none.
44
+ *
45
+ * Whitespace and control characters are skipped rather than rejected, because
46
+ * a browser ignores them too — `"java\tscript:alert(1)"` and `" javascript:..."`
47
+ * both run, so a check that did not skip them would be trivially side-stepped.
48
+ */
49
+ function urlScheme(url) {
50
+ let scheme = '';
51
+ for (const character of url) {
52
+ const code = character.charCodeAt(0);
53
+ if (code <= 0x20 || code === 0x7f) continue;
54
+ if (character === ':') return scheme.toLowerCase();
55
+ scheme += character;
56
+ }
57
+ return '';
58
+ }
59
+ function blockedUrl(attributeName, value) {
60
+ const scheme = urlScheme(value);
61
+ if (scheme in executableSchemes) return true;
62
+ // data: URLs can carry a whole HTML document, so they are barred from
63
+ // anything that navigates. They stay allowed on src, where inline images
64
+ // are ordinary and useful.
65
+ return scheme === 'data' && attributeName in navigationAttributes;
66
+ }
67
+ function attributesToProps(tag, attributes, sanitize, allow) {
68
+ const props = {};
69
+ for (const name in attributes) {
70
+ const value = attributes[name];
71
+ if (value === undefined) continue;
72
+ // Tungsten treats on* props as event listeners, not attributes.
73
+ if (eventAttribute.test(name)) continue;
74
+ // Checked against the written HTML name, before the mapping below.
75
+ if (!allow.attribute(tag, name.toLowerCase())) continue;
76
+ if (sanitize && name.toLowerCase() in urlAttributes && typeof value === 'string' && blockedUrl(name.toLowerCase(), value)) continue;
77
+ // `class` is dropped by processProps, which reads `className`, and a
78
+ // `style` string throws in jssToCss, which expects the object form.
79
+ // `css` is the raw-string equivalent it accepts.
80
+ if (name === 'class') props.className = value;else if (name === 'style') props.css = value;else props[name] = value;
81
+ }
82
+ return props;
83
+ }
84
+ /**
85
+ * Whether an element survives, and if not, how it goes.
86
+ *
87
+ * Elements holding program text rather than prose are dropped whole — there is
88
+ * no reader's content inside them to preserve. Everything else is unwrapped.
89
+ */
90
+ function removalFor(name, sanitize, allow) {
91
+ if (sanitize && name === 'script') return 'drop';
92
+ if (allow.tag(name)) return undefined;
93
+ return name in htmlRawTextTags ? 'drop' : 'unwrap';
94
+ }
95
+ function toContentNode({
96
+ name,
97
+ props,
98
+ children
99
+ }) {
100
+ const nodeProps = {
101
+ ...props
102
+ };
103
+ // Match the shape JSX produces: one child is passed directly, not wrapped.
104
+ if (children.length === 1) nodeProps.children = children[0];else if (children.length > 1) nodeProps.children = children;
105
+ return {
106
+ [$type]: name,
107
+ [$props]: nodeProps
108
+ };
109
+ }
110
+ /**
111
+ * Parses raw HTML into a Tungsten content tree.
112
+ *
113
+ * Never throws, whatever it is given. Unclosed tags are closed, stray end tags
114
+ * are ignored, and anything that is not markup is kept as text — the result is
115
+ * always a renderable tree.
116
+ *
117
+ * The point of parsing rather than injecting the markup is that the result is
118
+ * an ordinary tree: it renders, hydrates and reconciles like any other content,
119
+ * with no opaque region the framework cannot see into. That means the parser
120
+ * has to apply the browser's own nesting rules, or the tree it produces and the
121
+ * DOM the browser builds from the rendered markup would disagree.
122
+ */
123
+ export function parseHtml(html, options = {}) {
124
+ if (typeof html !== 'string' || html === '') return null;
125
+ const sanitize = options.sanitize !== false;
126
+ const allow = makeAllowCheck(options.allowList);
127
+ const root = {
128
+ name: '',
129
+ props: {},
130
+ children: []
131
+ };
132
+ const stack = [root];
133
+ // Always non-empty: the root is never popped.
134
+ const current = () => stack[stack.length - 1];
135
+ const close = downTo => {
136
+ while (stack.length > downTo) {
137
+ const element = stack.pop(); // eslint-disable-line no-type-assertion/no-type-assertion -- guarded by the loop
138
+ if (element.removal === 'drop') continue;
139
+ if (element.removal === 'unwrap') current().children.push(...element.children);else current().children.push(toContentNode(element));
140
+ }
141
+ };
142
+ for (const token of tokenizeHtml(html)) {
143
+ if (token.kind === 'text') {
144
+ // a dropped element's text goes with it
145
+ if (current().removal === 'drop') continue;
146
+ if (token.text === '') continue;
147
+ if (current().name in htmlTableStructureTags) {
148
+ // Whitespace between table tags is dropped rather than kept.
149
+ // HTML parsers disagree over whether it survives inside a
150
+ // table, and this tree has to match whichever one the browser
151
+ // uses. Emitting none settles it: there is then nothing in the
152
+ // rendered markup for a parser to have an opinion about, and
153
+ // between table cells it makes no visual difference anyway.
154
+ if (token.text.trim() === '') continue;
155
+ // Foster parenting: a browser relocates text with something in
156
+ // it to just before the enclosing table. That table is still
157
+ // open on the stack, and is pushed to its parent only when it
158
+ // closes, so appending to the parent now places the text ahead
159
+ // of it — exactly where the browser puts it.
160
+ let tableIndex = -1;
161
+ for (let i = stack.length - 1; i > 0; i--) {
162
+ if (stack[i].name === 'table') {
163
+ tableIndex = i;
164
+ break;
165
+ }
166
+ }
167
+ const host = tableIndex > 0 ? stack[tableIndex - 1] : current();
168
+ host.children.push(token.text);
169
+ continue;
170
+ }
171
+ current().children.push(token.text);
172
+ continue;
173
+ }
174
+ if (token.kind === 'open') {
175
+ // An element that is going away is still opened and tracked, so
176
+ // the markup inside it is accounted for rather than surfacing as
177
+ // stray text in its parent.
178
+ const removal = removalFor(token.name, sanitize, allow);
179
+ // apply the browser's implied end tags before opening
180
+ while (stack.length > 1 && impliedEndTag(current().name, token.name)) {
181
+ close(stack.length - 1);
182
+ }
183
+ const element = {
184
+ name: token.name,
185
+ props: removal === undefined ? attributesToProps(token.name, token.attributes, sanitize, allow) : {},
186
+ children: [],
187
+ ...(removal === undefined ? {} : {
188
+ removal
189
+ })
190
+ };
191
+ if (token.selfClosing || token.name in htmlVoidTags) {
192
+ if (removal === undefined) current().children.push(toContentNode(element));
193
+ } else {
194
+ stack.push(element);
195
+ }
196
+ continue;
197
+ }
198
+ // close token - find the nearest matching open element
199
+ let found = -1;
200
+ for (let i = stack.length - 1; i > 0; i--) {
201
+ if (stack[i].name === token.name) {
202
+ found = i;
203
+ break;
204
+ }
205
+ }
206
+ // no match: a stray end tag, which browsers ignore
207
+ if (found !== -1) close(found);
208
+ }
209
+ // close anything the markup left open
210
+ close(1);
211
+ if (root.children.length === 0) return null;
212
+ if (root.children.length === 1) return root.children[0] ?? null;
213
+ return root.children;
214
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=parseHtml.sxyt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parseHtml.sxyt.d.ts","sourceRoot":"","sources":["../../src/rawHtml/parseHtml.sxyt.ts"],"names":[],"mappings":""}