@fluixi/template-parser 0.1.0-alpha.4 → 1.0.0-alpha.100

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/dist/parser.mjs CHANGED
@@ -1 +1,809 @@
1
- function N(e){let t=(i,n)=>{i.parent=n;let r=B(i);for(let s of r)t(s,i)};return t(e,null),e}function B(e){switch(e.kind){case"Root":case"Element":case"Component":case"Fragment":return e.children;default:return[]}}var m=class{constructor(t){this.pieces=t;this.pi=0;this.oi=0;this.normalize()}normalize(){for(;this.pi<this.pieces.length;){let t=this.pieces[this.pi];if(t.kind==="static"&&this.oi>=t.text.length){this.pi++,this.oi=0;continue}break}}eof(){return this.pi>=this.pieces.length}pos(){if(this.eof()){let i=this.pieces[this.pieces.length-1];return i?i.kind==="static"?i.start+i.text.length:i.end:0}let t=this.pieces[this.pi];return t.kind==="static"?t.start+this.oi:t.start}atHole(){return!this.eof()&&this.pieces[this.pi].kind==="hole"}holeFollows(){if(this.eof())return!1;let t=this.pieces[this.pi];if(t.kind!=="static"||this.oi+1<t.text.length)return!1;let i=this.pieces[this.pi+1];return i!==void 0&&i.kind==="hole"}takeHole(){let t=this.pieces[this.pi];if(t.kind!=="hole")throw new Error("takeHole called off a hole");return this.pi++,this.oi=0,this.normalize(),{index:t.index,loc:{start:t.start,end:t.end}}}peek(t=0){if(this.eof())return"";let i=this.pieces[this.pi];return i.kind!=="static"?"":i.text[this.oi+t]??""}startsWith(t){if(this.eof())return!1;let i=this.pieces[this.pi];return i.kind!=="static"?!1:i.text.startsWith(t,this.oi)}advance(){if(this.eof())return"";let t=this.pieces[this.pi];if(t.kind!=="static")return"";let i=t.text[this.oi++]??"";return this.normalize(),i}readWhile(t){let i="";for(;!this.eof()&&!this.atHole();){let n=this.peek();if(n===""||!t(n))break;i+=n,this.oi++}return this.normalize(),i}skipWhitespace(){this.readWhile(t=>/\s/.test(t))}span(t){return{start:t,end:this.pos()}}};var b=new Set(["capture","once","passive","prevent","stop","self"]);function p(e,t){return e.value&&e.value.kind==="hole"?e.value.hole:(e.report("invalid-binding",`${t} requires an expression value: ${e.name}=\${…}`),-1)}var x=(e,t)=>({kind:"Attribute",name:t?e.name.slice(1):e.name,value:e.value,boolean:t,loc:e.loc}),g=[{id:"event",match:e=>e[0]==="@"||e.startsWith("on:")||/^on[A-Z]/.test(e),build:e=>{let t,i;e.name[0]==="@"?(t="at",i=e.name.slice(1)):e.name.startsWith("on:")?(t="colon",i=e.name.slice(3)):(t="on",i=e.name.slice(2));let[n,...r]=i.split("."),s=n.toLowerCase();for(let o of r)b.has(o)||e.report("invalid-directive",`Unknown event modifier ".${o}" — expected one of ${[...b].join(", ")}`);return{kind:"EventBinding",name:s,syntax:t,modifiers:r,hole:p(e,"event binding"),loc:e.loc}}},{id:"bind",elementOnly:!0,match:e=>e.startsWith("bind:"),build:e=>({kind:"BindDirective",name:e.name.slice(5),hole:p(e,"two-way binding"),loc:e.loc})},{id:"class-directive",elementOnly:!0,match:e=>e.startsWith("class:"),build:e=>({kind:"ClassDirective",name:e.name.slice(6),hole:p(e,"class directive"),loc:e.loc})},{id:"style-directive",elementOnly:!0,match:e=>e.startsWith("style:"),build:e=>({kind:"StyleDirective",name:e.name.slice(6),hole:p(e,"style directive"),loc:e.loc})},{id:"load",match:e=>e.startsWith("load:"),build:e=>({kind:"LoadDirective",strategy:e.name.slice(5),modifier:e.value&&e.value.kind==="static"?e.value.value:null,loc:e.loc})},{id:"use",match:e=>e==="use"||e.startsWith("use:"),build:e=>({kind:"UseDirective",name:e.name.startsWith("use:")?e.name.slice(4):null,hole:e.value&&e.value.kind==="hole"?e.value.hole:null,loc:e.loc})},{id:"if",elementOnly:!0,match:e=>e==="if",build:e=>({kind:"IfDirective",hole:p(e,"if directive"),loc:e.loc})},{id:"each",elementOnly:!0,match:e=>e==="each",build:e=>({kind:"EachDirective",hole:p(e,"each directive"),key:null,loc:e.loc})},{id:"else",elementOnly:!0,match:e=>e==="else",build:e=>(e.value!==null&&e.report("invalid-directive","`else` takes no value"),{kind:"ElseDirective",loc:e.loc})},{id:"ref",match:e=>e==="ref",build:e=>({kind:"RefBinding",hole:p(e,"ref binding"),loc:e.loc})},{id:"property",match:e=>e[0]===".",build:e=>({kind:"PropertyBinding",name:e.name.slice(1),hole:p(e,"property binding"),loc:e.loc})},{id:"boolean",match:e=>e[0]==="?",build:e=>x(e,!0)}];function y(e,t=g,i={component:!1}){for(let n of t)if(!(n.elementOnly&&i.component)&&n.match(e.name))return n.build(e);return x(e,!1)}var D=new Set(["svg","path","circle","rect","line","polygon","polyline","ellipse","g","defs","clipPath","text"]),E=new Set(["script","style","textarea","title"]),S=new Set(["area","base","br","col","embed","hr","img","input","link","meta","param","source","track","wbr"]);function C(e){return e.length>0&&(e[0]!==e[0].toLowerCase()||e.includes("."))}var R=new Set(["a","button","input","select","textarea","summary","details","option","label"]),W=new Set(["keydown","keyup","keypress"]);function u(e,t){return e.attributes.find(i=>"name"in i&&i.name===t)}function w(e){return e.attributes.some(t=>t.kind==="Spread")}function f(e,t){let i=u(e,t);if(!i||i.kind!=="Attribute"||!i.value)return;let n=i.value;return n.kind==="static"?n.value:void 0}function L(e){let t=f(e,"role");return t==="presentation"||t==="none"?!0:f(e,"aria-hidden")==="true"}function T(e,t){return e.attributes.some(i=>i.kind==="EventBinding"&&t(i.name))}function A(e){let t=[],i=(r,s,o)=>{t.push({code:s,message:o,severity:"warning",loc:r.loc})},n=r=>{for(let s of r){s.kind==="Element"&&H(s,i);let o="children"in s?s.children:void 0;o&&n(o)}};return n(e.children),t}function H(e,t){if(w(e))return;let i=e.tag.toLowerCase();i==="img"&&!u(e,"alt")&&!L(e)&&t(e,"a11y-img-alt",'An `img` needs an `alt`. Use `alt=""` when the image is decorative.'),i==="a"&&!u(e,"href")&&!u(e,"role")&&t(e,"a11y-anchor-href","An `a` without `href` is not a link. Use a `button` for an action."),i==="iframe"&&!u(e,"title")&&t(e,"a11y-iframe-title","An `iframe` needs a `title` describing its content.");let n=f(e,"tabindex")??f(e,"tabIndex");n!==void 0&&Number(n)>0&&t(e,"a11y-positive-tabindex","A positive `tabindex` reorders tab navigation for the entire page. Use `0`, or restructure the markup."),!R.has(i)&&T(e,r=>r==="click")&&!u(e,"role")&&!T(e,r=>W.has(r))&&t(e,"a11y-click-without-keyboard",`A click handler on \`${i}\` is unreachable by keyboard. Use a \`button\`, or add a \`role\` and a key handler.`)}var P=/[A-Za-z0-9\-_:@.?]/,k=class{constructor(t,i){this.diagnostics=[];this.r=new m(t),this.svg=i.svg??!1,this.directives=i.directives??g,this.accessibility=i.accessibility??!0}parse(){let t=this.r.pos(),i=[];for(;!this.r.eof()&&(i.push(...this.parseChildren()),!this.r.eof());){let r=this.r.pos();this.consumeCloseTag(),this.report("stray-close-tag","error","Close tag without a matching open tag",this.r.span(r))}let n={kind:"Root",children:i,loc:this.r.span(t)};return N(n),this.validateTree(n),this.accessibility&&this.diagnostics.push(...A(n)),{root:n,diagnostics:this.diagnostics}}validateTree(t){let i=n=>{let r=null;for(let s of n)s.kind==="Text"&&/^\s*$/.test(s.value)||(s.kind==="Element"||s.kind==="Component"?(s.attributes.some(l=>l.kind==="ElseDirective")&&(r!=null&&(r.kind==="Element"||r.kind==="Component")&&r.attributes.some(a=>a.kind==="IfDirective")||this.report("else-without-if","error","`else` must immediately follow an element with `if`",s.loc)),r=s):r=null,"children"in s&&s.children&&i(s.children))};i(t.children)}parseChildren(){let t=[];for(;!this.r.eof()&&!this.r.startsWith("</");){if(this.r.startsWith("<!--")){t.push(this.parseComment());continue}if(this.isFragmentStart()){t.push(this.parseFragment());continue}if(this.isDynamicTagStart()){t.push(this.parseDynamicComponent());continue}if(this.isTagStart()){t.push(this.parseElement());continue}if(this.r.atHole()){let n=this.r.takeHole(),r={kind:"Expression",hole:n.index,loc:n.loc};t.push(r);continue}let i=this.readText();i&&t.push(i)}return t}isTagStart(){return this.r.peek()==="<"&&/[A-Za-z]/.test(this.r.peek(1))}isFragmentStart(){return this.r.peek()==="<"&&this.r.peek(1)===">"}isDynamicTagStart(){return this.r.peek()==="<"&&this.r.holeFollows()}parseDynamicComponent(){let t=this.r.pos();this.r.advance();let i=this.r.takeHole().index,n=this.parseAttributes(!0);this.r.skipWhitespace();let r=!1,s=[];return this.r.startsWith("/>")?(this.r.advance(),this.r.advance(),r=!0):(this.r.peek()===">"&&this.r.advance(),s=this.parseChildren(),this.r.startsWith("</")?(this.r.advance(),this.r.advance(),this.r.atHole()?this.r.takeHole():this.readName(),this.r.skipWhitespace(),this.r.peek()===">"&&this.r.advance()):this.report("unclosed-tag","error","Unclosed dynamic component",this.r.span(t))),{kind:"Component",tag:"",tagHole:i,attributes:n,children:s,selfClosing:r,loc:this.r.span(t)}}readText(){let t=this.r.pos(),i="";for(;!this.r.eof()&&!this.r.atHole()&&!(this.r.startsWith("</")||this.r.startsWith("<!--")||this.isTagStart()||this.isFragmentStart()||this.isDynamicTagStart());)i+=this.r.advance();return i?{kind:"Text",value:i,raw:!1,loc:this.r.span(t)}:null}parseComment(){let t=this.r.pos();this.r.advance(),this.r.advance(),this.r.advance(),this.r.advance();let i="";for(;!this.r.eof()&&!this.r.startsWith("-->");){if(this.r.atHole()){this.r.takeHole(),i+="\0";continue}i+=this.r.advance()}return this.r.startsWith("-->")?(this.r.advance(),this.r.advance(),this.r.advance()):this.report("unclosed-comment","warning","Unterminated comment",this.r.span(t)),{kind:"Comment",value:i,loc:this.r.span(t)}}parseFragment(){let t=this.r.pos();this.r.advance(),this.r.advance();let i=this.parseChildren();if(this.r.startsWith("</")){let n=this.consumeCloseTag();n&&this.report("mismatched-close-tag","error",`Expected </> to close fragment, got </${n}>`,this.r.span(t))}else this.report("unclosed-tag","error","Unterminated fragment",this.r.span(t));return{kind:"Fragment",children:i,loc:this.r.span(t)}}parseElement(){let t=this.r.pos();this.r.advance();let i=this.readName(),n=C(i),r=this.parseAttributes(n);this.foldEachKey(r,t),this.r.skipWhitespace();let s=i.toLowerCase(),o=!n&&E.has(s),l=!n&&S.has(s),a=!1,c=[];if(this.r.startsWith("/>"))this.r.advance(),this.r.advance(),a=!0;else if(this.r.peek()===">"&&this.r.advance(),!l)if(c=o?this.parseRawText(i):this.parseChildren(),this.r.startsWith("</")){let v=this.consumeCloseTag();v&&v!==i&&this.report("mismatched-close-tag","error",`Expected </${i}>, got </${v}>`,this.r.span(t))}else this.report("unclosed-tag","error",`Unclosed <${i}>`,this.r.span(t));let h=this.r.span(t);if(n)return{kind:"Component",tag:i,tagHole:null,attributes:r,children:c,selfClosing:a,loc:h};let d=this.svg||D.has(i)?"svg":"html";return{kind:"Element",tag:i,namespace:d,attributes:r,children:c,selfClosing:a,rawText:o,loc:h}}parseRawText(t){let i=[],n="</"+t.toLowerCase(),r="",s=this.r.pos(),o=()=>{r&&i.push({kind:"Text",value:r,raw:!0,loc:this.r.span(s)}),r=""};for(;!this.r.eof()&&!this.startsWithCloseFor(n);){if(this.r.atHole()){o();let l=this.r.takeHole();i.push({kind:"Expression",hole:l.index,loc:l.loc}),s=this.r.pos();continue}r||(s=this.r.pos()),r+=this.r.advance()}return o(),i}startsWithCloseFor(t){let i=0;for(;i<t.length;){let n=this.r.peek(i);if(n===""||n.toLowerCase()!==t[i])return!1;i++}return!0}parseAttributes(t){let i=[];for(;!this.r.eof()&&(this.r.skipWhitespace(),!(this.r.peek()===">"||this.r.startsWith("/>")));){if(this.r.atHole()){let a=this.r.takeHole();i.push({kind:"Spread",hole:a.index,loc:a.loc});continue}if(this.r.startsWith("...")){let a=this.r.pos();if(this.r.advance(),this.r.advance(),this.r.advance(),this.r.atHole()){let c=this.r.takeHole();i.push({kind:"Spread",hole:c.index,loc:this.r.span(a)})}else this.report("invalid-binding","error","Spread `...` must be followed by ${…}",this.r.span(a));continue}let n=this.r.pos(),r=this.readName();if(!r){this.r.advance();continue}let s=this.r.span(n);this.r.skipWhitespace();let o=null;this.r.peek()==="="&&(this.r.advance(),this.r.skipWhitespace(),o=this.parseAttrValue());let l={name:r,nameLoc:s,value:o,loc:this.r.span(n),report:(a,c)=>this.report(a,"error",c,this.r.span(n))};i.push(y(l,this.directives,{component:t}))}return i}parseAttrValue(){if(this.r.atHole())return{kind:"hole",hole:this.r.takeHole().index};let t=this.r.peek();return t==='"'||t==="'"?this.classifyParts(this.readQuotedParts(t)):{kind:"static",value:this.r.readWhile(n=>!/[\s>]/.test(n))}}readQuotedParts(t){this.r.advance();let i=[],n="";for(;!this.r.eof()&&this.r.peek()!==t;){if(this.r.atHole()){n&&(i.push({text:n}),n=""),i.push({hole:this.r.takeHole().index});continue}let r=this.r.advance();if(r==="")break;n+=r}return n&&i.push({text:n}),this.r.peek()===t&&this.r.advance(),i}classifyParts(t){let i=t.filter(n=>"hole"in n);return i.length===0?{kind:"static",value:t.map(n=>"text"in n?n.text:"").join("")}:t.length===1?{kind:"hole",hole:i[0].hole}:{kind:"mixed",parts:t}}foldEachKey(t,i){let n=null,r=0,s=0,o=0,l=0;for(let d of t)d.kind==="EachDirective"?(n=d,s++):d.kind==="IfDirective"?r++:d.kind==="ElseDirective"?o++:d.kind==="RefBinding"&&l++;let a=this.r.span(i);s>1&&this.report("duplicate-directive","error","Multiple `each` directives on one element",a),r>1&&this.report("duplicate-directive","error","Multiple `if` directives on one element",a),l>1&&this.report("duplicate-directive","error","Multiple `ref` bindings on one element",a),r>0&&s>0&&this.report("unsupported-combination","error","`if` and `each` cannot both apply to one element — wrap one in another element",a),r>0&&o>0&&this.report("invalid-directive","error","`if` and `else` cannot be on the same element",a);let c=t.findIndex(d=>d.kind==="Attribute"&&d.name==="key");if(!n){c!==-1&&this.report("invalid-directive","error","`key` requires an `each` directive on the same element",a);return}if(c===-1)return;let h=t[c];h.kind==="Attribute"&&h.value&&(h.value.kind==="static"?n.key={static:h.value.value}:h.value.kind==="hole"&&(n.key={hole:h.value.hole})),t.splice(c,1)}readName(){return this.r.readWhile(t=>P.test(t))}consumeCloseTag(){this.r.advance(),this.r.advance();let t=this.readName();return this.r.skipWhitespace(),this.r.peek()===">"&&this.r.advance(),t}report(t,i,n,r){this.diagnostics.push({code:t,severity:i,message:n,loc:r})}};function G(e,t={}){return new k(e,t).parse()}export{G as parseTemplate};
1
+ // src/ast.ts
2
+ function setParents(root) {
3
+ const visit = (node, parent) => {
4
+ node.parent = parent;
5
+ const kids = childrenOf(node);
6
+ for (const child of kids) visit(child, node);
7
+ };
8
+ visit(root, null);
9
+ return root;
10
+ }
11
+ function childrenOf(node) {
12
+ switch (node.kind) {
13
+ case "Root":
14
+ case "Element":
15
+ case "Component":
16
+ case "Fragment":
17
+ return node.children;
18
+ default:
19
+ return [];
20
+ }
21
+ }
22
+
23
+ // src/reader.ts
24
+ var Reader = class {
25
+ // offset within the current static piece
26
+ constructor(pieces) {
27
+ this.pieces = pieces;
28
+ this.pi = 0;
29
+ // current piece index
30
+ this.oi = 0;
31
+ this.normalize();
32
+ }
33
+ /** Skip fully-consumed static pieces so `current()` is live text or a hole. */
34
+ normalize() {
35
+ while (this.pi < this.pieces.length) {
36
+ const p = this.pieces[this.pi];
37
+ if (p.kind === "static" && this.oi >= p.text.length) {
38
+ this.pi++;
39
+ this.oi = 0;
40
+ continue;
41
+ }
42
+ break;
43
+ }
44
+ }
45
+ eof() {
46
+ return this.pi >= this.pieces.length;
47
+ }
48
+ /** Absolute source offset of the cursor. */
49
+ pos() {
50
+ if (this.eof()) {
51
+ const last = this.pieces[this.pieces.length - 1];
52
+ return last ? last.kind === "static" ? last.start + last.text.length : last.end : 0;
53
+ }
54
+ const p = this.pieces[this.pi];
55
+ return p.kind === "static" ? p.start + this.oi : p.start;
56
+ }
57
+ /** True when the cursor sits exactly on a hole marker. */
58
+ atHole() {
59
+ return !this.eof() && this.pieces[this.pi].kind === "hole";
60
+ }
61
+ /** True when advancing one char would land on a hole (e.g. the `<` in `<${Tag}`). */
62
+ holeFollows() {
63
+ if (this.eof()) return false;
64
+ const p = this.pieces[this.pi];
65
+ if (p.kind !== "static") return false;
66
+ if (this.oi + 1 < p.text.length) return false;
67
+ const next = this.pieces[this.pi + 1];
68
+ return next !== void 0 && next.kind === "hole";
69
+ }
70
+ /** Consume the hole at the cursor. Caller must have checked {@link atHole}. */
71
+ takeHole() {
72
+ const p = this.pieces[this.pi];
73
+ if (p.kind !== "hole") throw new Error("takeHole called off a hole");
74
+ this.pi++;
75
+ this.oi = 0;
76
+ this.normalize();
77
+ return { index: p.index, loc: { start: p.start, end: p.end } };
78
+ }
79
+ /** Char `k` ahead within the current static segment, or '' at a boundary/hole. */
80
+ peek(k = 0) {
81
+ if (this.eof()) return "";
82
+ const p = this.pieces[this.pi];
83
+ if (p.kind !== "static") return "";
84
+ return p.text[this.oi + k] ?? "";
85
+ }
86
+ /** Whether the current static segment starts with `s` at the cursor. */
87
+ startsWith(s) {
88
+ if (this.eof()) return false;
89
+ const p = this.pieces[this.pi];
90
+ if (p.kind !== "static") return false;
91
+ return p.text.startsWith(s, this.oi);
92
+ }
93
+ /** Advance one character inside the current static segment. */
94
+ advance() {
95
+ if (this.eof()) return "";
96
+ const p = this.pieces[this.pi];
97
+ if (p.kind !== "static") return "";
98
+ const ch = p.text[this.oi++] ?? "";
99
+ this.normalize();
100
+ return ch;
101
+ }
102
+ /** Advance while `pred` holds and we stay inside one static segment. */
103
+ readWhile(pred) {
104
+ let out = "";
105
+ while (!this.eof() && !this.atHole()) {
106
+ const ch = this.peek();
107
+ if (ch === "" || !pred(ch)) break;
108
+ out += ch;
109
+ this.oi++;
110
+ }
111
+ this.normalize();
112
+ return out;
113
+ }
114
+ skipWhitespace() {
115
+ this.readWhile((ch) => /\s/.test(ch));
116
+ }
117
+ span(start) {
118
+ return { start, end: this.pos() };
119
+ }
120
+ };
121
+
122
+ // src/directives.ts
123
+ var EVENT_MODIFIERS = /* @__PURE__ */ new Set(["capture", "once", "passive", "prevent", "stop", "self"]);
124
+ function requireHole(raw, what) {
125
+ if (raw.value && raw.value.kind === "hole") return raw.value.hole;
126
+ raw.report("invalid-binding", `${what} requires an expression value: ${raw.name}=\${…}`);
127
+ return -1;
128
+ }
129
+ var attribute = (raw, boolean) => ({
130
+ kind: "Attribute",
131
+ name: boolean ? raw.name.slice(1) : raw.name,
132
+ value: raw.value,
133
+ boolean,
134
+ loc: raw.loc
135
+ });
136
+ var CORE_DIRECTIVES = [
137
+ // Events: `@click=${h}`, `on:click=${h}` (native), `onClick=${h}`, each with
138
+ // optional dotted modifiers (`@click.capture.once`, `on:input.passive`).
139
+ {
140
+ id: "event",
141
+ match: (n) => n[0] === "@" || n.startsWith("on:") || /^on[A-Z]/.test(n),
142
+ build: (raw) => {
143
+ let syntax;
144
+ let rest;
145
+ if (raw.name[0] === "@") {
146
+ syntax = "at";
147
+ rest = raw.name.slice(1);
148
+ } else if (raw.name.startsWith("on:")) {
149
+ syntax = "colon";
150
+ rest = raw.name.slice(3);
151
+ } else {
152
+ syntax = "on";
153
+ rest = raw.name.slice(2);
154
+ }
155
+ const [raw_name, ...modifiers] = rest.split(".");
156
+ const name = raw_name.toLowerCase();
157
+ for (const m of modifiers) {
158
+ if (!EVENT_MODIFIERS.has(m)) {
159
+ raw.report("invalid-directive", `Unknown event modifier ".${m}" — expected one of ${[...EVENT_MODIFIERS].join(", ")}`);
160
+ }
161
+ }
162
+ return {
163
+ kind: "EventBinding",
164
+ name,
165
+ syntax,
166
+ modifiers,
167
+ hole: requireHole(raw, "event binding"),
168
+ loc: raw.loc
169
+ };
170
+ }
171
+ },
172
+ // Two-way binding: `bind:value=${signal}`.
173
+ {
174
+ id: "bind",
175
+ elementOnly: true,
176
+ match: (n) => n.startsWith("bind:"),
177
+ build: (raw) => ({
178
+ kind: "BindDirective",
179
+ name: raw.name.slice("bind:".length),
180
+ hole: requireHole(raw, "two-way binding"),
181
+ loc: raw.loc
182
+ })
183
+ },
184
+ // Dynamic class toggle: `class:active=${flag}`.
185
+ {
186
+ id: "class-directive",
187
+ elementOnly: true,
188
+ match: (n) => n.startsWith("class:"),
189
+ build: (raw) => ({
190
+ kind: "ClassDirective",
191
+ name: raw.name.slice("class:".length),
192
+ hole: requireHole(raw, "class directive"),
193
+ loc: raw.loc
194
+ })
195
+ },
196
+ // Dynamic style: `style:color=${c}`.
197
+ {
198
+ id: "style-directive",
199
+ elementOnly: true,
200
+ match: (n) => n.startsWith("style:"),
201
+ build: (raw) => ({
202
+ kind: "StyleDirective",
203
+ name: raw.name.slice("style:".length),
204
+ hole: requireHole(raw, "style directive"),
205
+ loc: raw.loc
206
+ })
207
+ },
208
+ // Load strategy: `load:visible[="200px"]`. Components only, an intrinsic element
209
+ // has no module to defer.
210
+ {
211
+ id: "load",
212
+ match: (n) => n.startsWith("load:"),
213
+ build: (raw) => ({
214
+ kind: "LoadDirective",
215
+ strategy: raw.name.slice("load:".length),
216
+ modifier: raw.value && raw.value.kind === "static" ? raw.value.value : null,
217
+ loc: raw.loc
218
+ })
219
+ },
220
+ // Custom directive: `use=${d}` or `use:tooltip[=${opts}]`.
221
+ {
222
+ id: "use",
223
+ match: (n) => n === "use" || n.startsWith("use:"),
224
+ build: (raw) => {
225
+ const named = raw.name.startsWith("use:");
226
+ return {
227
+ kind: "UseDirective",
228
+ name: named ? raw.name.slice("use:".length) : null,
229
+ hole: raw.value && raw.value.kind === "hole" ? raw.value.hole : null,
230
+ loc: raw.loc
231
+ };
232
+ }
233
+ },
234
+ // Conditional rendering: `if=${cond}`.
235
+ {
236
+ id: "if",
237
+ elementOnly: true,
238
+ match: (n) => n === "if",
239
+ build: (raw) => ({
240
+ kind: "IfDirective",
241
+ hole: requireHole(raw, "if directive"),
242
+ loc: raw.loc
243
+ })
244
+ },
245
+ // Loop rendering: `each=${items}` (a sibling `key` folds in at parse time).
246
+ {
247
+ id: "each",
248
+ elementOnly: true,
249
+ match: (n) => n === "each",
250
+ build: (raw) => ({
251
+ kind: "EachDirective",
252
+ hole: requireHole(raw, "each directive"),
253
+ key: null,
254
+ loc: raw.loc
255
+ })
256
+ },
257
+ // `else` (valueless).
258
+ {
259
+ id: "else",
260
+ elementOnly: true,
261
+ match: (n) => n === "else",
262
+ build: (raw) => {
263
+ if (raw.value !== null) raw.report("invalid-directive", "`else` takes no value");
264
+ return { kind: "ElseDirective", loc: raw.loc };
265
+ }
266
+ },
267
+ // Ref binding: `ref=${el}`.
268
+ {
269
+ id: "ref",
270
+ match: (n) => n === "ref",
271
+ build: (raw) => ({
272
+ kind: "RefBinding",
273
+ hole: requireHole(raw, "ref binding"),
274
+ loc: raw.loc
275
+ })
276
+ },
277
+ // Property binding: `.currentTime=${t}`.
278
+ {
279
+ id: "property",
280
+ match: (n) => n[0] === ".",
281
+ build: (raw) => ({
282
+ kind: "PropertyBinding",
283
+ name: raw.name.slice(1),
284
+ hole: requireHole(raw, "property binding"),
285
+ loc: raw.loc
286
+ })
287
+ },
288
+ // Boolean attribute (lit `?disabled`): a plain attribute flagged boolean.
289
+ {
290
+ id: "boolean",
291
+ match: (n) => n[0] === "?",
292
+ build: (raw) => attribute(raw, true)
293
+ }
294
+ ];
295
+ function resolveAttribute(raw, directives = CORE_DIRECTIVES, ctx = { component: false }) {
296
+ for (const d of directives) {
297
+ if (d.elementOnly && ctx.component) continue;
298
+ if (d.match(raw.name)) return d.build(raw);
299
+ }
300
+ return attribute(raw, false);
301
+ }
302
+
303
+ // src/tags.ts
304
+ var SVG_TAGS = /* @__PURE__ */ new Set([
305
+ "svg",
306
+ "path",
307
+ "circle",
308
+ "rect",
309
+ "line",
310
+ "polygon",
311
+ "polyline",
312
+ "ellipse",
313
+ "g",
314
+ "defs",
315
+ "clipPath",
316
+ "text"
317
+ ]);
318
+ var RAW_TEXT_ELEMENTS = /* @__PURE__ */ new Set(["script", "style", "textarea", "title"]);
319
+ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
320
+ "area",
321
+ "base",
322
+ "br",
323
+ "col",
324
+ "embed",
325
+ "hr",
326
+ "img",
327
+ "input",
328
+ "link",
329
+ "meta",
330
+ "param",
331
+ "source",
332
+ "track",
333
+ "wbr"
334
+ ]);
335
+ function isComponentTag(tag) {
336
+ return tag.length > 0 && (tag[0] !== tag[0].toLowerCase() || tag.includes("."));
337
+ }
338
+
339
+ // src/a11y.ts
340
+ var INTERACTIVE = /* @__PURE__ */ new Set([
341
+ "a",
342
+ "button",
343
+ "input",
344
+ "select",
345
+ "textarea",
346
+ "summary",
347
+ "details",
348
+ "option",
349
+ "label"
350
+ ]);
351
+ var KEY_EVENTS = /* @__PURE__ */ new Set(["keydown", "keyup", "keypress"]);
352
+ function attributeNamed(node, name) {
353
+ return node.attributes.find((a) => "name" in a && a.name === name);
354
+ }
355
+ function hasSpread(node) {
356
+ return node.attributes.some((a) => a.kind === "Spread");
357
+ }
358
+ function staticValue(node, name) {
359
+ const attribute2 = attributeNamed(node, name);
360
+ if (!attribute2 || attribute2.kind !== "Attribute" || !attribute2.value) return void 0;
361
+ const value = attribute2.value;
362
+ return value.kind === "static" ? value.value : void 0;
363
+ }
364
+ function isPresentational(node) {
365
+ const role = staticValue(node, "role");
366
+ if (role === "presentation" || role === "none") return true;
367
+ return staticValue(node, "aria-hidden") === "true";
368
+ }
369
+ function hasEvent(node, predicate) {
370
+ return node.attributes.some((a) => a.kind === "EventBinding" && predicate(a.name));
371
+ }
372
+ function checkAccessibility(root) {
373
+ const out = [];
374
+ const report = (node, code, message) => {
375
+ out.push({ code, message, severity: "warning", loc: node.loc });
376
+ };
377
+ const visit = (nodes) => {
378
+ for (const node of nodes) {
379
+ if (node.kind === "Element") check(node, report);
380
+ const children = "children" in node ? node.children : void 0;
381
+ if (children) visit(children);
382
+ }
383
+ };
384
+ visit(root.children);
385
+ return out;
386
+ }
387
+ function check(node, report) {
388
+ if (hasSpread(node)) return;
389
+ const tag = node.tag.toLowerCase();
390
+ if (tag === "img" && !attributeNamed(node, "alt") && !isPresentational(node)) {
391
+ report(node, "a11y-img-alt", 'An `img` needs an `alt`. Use `alt=""` when the image is decorative.');
392
+ }
393
+ if (tag === "a" && !attributeNamed(node, "href") && !attributeNamed(node, "role")) {
394
+ report(node, "a11y-anchor-href", "An `a` without `href` is not a link. Use a `button` for an action.");
395
+ }
396
+ if (tag === "iframe" && !attributeNamed(node, "title")) {
397
+ report(node, "a11y-iframe-title", "An `iframe` needs a `title` describing its content.");
398
+ }
399
+ const tabindex = staticValue(node, "tabindex") ?? staticValue(node, "tabIndex");
400
+ if (tabindex !== void 0 && Number(tabindex) > 0) {
401
+ report(
402
+ node,
403
+ "a11y-positive-tabindex",
404
+ "A positive `tabindex` reorders tab navigation for the entire page. Use `0`, or restructure the markup."
405
+ );
406
+ }
407
+ if (!INTERACTIVE.has(tag) && hasEvent(node, (name) => name === "click") && !attributeNamed(node, "role") && !hasEvent(node, (name) => KEY_EVENTS.has(name))) {
408
+ report(
409
+ node,
410
+ "a11y-click-without-keyboard",
411
+ `A click handler on \`${tag}\` is unreachable by keyboard. Use a \`button\`, or add a \`role\` and a key handler.`
412
+ );
413
+ }
414
+ }
415
+
416
+ // src/parser.ts
417
+ var NAME_CHAR = /[A-Za-z0-9\-_:@.?]/;
418
+ var Parser = class {
419
+ constructor(pieces, opts) {
420
+ this.diagnostics = [];
421
+ this.r = new Reader(pieces);
422
+ this.svg = opts.svg ?? false;
423
+ this.directives = opts.directives ?? CORE_DIRECTIVES;
424
+ this.accessibility = opts.accessibility ?? true;
425
+ }
426
+ parse() {
427
+ const start = this.r.pos();
428
+ const children = [];
429
+ while (!this.r.eof()) {
430
+ children.push(...this.parseChildren());
431
+ if (this.r.eof()) break;
432
+ const s = this.r.pos();
433
+ this.consumeCloseTag();
434
+ this.report("stray-close-tag", "error", "Close tag without a matching open tag", this.r.span(s));
435
+ }
436
+ const root = { kind: "Root", children, loc: this.r.span(start) };
437
+ setParents(root);
438
+ this.validateTree(root);
439
+ if (this.accessibility) {
440
+ this.diagnostics.push(...checkAccessibility(root));
441
+ }
442
+ return { root, diagnostics: this.diagnostics };
443
+ }
444
+ /** Cross-node validation that needs the built tree (sibling relationships). */
445
+ validateTree(root) {
446
+ const visit = (nodes) => {
447
+ let prevBranch = null;
448
+ for (const node of nodes) {
449
+ if (node.kind === "Text" && /^\s*$/.test(node.value)) continue;
450
+ if (node.kind === "Element" || node.kind === "Component") {
451
+ const hasElse = node.attributes.some((a) => a.kind === "ElseDirective");
452
+ if (hasElse) {
453
+ const prevHasIf = prevBranch != null && (prevBranch.kind === "Element" || prevBranch.kind === "Component") && prevBranch.attributes.some((a) => a.kind === "IfDirective");
454
+ if (!prevHasIf) {
455
+ this.report("else-without-if", "error", "`else` must immediately follow an element with `if`", node.loc);
456
+ }
457
+ }
458
+ prevBranch = node;
459
+ } else {
460
+ prevBranch = null;
461
+ }
462
+ if ("children" in node && node.children) visit(node.children);
463
+ }
464
+ };
465
+ visit(root.children);
466
+ }
467
+ // --- content ------------------------------------------------------------
468
+ /** Parse sibling nodes until EOF or a `</` close the caller must consume. */
469
+ parseChildren() {
470
+ const out = [];
471
+ while (!this.r.eof()) {
472
+ if (this.r.startsWith("</")) break;
473
+ if (this.r.startsWith("<!--")) {
474
+ out.push(this.parseComment());
475
+ continue;
476
+ }
477
+ if (this.isFragmentStart()) {
478
+ out.push(this.parseFragment());
479
+ continue;
480
+ }
481
+ if (this.isDynamicTagStart()) {
482
+ out.push(this.parseDynamicComponent());
483
+ continue;
484
+ }
485
+ if (this.isTagStart()) {
486
+ out.push(this.parseElement());
487
+ continue;
488
+ }
489
+ if (this.r.atHole()) {
490
+ const h = this.r.takeHole();
491
+ const node = { kind: "Expression", hole: h.index, loc: h.loc };
492
+ out.push(node);
493
+ continue;
494
+ }
495
+ const text = this.readText();
496
+ if (text) out.push(text);
497
+ }
498
+ return out;
499
+ }
500
+ isTagStart() {
501
+ return this.r.peek() === "<" && /[A-Za-z]/.test(this.r.peek(1));
502
+ }
503
+ isFragmentStart() {
504
+ return this.r.peek() === "<" && this.r.peek(1) === ">";
505
+ }
506
+ /** `<${Comp} …>`: a component whose tag is an interpolated expression. */
507
+ isDynamicTagStart() {
508
+ return this.r.peek() === "<" && this.r.holeFollows();
509
+ }
510
+ /** Parse `<${Comp} …/>` or `<${Comp} …>…</${Comp}>` into a dynamic ComponentNode. */
511
+ parseDynamicComponent() {
512
+ const start = this.r.pos();
513
+ this.r.advance();
514
+ const tagHole = this.r.takeHole().index;
515
+ const attributes = this.parseAttributes(true);
516
+ this.r.skipWhitespace();
517
+ let selfClosing = false;
518
+ let children = [];
519
+ if (this.r.startsWith("/>")) {
520
+ this.r.advance();
521
+ this.r.advance();
522
+ selfClosing = true;
523
+ } else {
524
+ if (this.r.peek() === ">") this.r.advance();
525
+ children = this.parseChildren();
526
+ if (this.r.startsWith("</")) {
527
+ this.r.advance();
528
+ this.r.advance();
529
+ if (this.r.atHole()) this.r.takeHole();
530
+ else this.readName();
531
+ this.r.skipWhitespace();
532
+ if (this.r.peek() === ">") this.r.advance();
533
+ } else {
534
+ this.report("unclosed-tag", "error", "Unclosed dynamic component", this.r.span(start));
535
+ }
536
+ }
537
+ return { kind: "Component", tag: "", tagHole, attributes, children, selfClosing, loc: this.r.span(start) };
538
+ }
539
+ /** A raw text run (preserved verbatim; whitespace is normalized at lowering). */
540
+ readText() {
541
+ const start = this.r.pos();
542
+ let value = "";
543
+ while (!this.r.eof() && !this.r.atHole()) {
544
+ if (this.r.startsWith("</") || this.r.startsWith("<!--") || this.isTagStart() || this.isFragmentStart() || this.isDynamicTagStart()) break;
545
+ value += this.r.advance();
546
+ }
547
+ if (!value) return null;
548
+ return { kind: "Text", value, raw: false, loc: this.r.span(start) };
549
+ }
550
+ parseComment() {
551
+ const start = this.r.pos();
552
+ this.r.advance();
553
+ this.r.advance();
554
+ this.r.advance();
555
+ this.r.advance();
556
+ let value = "";
557
+ while (!this.r.eof() && !this.r.startsWith("-->")) {
558
+ if (this.r.atHole()) {
559
+ this.r.takeHole();
560
+ value += "\0";
561
+ continue;
562
+ }
563
+ value += this.r.advance();
564
+ }
565
+ if (this.r.startsWith("-->")) {
566
+ this.r.advance();
567
+ this.r.advance();
568
+ this.r.advance();
569
+ } else {
570
+ this.report("unclosed-comment", "warning", "Unterminated comment", this.r.span(start));
571
+ }
572
+ return { kind: "Comment", value, loc: this.r.span(start) };
573
+ }
574
+ parseFragment() {
575
+ const start = this.r.pos();
576
+ this.r.advance();
577
+ this.r.advance();
578
+ const children = this.parseChildren();
579
+ if (this.r.startsWith("</")) {
580
+ const name = this.consumeCloseTag();
581
+ if (name) this.report("mismatched-close-tag", "error", `Expected </> to close fragment, got </${name}>`, this.r.span(start));
582
+ } else {
583
+ this.report("unclosed-tag", "error", "Unterminated fragment", this.r.span(start));
584
+ }
585
+ return { kind: "Fragment", children, loc: this.r.span(start) };
586
+ }
587
+ parseElement() {
588
+ const start = this.r.pos();
589
+ this.r.advance();
590
+ const tag = this.readName();
591
+ const component = isComponentTag(tag);
592
+ const attributes = this.parseAttributes(component);
593
+ this.foldEachKey(attributes, start);
594
+ this.r.skipWhitespace();
595
+ const lower = tag.toLowerCase();
596
+ const rawText = !component && RAW_TEXT_ELEMENTS.has(lower);
597
+ const isVoid = !component && VOID_ELEMENTS.has(lower);
598
+ let selfClosing = false;
599
+ let children = [];
600
+ if (this.r.startsWith("/>")) {
601
+ this.r.advance();
602
+ this.r.advance();
603
+ selfClosing = true;
604
+ } else {
605
+ if (this.r.peek() === ">") this.r.advance();
606
+ if (!isVoid) {
607
+ children = rawText ? this.parseRawText(tag) : this.parseChildren();
608
+ if (this.r.startsWith("</")) {
609
+ const name = this.consumeCloseTag();
610
+ if (name && name !== tag) {
611
+ this.report("mismatched-close-tag", "error", `Expected </${tag}>, got </${name}>`, this.r.span(start));
612
+ }
613
+ } else {
614
+ this.report("unclosed-tag", "error", `Unclosed <${tag}>`, this.r.span(start));
615
+ }
616
+ }
617
+ }
618
+ const loc = this.r.span(start);
619
+ if (component) {
620
+ return { kind: "Component", tag, tagHole: null, attributes, children, selfClosing, loc };
621
+ }
622
+ const namespace = this.svg || SVG_TAGS.has(tag) ? "svg" : "html";
623
+ return { kind: "Element", tag, namespace, attributes, children, selfClosing, rawText, loc };
624
+ }
625
+ /** Read a raw-text element body: only holes break the text run. */
626
+ parseRawText(tag) {
627
+ const out = [];
628
+ const close = "</" + tag.toLowerCase();
629
+ let text = "";
630
+ let start = this.r.pos();
631
+ const flush = () => {
632
+ if (text) out.push({ kind: "Text", value: text, raw: true, loc: this.r.span(start) });
633
+ text = "";
634
+ };
635
+ while (!this.r.eof()) {
636
+ if (this.startsWithCloseFor(close)) break;
637
+ if (this.r.atHole()) {
638
+ flush();
639
+ const h = this.r.takeHole();
640
+ out.push({ kind: "Expression", hole: h.index, loc: h.loc });
641
+ start = this.r.pos();
642
+ continue;
643
+ }
644
+ if (!text) start = this.r.pos();
645
+ text += this.r.advance();
646
+ }
647
+ flush();
648
+ return out;
649
+ }
650
+ startsWithCloseFor(close) {
651
+ let i = 0;
652
+ while (i < close.length) {
653
+ const ch = this.r.peek(i);
654
+ if (ch === "" || ch.toLowerCase() !== close[i]) return false;
655
+ i++;
656
+ }
657
+ return true;
658
+ }
659
+ // --- attributes ---------------------------------------------------------
660
+ parseAttributes(component) {
661
+ const props = [];
662
+ while (!this.r.eof()) {
663
+ this.r.skipWhitespace();
664
+ if (this.r.peek() === ">" || this.r.startsWith("/>")) break;
665
+ if (this.r.atHole()) {
666
+ const h = this.r.takeHole();
667
+ props.push({ kind: "Spread", hole: h.index, loc: h.loc });
668
+ continue;
669
+ }
670
+ if (this.r.startsWith("...")) {
671
+ const s = this.r.pos();
672
+ this.r.advance();
673
+ this.r.advance();
674
+ this.r.advance();
675
+ if (this.r.atHole()) {
676
+ const h = this.r.takeHole();
677
+ props.push({ kind: "Spread", hole: h.index, loc: this.r.span(s) });
678
+ } else {
679
+ this.report("invalid-binding", "error", "Spread `...` must be followed by ${…}", this.r.span(s));
680
+ }
681
+ continue;
682
+ }
683
+ const nameStart = this.r.pos();
684
+ const name = this.readName();
685
+ if (!name) {
686
+ this.r.advance();
687
+ continue;
688
+ }
689
+ const nameLoc = this.r.span(nameStart);
690
+ this.r.skipWhitespace();
691
+ let value = null;
692
+ if (this.r.peek() === "=") {
693
+ this.r.advance();
694
+ this.r.skipWhitespace();
695
+ value = this.parseAttrValue();
696
+ }
697
+ const raw = {
698
+ name,
699
+ nameLoc,
700
+ value,
701
+ loc: this.r.span(nameStart),
702
+ report: (code, message) => this.report(code, "error", message, this.r.span(nameStart))
703
+ };
704
+ props.push(resolveAttribute(raw, this.directives, { component }));
705
+ }
706
+ return props;
707
+ }
708
+ parseAttrValue() {
709
+ if (this.r.atHole()) {
710
+ const h = this.r.takeHole();
711
+ return { kind: "hole", hole: h.index };
712
+ }
713
+ const quote = this.r.peek();
714
+ if (quote === '"' || quote === "'") {
715
+ return this.classifyParts(this.readQuotedParts(quote));
716
+ }
717
+ const value = this.r.readWhile((ch) => !/[\s>]/.test(ch));
718
+ return { kind: "static", value };
719
+ }
720
+ readQuotedParts(quote) {
721
+ this.r.advance();
722
+ const parts = [];
723
+ let text = "";
724
+ while (!this.r.eof() && this.r.peek() !== quote) {
725
+ if (this.r.atHole()) {
726
+ if (text) {
727
+ parts.push({ text });
728
+ text = "";
729
+ }
730
+ parts.push({ hole: this.r.takeHole().index });
731
+ continue;
732
+ }
733
+ const ch = this.r.advance();
734
+ if (ch === "") break;
735
+ text += ch;
736
+ }
737
+ if (text) parts.push({ text });
738
+ if (this.r.peek() === quote) this.r.advance();
739
+ return parts;
740
+ }
741
+ classifyParts(parts) {
742
+ const holes = parts.filter((p) => "hole" in p);
743
+ if (holes.length === 0) {
744
+ return { kind: "static", value: parts.map((p) => "text" in p ? p.text : "").join("") };
745
+ }
746
+ if (parts.length === 1) return { kind: "hole", hole: holes[0].hole };
747
+ return { kind: "mixed", parts };
748
+ }
749
+ /** Fold `key` into `each`, and validate an element's directive combination. */
750
+ foldEachKey(attrs, elLoc) {
751
+ let each = null;
752
+ let ifCount = 0;
753
+ let eachCount = 0;
754
+ let elseCount = 0;
755
+ let refCount = 0;
756
+ for (const a of attrs) {
757
+ if (a.kind === "EachDirective") {
758
+ each = a;
759
+ eachCount++;
760
+ } else if (a.kind === "IfDirective") ifCount++;
761
+ else if (a.kind === "ElseDirective") elseCount++;
762
+ else if (a.kind === "RefBinding") refCount++;
763
+ }
764
+ const span = this.r.span(elLoc);
765
+ if (eachCount > 1) this.report("duplicate-directive", "error", "Multiple `each` directives on one element", span);
766
+ if (ifCount > 1) this.report("duplicate-directive", "error", "Multiple `if` directives on one element", span);
767
+ if (refCount > 1) this.report("duplicate-directive", "error", "Multiple `ref` bindings on one element", span);
768
+ if (ifCount > 0 && eachCount > 0) {
769
+ this.report("unsupported-combination", "error", "`if` and `each` cannot both apply to one element — wrap one in another element", span);
770
+ }
771
+ if (ifCount > 0 && elseCount > 0) {
772
+ this.report("invalid-directive", "error", "`if` and `else` cannot be on the same element", span);
773
+ }
774
+ const keyIdx = attrs.findIndex((a) => a.kind === "Attribute" && a.name === "key");
775
+ if (!each) {
776
+ if (keyIdx !== -1) this.report("invalid-directive", "error", "`key` requires an `each` directive on the same element", span);
777
+ return;
778
+ }
779
+ if (keyIdx === -1) return;
780
+ const key = attrs[keyIdx];
781
+ if (key.kind === "Attribute" && key.value) {
782
+ if (key.value.kind === "static") each.key = { static: key.value.value };
783
+ else if (key.value.kind === "hole") each.key = { hole: key.value.hole };
784
+ }
785
+ attrs.splice(keyIdx, 1);
786
+ }
787
+ // --- low level ----------------------------------------------------------
788
+ readName() {
789
+ return this.r.readWhile((ch) => NAME_CHAR.test(ch));
790
+ }
791
+ /** Consume `</name>` (or `</>`), returning the close-tag name (may be ''). */
792
+ consumeCloseTag() {
793
+ this.r.advance();
794
+ this.r.advance();
795
+ const name = this.readName();
796
+ this.r.skipWhitespace();
797
+ if (this.r.peek() === ">") this.r.advance();
798
+ return name;
799
+ }
800
+ report(code, severity, message, loc) {
801
+ this.diagnostics.push({ code, severity, message, loc });
802
+ }
803
+ };
804
+ function parseTemplate(pieces, opts = {}) {
805
+ return new Parser(pieces, opts).parse();
806
+ }
807
+ export {
808
+ parseTemplate
809
+ };