zdc-runtime 0.1.0

Embeds the ZDeceptron JavaScript runtime and evaluates it without an external toolchain.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
// The built-in view elements.
//
// These are the only elements a program can use until user-defined
// components land (spec §14D). Each is a thin mapping onto DOM structure
// plus a small amount of default styling, so that a program with no style
// declarations still renders as something a person would recognise.
//
// Input elements bind two-way, and only to `client`-placed signals — a
// keystroke must not silently become a network write (spec §14B.5). The
// compiler enforces the placement rule; the runtime just wires the event.
//
// THE DIRECTORY OF THE VOCABULARY IS THE EXPORT LIST, and there is no
// object holding one property per element. There was: `BUILTINS`, which
// nothing in the runtime or the compiler read, whose one consumer was a
// test asserting it existed. It was removed rather than kept, because it
// had a measured cost and no benefit. `boa`, the engine both parity
// suites run this file in, aborts the *process* with a Rust-level
// `BorrowMutError` inside its own `Set` builtin once a context crosses an
// allocation threshold — the defect BENCHMARKS.md records as making
// signal fan-out unmeasurable here — and this file sat on that threshold.
// Building the object on demand instead of at load bought about a dozen
// elements and then stopped working too, because the function itself is
// an object holding a reference per element.
//
// Nothing is lost. `element_parity.rs` calls each name in this file
// directly, once per built-in, so an element the compiler knows and this
// file does not export fails there with the name in the message.

import { el, safeUrl, text, variant } from './dom.js';
import { effect } from './signal.js';
import { markup } from './markup.js';

// Base styling is a CLASS NAME, not an inline style object (spec §16.2 R6).
// §6 already specifies that styles compile to static CSS with generated
// scoped class names and zero runtime cost; an inline style object costs one
// effect and one `setProperty` per declaration, which is seven of each for a
// `Column` and a `Row` that can never change. The declarations themselves
// live in `base.css`, which `zdc build` copies into `styles.css`.
const BASE = {
  column: 'zd-col',
  row: 'zd-row',
  error: 'zd-err',
  prose: 'zd-prose',
  preformatted: 'zd-pre',
};

/**
 * Put a base class in front of whatever class the program asked for.
 *
 * The program's `class` may be a getter, so the join has to stay reactive
 * rather than stringifying a function into the attribute.
 */
function withBase(p, base) {
  const given = p.class;
  if (given === undefined) {
    p.class = base;
  } else if (typeof given === 'function') {
    p.class = () => `${base} ${given()}`;
  } else {
    p.class = `${base} ${given}`;
  }
  return p;
}

/**
 * A `Truth` as the word an ARIA state attribute is spelled with.
 *
 * Reactive when it has to be: the program's value may be a getter, and
 * stringifying the function itself would write `aria-selected="() => …"`.
 */
function word(value) {
  return typeof value === 'function' ? () => String(value()) : String(value);
}

/** Split ZDeceptron element arguments into DOM props. */
function props(args = {}) {
  const out = {};
  const style = {};
  for (const [name, value] of Object.entries(args)) {
    switch (name) {
      case 'padding':
        style.padding = typeof value === 'function' ? () => `${value()}px` : `${value}px`;
        break;
      case 'weight':
        style['font-weight'] = value;
        break;
      case 'hint':
        out.placeholder = value;
        break;
      // The ZDeceptron spelling of `src`. Filtered, not merely renamed:
      // an image source is a request the browser issues to whatever host
      // the value names (spec §16.3.5, corrected).
      case 'source':
        out.src = typeof value === 'function' ? () => safeUrl(value()) : safeUrl(value);
        break;
      // The still a video shows before it plays: a request the browser
      // issues at once, so it is filtered exactly as `source` is.
      case 'poster':
        out.poster = typeof value === 'function' ? () => safeUrl(value()) : safeUrl(value);
        break;
      case 'src':
      case 'href':
        out[name] = typeof value === 'function' ? () => safeUrl(value()) : safeUrl(value);
        break;
      case 'exact':
        out.datetime = value;
        break;
      // What the letters stand for. It is `title` in the DOM, and the
      // compiler requires it, because an `abbr` with no expansion is an
      // acronym with nothing behind it.
      case 'expansion':
        out.title = value;
        break;
      // What this element operates, by `id`. `Label` renames it to `for`
      // itself, below, because that is the only element that has one.
      case 'controls':
        out['aria-controls'] = value;
        break;
      // The element that explains this one, and the element that names
      // it. Both are an `id` and neither translates.
      case 'describedBy':
        out['aria-describedby'] = value;
        break;
      case 'labelledBy':
        out['aria-labelledby'] = value;
        break;
      // Which of a set you are looking at, and how urgently a region
      // interrupts. Both are one word from a closed set the compiler
      // checks; nothing is translated, so both pass straight through.
      case 'current':
        out['aria-current'] = value;
        break;
      case 'live':
        out['aria-live'] = value;
        break;
      // The ARIA states, and the one thing about them that is not a
      // rename. `setAttribute` in `dom.js` implements HTML's boolean
      // attributes — `false` removes, `true` sets the empty string — and
      // an `aria-*` state is not one of those. Its value is the *word*
      // `true` or the word `false`, and an unselected tab carrying no
      // `aria-selected` announces a tablist with nothing chosen. So each
      // of these is stringified here, reactively when it is a getter.
      case 'selected':
      case 'expanded':
      case 'pressed':
      case 'checked':
        out[`aria-${name}`] = word(value);
        break;
      case 'disabled':
        out['aria-disabled'] = word(value);
        break;
      // `aria-hidden`, named for the only thing it is ever right for.
      // It hides nothing: the element stays where it was and a screen
      // reader stops reading it.
      case 'decorative':
        out['aria-hidden'] = word(value);
        break;
      // What this control is called when no text beside it says so.
      // `Checkbox` and `Radio` never reach here — they read `label`
      // themselves and wrap their box in a `<label>` holding it.
      case 'label':
        out['aria-label'] = value;
        break;
      // The ends and the landmarks of a measured range, in English.
      case 'least':
        out.min = value;
        break;
      case 'most':
        out.max = value;
        break;
      case 'best':
        out.optimum = value;
        break;
      case 'message':
        break; // consumed by the element itself, never an attribute
      case 'class':
        out.class = value;
        break;
      default:
        out[name] = value;
    }
  }
  if (Object.keys(style).length > 0) out.style = style;
  return out;
}

/**
 * The two layout containers.
 *
 * Both take an optional leading text slot, ratified in §4.4: `Row item.name`
 * is one text node followed by the row's children, exactly as `Button`
 * already is. A row with nothing to say of its own passes `undefined`,
 * which is what a source program with no leading argument compiles to.
 */
export function Column(value, args = {}, children = []) {
  return el(
    'div',
    withBase(props(args), BASE.column),
    value === undefined ? children : [text(value), ...children],
  );
}

export function Row(value, args = {}, children = []) {
  return el(
    'div',
    withBase(props(args), BASE.row),
    value === undefined ? children : [text(value), ...children],
  );
}

export function Text(value, args = {}) {
  return el('span', props(args), [text(value)]);
}

/**
 * A heading, at the level its nesting says.
 *
 * The compiler chooses the tag from how many sectioning elements enclose
 * the heading, so `h1` is what a heading at the top of a document is. This
 * reference implementation has no enclosing context to consult, so it
 * renders the top level, which is the case the parity test compares.
 */
export function Heading(value, args = {}) {
  return el('h1', props(args), [text(value)]);
}

export function Button(label, args = {}, children = []) {
  return el('button', { type: 'button', ...props(args) }, [text(label), ...children]);
}

/**
 * A text input bound two-way to a client signal.
 *
 * `binding` is the [read, write] pair the compiler emits for a `client`
 * signal. Passing a server or durable signal here is a compile error
 * (§14B.5), so the runtime can assume the write is local and synchronous.
 */
export function Input(binding, args = {}) {
  const [get, set] = binding;
  return el('input', {
    type: 'text',
    value: get,
    onInput: (e) => set(e.target.value),
    ...props(args),
  });
}

/**
 * A multi-line field, bound the way `Input` is.
 *
 * A `textarea` holds its value as a property rather than as an attribute,
 * which `setAttribute` in `dom.js` already knows; nothing here is special
 * about the binding except the tag.
 */
export function TextArea(binding, args = {}) {
  const [get, set] = binding;
  return el('textarea', {
    value: get,
    onInput: (e) => set(e.target.value),
    ...props(args),
  });
}

/**
 * A masked field.
 *
 * The three baked attributes are the whole of what the browser gives a
 * password field and nothing else does. What the *compiler* adds is a rule
 * about where the bound signal may appear, which has no counterpart here
 * because this file builds nodes and does not read programs; `elements.rs`
 * states the decision and `view.rs` enforces it.
 */
export function PasswordInput(binding, args = {}) {
  const [get, set] = binding;
  return el('input', {
    type: 'password',
    autocomplete: 'current-password',
    spellcheck: 'false',
    value: get,
    onInput: (e) => set(e.target.value),
    ...props(args),
  });
}

/**
 * A number, typed. And a date, picked, which is the same control with a
 * different `type` and a different reading of the same number.
 *
 * # Both bind an `Option`, and both bind through `valueAsNumber`
 *
 * A `Slider` always has a number, because a track always has a thumb on
 * it. A box a person types in does not: empty, a lone `-` and a
 * half-written `1e` all report `valueAsNumber` `NaN`, which is not a
 * value ZDeceptron has. So the read is `None` or `Some n`.
 *
 * The write is `valueAsNumber` and not `value`. A number field runs
 * HTML's value sanitisation, so `value` is the empty string while a
 * reader is part way through `1.`; comparing text would rewrite the box
 * on every keystroke and a decimal point could never be typed at all.
 *
 * A date field's `valueAsNumber` is defined by HTML as the moment at
 * midnight UTC on the chosen day, which is what `prelude/time.zd` means
 * by a moment. So the browser renders `YYYY-MM-DD` from the number and
 * reads the number back, and no calendar is written here.
 *
 * # Why the two rules are spelled here rather than imported
 *
 * The compiler emits them into a program's own preamble
 * (`intrinsics.rs`'s `$optionalNumber` and `$numberField`) rather than
 * exporting them from `dom.js`, because the shipped runtime is against
 * the size gate `zdc-bench` holds it to. This file is a reference
 * implementation and is never shipped, so it says the same two things in
 * its own words — which is what this file is *for*: `element_parity.rs`
 * compares the node against the compiler's, and `vocabulary.rs` drives
 * the behaviour.
 */
export function NumberInput(binding, args = {}) {
  return numericField('number', binding, args);
}

export function DateInput(binding, args = {}) {
  return numericField('date', binding, args);
}

function numericField(type, [get, set], args) {
  const node = el('input', {
    type,
    onInput: (e) => {
      const read = e.target.valueAsNumber;
      // `Number.isNaN`, not the coercing global: `isNaN('')` is `false`.
      set(Number.isNaN(read) ? variant('None') : variant('Some', read));
    },
    ...props(args),
  });
  effect(() => {
    const held = get();
    // `NaN` empties the box, which is what `None` looks like and where a
    // non-finite number has to go too: the setter throws on an infinity.
    const shown =
      held.tag === 'Some' && Number.isFinite(held.fields[0]) ? held.fields[0] : NaN;
    if (!Object.is(node.valueAsNumber, shown)) node.valueAsNumber = shown;
  });
  return node;
}

/**
 * A bounded number, dragged.
 *
 * The listener reads `valueAsNumber` and not `value`: the signal holds a
 * number, and `value` is the text of one, so a `Whole` given `'55'` would
 * render `551` the moment anything added to it.
 */
export function Slider(binding, args = {}) {
  const [get, set] = binding;
  return el('input', {
    type: 'range',
    value: get,
    onInput: (e) => set(e.target.valueAsNumber),
    ...props(args),
  });
}

/**
 * One variant of a `choice`, picked from a list.
 *
 * `variants` is the choice's own arms, in declaration order, which the
 * compiler writes from the declaration. The value on the wire is the
 * variant's tag, because an option's value is one string.
 */
export function Select(binding, variants = [], args = {}) {
  const [get, set] = binding;
  return el(
    'select',
    {
      value: () => get().tag,
      onChange: (e) => set(variant(e.target.value)),
      ...props(args),
    },
    variants.map((name) => el('option', { value: name }, [name])),
  );
}

/**
 * One radio of a group.
 *
 * `option` is the variant's tag, which the compiler writes down: it is
 * this button's value in the markup and the tag the binding compares
 * against. The group is the signal, named by `group`, so the browser
 * clears the others when one is picked.
 */
export function Radio(binding, group, option, args = {}) {
  const [get, set] = binding;
  const button = el('input', {
    type: 'radio',
    name: group,
    checked: () => get().tag === option,
    onChange: () => set(variant(option)),
  });
  // The attribute, not the property. A radio's value never changes, so it
  // is markup on both sides: the compiler bakes it into the template, and
  // routing it through `el` here would set the property instead and the
  // two trees would differ by exactly that.
  button.setAttribute('value', option);
  if (args.label === undefined) return button;
  return el('label', { class: BASE.row }, [button, text(args.label)]);
}

export function Checkbox(binding, args = {}) {
  const [get, set] = binding;
  const box = el('input', {
    type: 'checkbox',
    checked: get,
    onChange: (e) => set(e.target.checked),
  });
  if (args.label === undefined) return box;
  return el('label', { class: BASE.row }, [box, text(args.label)]);
}

/**
 * Completion toward a goal, bound one way.
 *
 * The leading argument is the value, and nothing writes back: this is a
 * report rather than a control, so there is no listener.
 */
export function Progress(value, args = {}) {
  return el('progress', { value, ...props(args) });
}

/** A value inside a range, with the landmarks a browser colours it by. */
export function Meter(value, args = {}) {
  return el('meter', { value, ...props(args) });
}

export function Spinner(args = {}) {
  return el('span', { 'aria-busy': 'true', ...props(args) }, ['']);
}

export function ErrorBar(args = {}) {
  return el('div', withBase({ role: 'alert', ...props(args) }, BASE.error), [
    text(args.message ?? ''),
  ]);
}

// --- structure, text, lists and media --------------------------------------
//
// These carry no base class and no baked-in attribute: they are the
// language's semantic vocabulary, and what they mean is the tag itself.
// Each is written out rather than generated from a table, because the whole
// value of this file is being an *independent* statement of the DOM shape
// that `element_parity.rs` checks the compiler's table against. A table
// here would be the compiler's table again, in JavaScript.

/** A container: everything it shows is nested inside it. */
function group(tag) {
  return (args = {}, children = []) => el(tag, props(args), children);
}

/** An element whose leading argument is one text node, before children. */
function shown(tag) {
  return (value, args = {}, children = []) =>
    el(tag, props(args), value === undefined ? children : [text(value), ...children]);
}

/** An element with no children at all. */
function empty(tag) {
  return (args = {}) => el(tag, props(args));
}

export const Main = group('main');
export const Section = group('section');
export const Article = group('article');
export const Aside = group('aside');
export const Navigation = group('nav');
export const Header = group('header');
export const Footer = group('footer');
export const Address = group('address');
export const Quote = group('blockquote');
export const List = group('ul');
export const NumberedList = group('ol');
export const Terms = group('dl');
export const HeaderRow = group('tr');
export const TableRow = group('tr');
export const Figure = group('figure');
export const Form = group('form');
export const Fieldset = group('fieldset');
export const Details = group('details');

export const Paragraph = shown('p');
export const Emphasis = shown('em');
export const Strong = shown('strong');
export const Code = shown('code');
export const CodeBlock = shown('pre');
export const Key = shown('kbd');
export const Time = shown('time');
export const Small = shown('small');
export const Mark = shown('mark');
export const Abbreviation = shown('abbr');
/**
 * The name of a control, tied to it by `id`.
 *
 * The one element where `controls` is not `aria-controls`. A `label` has
 * HTML's own `for`, which is what clicking the label acts on and what the
 * accessible-name computation reads there, so the same word reaches the
 * browser by the route that works — exactly as `label` itself does, going
 * to `aria-label` here and into a wrapping `<label>` on a `Checkbox`. The
 * compiler's table makes the same split, keyed on the element.
 */
export function Label(value, args = {}, children = []) {
  const p = props(args);
  if ('aria-controls' in p) {
    p.for = p['aria-controls'];
    delete p['aria-controls'];
  }
  return el('label', p, value === undefined ? children : [text(value), ...children]);
}
export const Legend = shown('legend');
export const Summary = shown('summary');
export const Superscript = shown('sup');
export const Subscript = shown('sub');
export const Item = shown('li');
export const Term = shown('dt');
export const Description = shown('dd');
export const Caption = shown('figcaption');
export const Cell = shown('td');

/**
 * A table, whose rows sit in a row group this function writes.
 *
 * The parser inserts a `tbody` of its own around any `tr` found directly
 * inside a `table`, so a table built without one here and cloned from a
 * template there would be two different trees.
 */
export function Table(args = {}, children = []) {
  return el('table', props(args), [el('tbody', {}, children)]);
}

/** A column heading, which says so: a `th` that heads its column. */
export function HeaderCell(value, args = {}, children = []) {
  return el(
    'th',
    { scope: 'col', ...props(args) },
    value === undefined ? children : [text(value), ...children],
  );
}

/**
 * A rendered document: markup, parsed as markup.
 *
 * The one built-in whose content is parsed rather than assigned as a text
 * node. It is safe for the reason `dom.js`'s `markup` is safe and for no
 * other: its argument's type is `Markup`, the compiler admits nothing else
 * there, and the only producer of a `Markup` is `build markdown`, which
 * escapes raw HTML and rewrites script-bearing URLs before it returns.
 */
export function Prose(value, args = {}) {
  const p = props(args);
  withBase(p, BASE.prose);
  const node = el('div', p);
  markup(node, typeof value === 'function' ? value() : value);
  return node;
}

export const Divider = empty('hr');
export const Break = empty('br');
export const Canvas = empty('canvas');

/**
 * Preserved whitespace that is not code.
 *
 * A `pre`, as `CodeBlock` is, and told apart by its class: `zd-pre` takes
 * the document's own typeface and lets long lines wrap, which is what a
 * poem or an address block wants and what a listing must not have.
 */
export function Preformatted(value, args = {}, children = []) {
  return el(
    'pre',
    withBase(props(args), BASE.preformatted),
    value === undefined ? children : [text(value), ...children],
  );
}

/** An image. `source` and `alt` are required by the compiler, not here. */
export function Image(args = {}) {
  return el('img', props(args));
}

/**
 * A video. `controls` is baked rather than offered: a media element with
 * no controls can be operated by a pointer and by nothing else.
 */
export function Video(args = {}) {
  return el('video', { controls: '', ...props(args) });
}

/** Audio, on the same terms as `Video`. */
export function Audio(args = {}) {
  return el('audio', { controls: '', ...props(args) });
}

/**
 * An embedded document, sandboxed to nothing.
 *
 * The empty `sandbox` grants no capability at all: no script, no form, no
 * top-level navigation, no popup, and an opaque origin, so the framed
 * document can read nothing of the page that embedded it. There is no
 * argument that widens it; `elements.rs` states why.
 */
export function Frame(args = {}) {
  return el('iframe', {
    sandbox: '',
    referrerpolicy: 'no-referrer',
    loading: 'lazy',
    ...props(args),
  });
}

/**
 * A hyperlink, and routing's one element (spec §14G.2 revision 1).
 *
 * The leading argument is where it goes — §14G.2 writes `Link Home` with
 * the destination first and the content nested under it — and it is
 * filtered, because `setAttribute('href', 'javascript:…')` is script
 * execution that no amount of HTML escaping would have caught.
 *
 * A real anchor with a real `href`, because that is the whole argument:
 * clicking one is a browser navigation, so every navigation is crawlable,
 * works with a middle click, and needs no runtime at all. When the
 * destination is one of the program's routes the compiler has already
 * rendered the URL; nothing here parses a path or matches a pattern.
 */
export function Link(destination, args = {}, children = []) {
  const href =
    typeof destination === 'function' ? () => safeUrl(destination()) : safeUrl(destination);
  return el('a', { href, ...props(args) }, children);
}