@nativele/ts-dom 0.3.0 → 0.4.6

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.
@@ -21,6 +21,9 @@ const CODEGEN_RULES = {
21
21
  "empty-each": "data-each must not be empty",
22
22
  "nested-each": "data-each templates cannot be nested",
23
23
  "duplicate-each": "data-each names must be unique",
24
+ "each-binding-incomplete": "data-key and data-prop go together on a data-each template",
25
+ "invalid-each-binding": "data-key and data-prop must each be a single identifier",
26
+ "each-binding-root": "with data-key and data-prop the item must be one custom element",
24
27
  "each-root-count": "a data-each template needs exactly one root element",
25
28
  "document-tag": "<html>, <head> and <body> cannot be in a template (the browser drops them from fragments)",
26
29
  "forbidden-tag": "<script> cannot be in a template (it would run code)",
@@ -28,6 +31,17 @@ const CODEGEN_RULES = {
28
31
  "unsafe-url": "href / src / action / formaction cannot start with javascript:",
29
32
  "duplicate-define": "a custom element tag can be defined only once per scanned directory"
30
33
  };
34
+ const RESERVED_ELEMENT_NAMES = /* @__PURE__ */ new Set([
35
+ "annotation-xml",
36
+ "color-profile",
37
+ "font-face",
38
+ "font-face-src",
39
+ "font-face-uri",
40
+ "font-face-format",
41
+ "font-face-name",
42
+ "missing-glyph"
43
+ ]);
44
+ const isCustomElementName = (tag) => /^[a-z][a-z0-9._]*-[a-z0-9._-]*$/.test(tag) && !RESERVED_ELEMENT_NAMES.has(tag);
31
45
  const URL_ATTRS = /^(href|src|action|formaction|xlink:href)$/i;
32
46
  const HTML_NS = "http://www.w3.org/1999/xhtml";
33
47
  const newScope = () => ({
@@ -52,6 +66,7 @@ function analyze(html) {
52
66
  scope: newScope(),
53
67
  lists: /* @__PURE__ */ new Map(),
54
68
  slots: /* @__PURE__ */ new Set(),
69
+ boundLists: /* @__PURE__ */ new Map(),
55
70
  events: /* @__PURE__ */ new Set(),
56
71
  eventSites: [],
57
72
  customTags: /* @__PURE__ */ new Set(),
@@ -169,6 +184,21 @@ function analyze(html) {
169
184
  else {
170
185
  const items = n.content.childNodes.filter((c) => isEl$1(c) || c.nodeName === "#text" && c.value.trim());
171
186
  if (items.length !== 1 || !isEl$1(items[0])) report("each-root-count", "error", `data-each="${each}" must contain exactly one root element (found ${items.length} root nodes). Fix: wrap the item content in a single element such as <li>.`, n);
187
+ const key = attr$1(n, "data-key");
188
+ const prop = attr$1(n, "data-prop");
189
+ if (key === void 0 !== (prop === void 0)) report("each-binding-incomplete", "error", `data-each="${each}" has ${key === void 0 ? "data-prop" : "data-key"} but not ${key === void 0 ? "data-key" : "data-prop"}. Fix: write both, e.g. <template data-each="${each}" data-key="id" data-prop="item">, or neither to fill the items yourself with replace().`, n);
190
+ else if (key !== void 0 && prop !== void 0) {
191
+ if (!IDENT.test(key.trim()) || !IDENT.test(prop.trim())) report("invalid-each-binding", "error", `data-key="${key}" and data-prop="${prop}" must each be a single identifier: the item field that identifies it, and the property of the item element that receives the item. Fix: write data-key="id" data-prop="item".`, n);
192
+ else if (items.length === 1 && isEl$1(items[0])) {
193
+ const root = items[0];
194
+ if (root.namespaceURI === HTML_NS && isCustomElementName(root.tagName)) a.boundLists.set(each, {
195
+ tag: root.tagName,
196
+ prop: prop.trim(),
197
+ key: key.trim()
198
+ });
199
+ else report("each-binding-root", "error", `data-each="${each}" has data-key and data-prop, so each item is one component that receives the item as a property, but its root is <${root.tagName}>. Fix: make the item a custom element such as <${each}-row></${each}-row> and put the item's markup in that component's own .html.`, n);
200
+ }
201
+ }
172
202
  const s = newScope();
173
203
  a.lists.set(each, s);
174
204
  mark(`each:${each}`, n);
@@ -197,6 +227,14 @@ function rootElementType(html) {
197
227
  const el = elements[0];
198
228
  return el.namespaceURI === HTML_NS ? `El<${JSON.stringify(el.tagName)}>` : "SVGElement";
199
229
  }
230
+ /** The whitespace mode an existing generated module was written with; "preserve" when there is none. */
231
+ function recordedWhitespace(out) {
232
+ try {
233
+ return readFileSync(out, "utf8").split("\n", 3).includes(COLLAPSE_MARK) ? "collapse" : "preserve";
234
+ } catch {
235
+ return "preserve";
236
+ }
237
+ }
200
238
  function globToRegExp(glob) {
201
239
  let re = "";
202
240
  for (let i = 0; i < glob.length; i++) {
@@ -252,10 +290,16 @@ function toNodes(parent, collapse = false) {
252
290
  * tsc (or any bundler) can compile it directly: no plugin and no allowArbitraryExtensions needed.
253
291
  * Consumers import it as `import tpl from "./x.html.js"` (TypeScript maps it to x.html.ts).
254
292
  */
255
- function generateModule(html, options = {}, imports = []) {
293
+ function generateModule(html, options = {}, imports = [], defs = []) {
256
294
  const runtime = options.runtime ?? "@nativele/ts-dom";
257
295
  const a = analyze(html);
258
- const lists = [...a.lists].map(([n, s]) => `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)} }`).join("; ");
296
+ const lists = [...a.lists].map(([n, s]) => {
297
+ const bound = a.boundLists.get(n);
298
+ const def = bound && defs.find((d) => d.tag === bound.tag);
299
+ const source = !def ? "external" : def.exported ? "local" : "unexported";
300
+ const item = bound ? `; item: { tag: ${q(bound.tag)}; prop: ${q(bound.prop)}; key: ${q(bound.key)}; source: ${q(source)} }` : "";
301
+ return `${q(n)}: { refs: ${obj(s.refs)}; text: ${union(s.text)}; ifs: ${union(s.ifs)}${item} }`;
302
+ }).join("; ");
259
303
  const type = `{
260
304
  refs: ${obj(a.scope.refs)};
261
305
  lists: { ${lists} };
@@ -266,8 +310,9 @@ function generateModule(html, options = {}, imports = []) {
266
310
  events: ${union(a.events)};
267
311
  }`;
268
312
  const needsEl = type.includes("El<");
269
- return `// Generated by ts-dom. Do not edit.
270
- ${imports.map((i) => `import ${q(i)};\n`).join("")}import { template } from ${q(runtime)};
313
+ const side = imports.map((i) => `import ${q(i)};\n`).join("");
314
+ return `// Generated by ts-dom. Do not edit.${options.whitespace === "collapse" ? `\n${COLLAPSE_MARK}` : ""}
315
+ ${side}import { template } from ${q(runtime)};
271
316
  ${needsEl ? `import type { El } from ${q(runtime)};\n` : ""}export default template<${type}>(${JSON.stringify(toNodes(parseFragment(html), options.whitespace === "collapse"))});
272
317
  `;
273
318
  }
@@ -284,21 +329,31 @@ function formatDiagnostics(file, ds) {
284
329
  return ds.map((d) => `${file}:${d.line}:${d.col} ${d.severity} [${d.rule}] ${d.message}`).join("\n");
285
330
  }
286
331
  var CodegenError = class extends Error {};
287
- /**
288
- * Generate a single `x.html.ts`; throws CodegenError (and writes nothing) when there are error diagnostics.
289
- * `defs` is either a directory to scan or a precomputed scan result: custom elements used by the template
290
- * are imported automatically from the modules that define them.
291
- */
292
- function generateFile(htmlPath, options = {}, defs) {
332
+ /** What `generateFile` would write for a template, without writing it; throws CodegenError when the template has error diagnostics. */
333
+ function renderFile(htmlPath, options = {}, defs) {
293
334
  const html = readFileSync(htmlPath, "utf8");
335
+ if (options.whitespace === void 0) options = {
336
+ ...options,
337
+ whitespace: recordedWhitespace(modulePathFor(htmlPath))
338
+ };
294
339
  const analysis = analyze(html);
295
340
  const errors = analysis.diagnostics.filter((d) => d.severity === "error");
296
341
  if (errors.length) throw new CodegenError(formatDiagnostics(htmlPath, errors));
297
342
  const known = typeof defs === "string" ? scanDefines(defs) : defs ?? [];
298
343
  const here = dirname(htmlPath);
299
344
  const imports = [...new Set(known.filter((d) => analysis.customTags.has(d.tag)).map((d) => relImport(here, d.file)))].sort();
300
- const out = modulePathFor(htmlPath);
301
- const next = generateModule(html, options, imports);
345
+ return {
346
+ out: modulePathFor(htmlPath),
347
+ next: generateModule(html, options, imports, known)
348
+ };
349
+ }
350
+ /**
351
+ * Generate a single `x.html.ts`; throws CodegenError (and writes nothing) when there are error diagnostics.
352
+ * `defs` is either a directory to scan or a precomputed scan result: custom elements used by the template
353
+ * are imported automatically from the modules that define them.
354
+ */
355
+ function generateFile(htmlPath, options = {}, defs) {
356
+ const { out, next } = renderFile(htmlPath, options, defs);
302
357
  let prev = "";
303
358
  try {
304
359
  prev = readFileSync(out, "utf8");
@@ -309,7 +364,7 @@ function generateFile(htmlPath, options = {}, defs) {
309
364
  function listHtml(dir) {
310
365
  const out = [];
311
366
  for (const name of readdirSync(dir)) {
312
- if (name === "node_modules") continue;
367
+ if (name === "node_modules" || name === "dist") continue;
313
368
  const full = join(dir, name);
314
369
  if (statSync(full).isDirectory()) out.push(...listHtml(full));
315
370
  else if (name.endsWith(".html") && !isFullPage(readFileSync(full, "utf8"))) out.push(full);
@@ -318,6 +373,7 @@ function listHtml(dir) {
318
373
  }
319
374
  const TAGS_FILE = "ts-dom-tags.ts";
320
375
  const TAGS_HEADER = "// Generated by ts-dom. Do not edit.";
376
+ const COLLAPSE_MARK = "// whitespace: collapse";
321
377
  function listTs(dir) {
322
378
  const out = [];
323
379
  for (const name of readdirSync(dir)) {
@@ -395,6 +451,20 @@ function scanTags(dir) {
395
451
  * so that `refs.x` for `<tag data-ref="x">` in a template is fully typed (props, methods and emitted events).
396
452
  */
397
453
  function generateTags(dir) {
454
+ const { out, next } = renderTags(dir);
455
+ if (next === null) {
456
+ if (existsSync(out) && readFileSync(out, "utf8").startsWith(TAGS_HEADER)) unlinkSync(out);
457
+ return null;
458
+ }
459
+ let prev = "";
460
+ try {
461
+ prev = readFileSync(out, "utf8");
462
+ } catch {}
463
+ if (prev !== next) writeFileSync(out, next);
464
+ return out;
465
+ }
466
+ /** What `generateTags` would write, without writing it; `next` is null when the directory defines no exported component. */
467
+ function renderTags(dir) {
398
468
  const defs = scanTags(dir);
399
469
  const out = join(dir, TAGS_FILE);
400
470
  const seen = /* @__PURE__ */ new Map();
@@ -403,19 +473,86 @@ function generateTags(dir) {
403
473
  if (prev && (prev.from !== d.from || prev.className !== d.className)) throw new CodegenError(`${dir}: [duplicate-define] the tag <${d.tag}> is defined more than once (${prev.from} and ${d.from}). Fix: each custom element name can be defined only once; rename one of them.`);
404
474
  seen.set(d.tag, d);
405
475
  }
406
- if (!defs.length) {
407
- if (existsSync(out) && readFileSync(out, "utf8").startsWith(TAGS_HEADER)) unlinkSync(out);
408
- return null;
409
- }
476
+ if (!defs.length) return {
477
+ out,
478
+ next: null
479
+ };
410
480
  const alias = (d) => `Tag${[...seen.keys()].indexOf(d.tag)}`;
411
481
  const imports = [...seen.values()].map((d) => `import type { ${d.isDefault ? "default" : d.className} as ${alias(d)} } from ${JSON.stringify(d.from)};`);
412
482
  const entries = [...seen.values()].map((d) => ` ${JSON.stringify(d.tag)}: ${alias(d)};`);
413
- const next = `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n\nexport {};\n`;
414
- let prev = "";
483
+ return {
484
+ out,
485
+ next: `${TAGS_HEADER}\n${imports.join("\n")}\n\ndeclare global {\n interface HTMLElementTagNameMap {\n${entries.join("\n")}\n }\n}\n\nexport {};\n`
486
+ };
487
+ }
488
+ /**
489
+ * Everything `generateAll` would write for a directory, in memory: generated path to content, or null for a file that should not exist.
490
+ * Templates with error diagnostics are left out and their messages returned in `errors`.
491
+ */
492
+ function planGenerated(dir, options = {}) {
493
+ const files = /* @__PURE__ */ new Map();
494
+ const errors = [];
495
+ const defs = scanDefines(dir);
496
+ for (const f of listHtml(dir)) {
497
+ if (!isSelected(dir, f, options)) continue;
498
+ try {
499
+ const { out, next } = renderFile(f, options, defs);
500
+ files.set(out, next);
501
+ } catch (e) {
502
+ if (e instanceof CodegenError) errors.push(e.message);
503
+ else throw e;
504
+ }
505
+ }
415
506
  try {
416
- prev = readFileSync(out, "utf8");
417
- } catch {}
418
- if (prev !== next) writeFileSync(out, next);
507
+ const tags = renderTags(dir);
508
+ files.set(tags.out, tags.next);
509
+ } catch (e) {
510
+ if (e instanceof CodegenError) errors.push(e.message);
511
+ else throw e;
512
+ }
513
+ for (const f of listGenerated(dir)) if (!files.has(f) && !existsSync(f.slice(0, -3))) files.set(f, null);
514
+ return {
515
+ files,
516
+ errors
517
+ };
518
+ }
519
+ /** Generated files under `dir` that differ from what `ts-dom gen` would write now. */
520
+ function staleGenerated(dir, options = {}) {
521
+ return checkGenerated(dir, options).stale;
522
+ }
523
+ /** `staleGenerated`, plus the errors that would make `ts-dom gen` fail (template diagnostics, a tag defined twice). */
524
+ function checkGenerated(dir, options = {}) {
525
+ const plan = planGenerated(dir, options);
526
+ const out = [];
527
+ for (const [file, next] of plan.files) {
528
+ const prev = existsSync(file) ? readFileSync(file, "utf8") : null;
529
+ if (next === null) {
530
+ if (prev !== null && prev.startsWith(TAGS_HEADER)) out.push({
531
+ file,
532
+ reason: file.endsWith(".html.ts") ? "orphan" : "outdated"
533
+ });
534
+ } else if (prev === null) out.push({
535
+ file,
536
+ reason: "missing"
537
+ });
538
+ else if (prev !== next) out.push({
539
+ file,
540
+ reason: "outdated"
541
+ });
542
+ }
543
+ return {
544
+ stale: out.sort((a, b) => a.file.localeCompare(b.file)),
545
+ errors: plan.errors
546
+ };
547
+ }
548
+ function listGenerated(dir) {
549
+ const out = [];
550
+ for (const name of readdirSync(dir)) {
551
+ if (name === "node_modules") continue;
552
+ const full = join(dir, name);
553
+ if (statSync(full).isDirectory()) out.push(...listGenerated(full));
554
+ else if (name.endsWith(".html.ts") && readFileSync(full, "utf8").startsWith(TAGS_HEADER)) out.push(full);
555
+ }
419
556
  return out;
420
557
  }
421
558
  /** Generate every `x.html.ts` (and the tag map) under a directory; collects all errors and throws them together. */
@@ -439,6 +576,7 @@ function generateAll(dir, options = {}) {
439
576
  if (e instanceof CodegenError) errors.push(e.message);
440
577
  else throw e;
441
578
  }
579
+ for (const f of listGenerated(dir)) if (!existsSync(f.slice(0, -3))) unlinkSync(f);
442
580
  if (errors.length) throw new CodegenError(errors.join("\n"));
443
581
  return written;
444
582
  }
@@ -498,10 +636,10 @@ const A11Y_RULES = {
498
636
  "aria-idref": "This ARIA attribute refers to an id that does not exist in this template. Fix: add the id, or ignore this rule if it refers to an element outside the component.",
499
637
  "heading-order": "Heading levels are skipped. Fix: do not jump levels (for example h1 straight to h3); use the next level down.",
500
638
  "table-header": "<table> has no <th> header cells. Fix: add <th> cells, or set role=\"presentation\" if the table is only used for layout.",
501
- "list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li>, and <li> must be inside <ul>, <ol> or <menu>.",
639
+ "list-structure": "Invalid list structure. Fix: <ul>/<ol> may only contain <li> (or a custom element with role=\"listitem\"), and <li> must be inside <ul>, <ol> or <menu>.",
502
640
  "landmark-label": "There are several landmarks of the same kind. Fix: give each <nav> / <aside> a distinct aria-label.",
503
641
  "redundant-role": "The role repeats the element's native role. Fix: remove the role attribute.",
504
- "click-semantics": "data-on-click is on an element that is not ts-domy operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling."
642
+ "click-semantics": "data-on-click is on an element that is not natively operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling."
505
643
  };
506
644
  const ROLES = new Set(`alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading img insertion link list listbox listitem log main mark marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem`.split(" "));
507
645
  const ARIA_ATTRS = new Set(`activedescendant atomic autocomplete braillelabel brailleroledescription busy checked colcount colindex colindextext colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowindextext rowspan selected setsize sort valuemax valuemin valuenow valuetext`.split(" ").map((n) => `aria-${n}`));
@@ -629,10 +767,10 @@ function checkA11y(html, ignore = []) {
629
767
  if (role !== void 0 && !ROLES.has(role) && !role.startsWith("doc-") && !role.startsWith("graphics-")) report("aria-role", el, `role="${role}" is not a valid ARIA role. Fix: check the spelling against the WAI-ARIA roles.`);
630
768
  for (const a of el.attrs) if (a.name.startsWith("aria-") && !ARIA_ATTRS.has(a.name)) report("aria-attr", el, `${a.name} is not a valid ARIA attribute. Fix: check the spelling against the WAI-ARIA attributes.`);
631
769
  const tabindex = attr(el, "tabindex");
632
- if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not ts-domy focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
770
+ if (role && INTERACTIVE_ROLES.has(role) && !focusable(el) && tabindex === void 0 && !managed) report("role-tabindex", el, `<${tag} role="${role}"> is not natively focusable. Fix: add tabindex="0" and handle keyboard events, or use a native element such as <button>.`);
633
771
  if (tabindex !== void 0 && Number(tabindex) > 0) report("tabindex-positive", el, `tabindex="${tabindex}" disturbs the natural focus order. Fix: use tabindex="0" or "-1".`);
634
772
  if (attr(el, "aria-hidden") === "true" && (focusable(el) || tabindex !== void 0 && Number(tabindex) >= 0)) report("hidden-focusable", el);
635
- if (has(el, "data-on-click") && !focusable(el) && tag !== "label" && !(role && INTERACTIVE_ROLES.has(role) && tabindex !== void 0)) report("click-semantics", el, `data-on-click is on <${tag}>, which is not ts-domy operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling.`);
773
+ if (has(el, "data-on-click") && !focusable(el) && tag !== "label" && !(role && INTERACTIVE_ROLES.has(role) && tabindex !== void 0)) report("click-semantics", el, `data-on-click is on <${tag}>, which is not natively operable, so keyboard and assistive technology users cannot use it. Fix: use <button> or <a href>, or add an interactive role plus tabindex and keyboard handling.`);
636
774
  if (/^h[1-6]$/.test(tag)) {
637
775
  const level = Number(tag[1]);
638
776
  if (lastHeading && level > lastHeading + 1) report("heading-order", el, `<${tag}> skips a heading level (the previous heading is h${lastHeading}). Fix: use h${lastHeading + 1} here, or restructure the headings.`);
@@ -643,8 +781,16 @@ function checkA11y(html, ignore = []) {
643
781
  if (!isEl(c) || c.tagName === "slot" || c.tagName === "script") continue;
644
782
  if (c.tagName === "template") {
645
783
  if (!has(c, "data-each")) continue;
646
- for (const item of c.content.childNodes) if (isEl(item) && item.tagName !== "li") report("list-structure", item, `The root element of a data-each item inside <${tag}> must be <li>, found <${item.tagName}>. Fix: use <li> as the item root.`);
647
- } else if (c.tagName !== "li") report("list-structure", c, `<${tag}> may only contain <li> children, found <${c.tagName}>. Fix: wrap the content in <li>.`);
784
+ for (const item of c.content.childNodes) if (isEl(item)) listItemProblem(item, tag, "The root element of a data-each item");
785
+ } else listItemProblem(c, tag, `A child of <${tag}>`);
786
+ }
787
+ function listItemProblem(item, listTag, what) {
788
+ if (item.tagName === "li") return;
789
+ if (item.tagName.includes("-")) {
790
+ if (attr(item, "role") !== "listitem") report("list-structure", item, `${what} inside <${listTag}> is the component <${item.tagName}>, which has no list item role. Fix: add role="listitem" to <${item.tagName}>, or make its root an <li>.`);
791
+ return;
792
+ }
793
+ report("list-structure", item, `${what} inside <${listTag}> must be <li> or a custom element with role="listitem", found <${item.tagName}>. Fix: use <li> as the item root.`);
648
794
  }
649
795
  if (tag === "li" && parent && ![
650
796
  "ul",
@@ -668,4 +814,4 @@ function checkA11y(html, ignore = []) {
668
814
  for (const { id, el } of labelFor) if (!ids.has(id)) report("label-for", el, `<label for="${id}"> points to an id that does not exist in this template. Fix: add id="${id}" to the control, or wrap the control in the label.`);
669
815
  return out.sort((a, b) => a.line - b.line || a.col - b.col);
670
816
  }
671
- export { analyze as a, generateFile as c, listHtml as d, scanDefines as f, CodegenError as i, isFullPage as l, checkA11y as n, formatDiagnostics as o, watchAll as p, CODEGEN_RULES as r, generateAll as s, A11Y_RULES as t, isSelected as u };
817
+ export { watchAll as _, analyze as a, generateAll as c, isSelected as d, listHtml as f, staleGenerated as g, scanDefines as h, CodegenError as i, generateFile as l, renderTags as m, checkA11y as n, checkGenerated as o, listTs as p, CODEGEN_RULES as r, formatDiagnostics as s, A11Y_RULES as t, isFullPage as u };