@aldi/ralma 1.0.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.
package/src/index.js ADDED
@@ -0,0 +1,783 @@
1
+ /**
2
+ * A Ractive component definition as accepted by `Ractive.extend()`.
3
+ *
4
+ * @typedef {object} RalmaComponentDefinition
5
+ * @property {string} template
6
+ * @property {boolean} [isolated]
7
+ * @property {() => Record<string, unknown>} [data]
8
+ * @property {() => void} [oninit]
9
+ * @property {Record<string, () => unknown>} [computed]
10
+ */
11
+
12
+ /**
13
+ * The subset of the `Ractive` constructor that Ralma needs.
14
+ *
15
+ * @typedef {object} RactiveLike
16
+ * @property {(definition: RalmaComponentDefinition) => unknown} extend
17
+ * @property {Record<string | symbol, unknown>} components
18
+ */
19
+
20
+ /**
21
+ * The subset of a live Ractive component instance that Ralma's hooks use.
22
+ *
23
+ * @typedef {object} RactiveInstance
24
+ * @property {(keypath: string) => any} get
25
+ * @property {(keypath: string, value: unknown) => unknown} set
26
+ * @property {(keypath: string, handler: (value: any) => void, options?: { init?: boolean }) => unknown} observe
27
+ */
28
+
29
+ const REGISTRY_MARKER = Symbol.for('ralma.__registered__');
30
+ const UNSAFE_HREF_SCHEME_PATTERN = /^\s*(?:javascript|data|vbscript):/i;
31
+ /**
32
+ * Component names whose `href` runs through {@link sanitizeHref}. A component that renders
33
+ * an `href` and is missing from this set skips sanitizing entirely.
34
+ */
35
+ export const hrefAwareComponentNames = new Set([
36
+ 'button',
37
+ 'card-footer-item',
38
+ 'dropdown-item',
39
+ 'navbar-item',
40
+ 'navbar-link',
41
+ 'pagination-previous',
42
+ 'pagination-next',
43
+ 'pagination-link',
44
+ 'panel-block',
45
+ 'tab-link',
46
+ ]);
47
+ const columnFractionModifiers = [
48
+ 'three-quarters',
49
+ 'two-thirds',
50
+ 'half',
51
+ 'one-third',
52
+ 'one-quarter',
53
+ 'four-fifths',
54
+ 'three-fifths',
55
+ 'two-fifths',
56
+ 'one-fifth',
57
+ ];
58
+ const columnNumberModifiers = Array.from({ length: 12 }, (_, index) => String(index + 1));
59
+ const columnBreakpoints = ['mobile', 'tablet', 'touch', 'desktop', 'widescreen', 'fullhd'];
60
+ // Cast, not annotation: the nested flatMaps below produce `string[][]`, and TS won't narrow an
61
+ // array literal to a tuple through a callback return.
62
+ const columnModifierClassNames = /** @type {Array<[string, string]>} */ ([
63
+ ...columnFractionModifiers.flatMap((modifier) => [
64
+ [modifier, `is-${modifier}`],
65
+ [`offset-${modifier}`, `is-offset-${modifier}`],
66
+ ]),
67
+ ...columnNumberModifiers.flatMap((modifier) => [
68
+ [modifier, `is-${modifier}`],
69
+ [`offset-${modifier}`, `is-offset-${modifier}`],
70
+ ]),
71
+ ['narrow', 'is-narrow'],
72
+ ...columnBreakpoints.map((breakpoint) => [`narrow-${breakpoint}`, `is-narrow-${breakpoint}`]),
73
+ ...columnBreakpoints.flatMap((breakpoint) =>
74
+ columnFractionModifiers.flatMap((modifier) => [
75
+ [`${modifier}-${breakpoint}`, `is-${modifier}-${breakpoint}`],
76
+ [`offset-${modifier}-${breakpoint}`, `is-offset-${modifier}-${breakpoint}`],
77
+ ]),
78
+ ),
79
+ ...columnBreakpoints.flatMap((breakpoint) =>
80
+ columnNumberModifiers.flatMap((modifier) => [
81
+ [`${modifier}-${breakpoint}`, `is-${modifier}-${breakpoint}`],
82
+ [`offset-${modifier}-${breakpoint}`, `is-offset-${modifier}-${breakpoint}`],
83
+ ]),
84
+ ),
85
+ ]);
86
+ const columnsModifierClassNames = /** @type {Array<[string, string]>} */ ([
87
+ ['centered', 'is-centered'],
88
+ ['gapless', 'is-gapless'],
89
+ ['multiline', 'is-multiline'],
90
+ ['vcentered', 'is-vcentered'],
91
+ ['mobile', 'is-mobile'],
92
+ ['desktop', 'is-desktop'],
93
+ ]);
94
+
95
+ /**
96
+ * Builds the `{{#flag}}is-flag{{/}}` chain Bulma modifiers take inside a `class` attribute.
97
+ * Property name and class suffix are always the same, which is what makes this mechanical.
98
+ *
99
+ * @param {...string} modifiers
100
+ * @returns {string}
101
+ */
102
+ function modifierSections(...modifiers) {
103
+ return modifiers.map((modifier) => `{{#${modifier}}}is-${modifier}{{/}}`).join(' ');
104
+ }
105
+
106
+ // Bulma's colors, in the order the templates have always emitted them. Order is part of the rendered
107
+ // output, so these lists are not interchangeable even where they hold the same names.
108
+ const colorModifiers = [
109
+ 'white',
110
+ 'light',
111
+ 'dark',
112
+ 'black',
113
+ 'text',
114
+ 'primary',
115
+ 'link',
116
+ 'info',
117
+ 'success',
118
+ 'warning',
119
+ 'danger',
120
+ ];
121
+ // Panels and messages emit a shorter list, in a different order, with `dark` last.
122
+ const panelColorModifiers = ['primary', 'link', 'info', 'success', 'warning', 'danger', 'dark'];
123
+ const sizeModifiers = ['small', 'medium', 'large'];
124
+ const buttonStateModifiers = [
125
+ 'outlined',
126
+ 'inverted',
127
+ 'rounded',
128
+ 'hovered',
129
+ 'focused',
130
+ 'loading',
131
+ 'static',
132
+ 'active',
133
+ ];
134
+
135
+ const buttonModifiers = modifierSections(
136
+ ...colorModifiers,
137
+ ...sizeModifiers,
138
+ ...buttonStateModifiers,
139
+ );
140
+ const navbarModifiers = modifierSections(
141
+ 'transparent',
142
+ 'spaced',
143
+ 'fixed-top',
144
+ 'fixed-bottom',
145
+ ...colorModifiers,
146
+ );
147
+ const messageModifiers = modifierSections(...panelColorModifiers, ...sizeModifiers);
148
+ const panelModifiers = modifierSections(...panelColorModifiers);
149
+
150
+ const buttonTemplate = `
151
+ {{#if safeHref}}
152
+ <a
153
+ class="button ${buttonModifiers} {{class}}"
154
+ {{#if id}}id="{{id}}"{{/if}}
155
+ {{#if title}}title="{{title}}"{{/if}}
156
+ {{#if disabled}}aria-disabled="true" tabindex="-1"{{/if}}
157
+ {{#if dropdown}}aria-haspopup="true"{{/if}}
158
+ {{#if ariaControls}}aria-controls="{{ariaControls}}"{{/if}}
159
+ {{#if safeHref}}{{#if disabled}}{{else}}href="{{safeHref}}"{{/if}}{{/if}}
160
+ >{{yield}}</a>
161
+ {{else}}
162
+ <button
163
+ type="{{buttonType}}"
164
+ class="button ${buttonModifiers} {{class}}"
165
+ {{#if id}}id="{{id}}"{{/if}}
166
+ {{#if title}}title="{{title}}"{{/if}}
167
+ {{#if disabled}}disabled{{/if}}
168
+ {{#if dropdown}}aria-haspopup="true"{{/if}}
169
+ {{#if ariaControls}}aria-controls="{{ariaControls}}"{{/if}}
170
+ >{{yield}}</button>
171
+ {{/if}}
172
+ `.trim();
173
+
174
+ const componentDefinitions = {
175
+ button: {
176
+ isolated: true,
177
+ data() {
178
+ return {
179
+ buttonType: 'button',
180
+ };
181
+ },
182
+ template: buttonTemplate,
183
+ },
184
+
185
+ // Breadcrumb
186
+ breadcrumb: {
187
+ isolated: true,
188
+ template: `
189
+ <nav
190
+ class="breadcrumb {{#centered}}is-centered{{/}} {{#right}}is-right{{/}} {{#small}}is-small{{/}} {{#medium}}is-medium{{/}} {{#large}}is-large{{/}} {{#arrow}}has-arrow-separator{{/}} {{#bullet}}has-bullet-separator{{/}} {{#dot}}has-dot-separator{{/}} {{#succeeds}}has-succeeds-separator{{/}} {{class}}"
191
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}breadcrumbs{{/if}}"
192
+ {{#if id}}id="{{id}}"{{/if}}
193
+ >
194
+ <ul>{{yield}}</ul>
195
+ </nav>
196
+ `.trim(),
197
+ },
198
+ 'breadcrumb-item': {
199
+ isolated: true,
200
+ template: `
201
+ <li class="{{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
202
+ {{yield}}
203
+ </li>
204
+ `.trim(),
205
+ },
206
+
207
+ // Card
208
+ card: {
209
+ isolated: true,
210
+ template: `<div class="card {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
211
+ },
212
+ 'card-header': {
213
+ isolated: true,
214
+ template: `<header class="card-header {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</header>`,
215
+ },
216
+ 'card-header-title': {
217
+ isolated: true,
218
+ template: `<p class="card-header-title {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</p>`,
219
+ },
220
+ 'card-header-icon': {
221
+ isolated: true,
222
+ template: `
223
+ <button
224
+ type="button"
225
+ class="card-header-icon {{class}}"
226
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}more options{{/if}}"
227
+ {{#if id}}id="{{id}}"{{/if}}
228
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
229
+ >{{yield}}</button>
230
+ `.trim(),
231
+ },
232
+ 'card-image': {
233
+ isolated: true,
234
+ template: `<div class="card-image {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
235
+ },
236
+ 'card-content': {
237
+ isolated: true,
238
+ template: `<div class="card-content {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
239
+ },
240
+ 'card-footer': {
241
+ isolated: true,
242
+ template: `<footer class="card-footer {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</footer>`,
243
+ },
244
+ 'card-footer-item': {
245
+ isolated: true,
246
+ template: `
247
+ {{#if safeHref}}
248
+ <a class="card-footer-item {{class}}" href="{{safeHref}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</a>
249
+ {{else}}
250
+ <div class="card-footer-item {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>
251
+ {{/if}}
252
+ `.trim(),
253
+ },
254
+
255
+ // Dropdowns (beta)
256
+ dropdown: {
257
+ isolated: true,
258
+ template:
259
+ "<div class='dropdown {{#active}}is-active{{/}} {{class}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
260
+ },
261
+ 'dropdown-trigger': {
262
+ isolated: true,
263
+ template:
264
+ "<div class='dropdown-trigger {{class}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
265
+ },
266
+ 'dropdown-button': {
267
+ isolated: true,
268
+ template:
269
+ "<button type='button' class='button {{class}}' {{#if id}} id='{{id}}' {{/if}} aria-haspopup='true' {{#if ariaControls}}aria-controls='{{ariaControls}}'{{/if}}><span>{{yield}}</span><span class='icon is-small'><i class='fas fa-angle-down' aria-hidden='true'></i></span></button>",
270
+ },
271
+ 'dropdown-item': {
272
+ isolated: true,
273
+ // Without an href this must be a <button>: an <a> with no href is not keyboard-focusable, and
274
+ // this element carries an on-click. Bulma styles both a.dropdown-item and button.dropdown-item.
275
+ template: `
276
+ {{#if safeHref}}
277
+ <a
278
+ class="dropdown-item {{class}} {{#active}}is-active{{/}}"
279
+ href="{{safeHref}}"
280
+ {{#if id}}id="{{id}}"{{/if}}
281
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
282
+ >{{yield}}</a>
283
+ {{else}}
284
+ <button
285
+ type="button"
286
+ class="dropdown-item {{class}} {{#active}}is-active{{/}}"
287
+ {{#if id}}id="{{id}}"{{/if}}
288
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
289
+ >{{yield}}</button>
290
+ {{/if}}
291
+ `.trim(),
292
+ },
293
+ 'dropdown-menu': {
294
+ isolated: true,
295
+ // No default role. A real ARIA menu needs role="menuitem" on every child and arrow-key focus
296
+ // management, and Ralma ships no behavior — an announced "menu" that doesn't respond to arrow
297
+ // keys is worse for assistive tech than a plain container. Pass `role` to opt in.
298
+ template:
299
+ "<div class='dropdown-menu {{class}}' {{#if id}} id='{{id}}' {{/if}} {{#if role}}role='{{role}}'{{/if}}>{{yield}}</div>",
300
+ },
301
+ 'dropdown-content': {
302
+ isolated: true,
303
+ template:
304
+ "<div class='dropdown-content {{class}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
305
+ },
306
+ 'dropdown-divider': {
307
+ isolated: true,
308
+ template: "<hr class='dropdown-divider {{class}}' {{#if id}} id='{{id}}' {{/if}}>",
309
+ },
310
+
311
+ // Menu
312
+ menu: {
313
+ isolated: true,
314
+ template: `<aside class="menu {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</aside>`,
315
+ },
316
+ 'menu-label': {
317
+ isolated: true,
318
+ template: `<p class="menu-label {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</p>`,
319
+ },
320
+ 'menu-list': {
321
+ isolated: true,
322
+ template: `<ul class="menu-list {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</ul>`,
323
+ },
324
+
325
+ // Message
326
+ message: {
327
+ isolated: true,
328
+ template: `
329
+ <article class="message ${messageModifiers} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
330
+ {{yield}}
331
+ </article>
332
+ `.trim(),
333
+ },
334
+ 'message-header': {
335
+ isolated: true,
336
+ template: `<div class="message-header {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
337
+ },
338
+ 'message-body': {
339
+ isolated: true,
340
+ template: `<div class="message-body {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
341
+ },
342
+
343
+ // Modal
344
+ modal: {
345
+ isolated: true,
346
+ template: `<div class="modal {{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
347
+ },
348
+ 'modal-background': {
349
+ isolated: true,
350
+ template: `<div class="modal-background {{class}}" {{#if id}}id="{{id}}"{{/if}}></div>`,
351
+ },
352
+ 'modal-content': {
353
+ isolated: true,
354
+ template: `<div class="modal-content {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
355
+ },
356
+ 'modal-close': {
357
+ isolated: true,
358
+ template: `
359
+ <button
360
+ type="button"
361
+ class="modal-close {{#small}}is-small{{/}} {{#medium}}is-medium{{/}} {{#large}}is-large{{/}} {{class}}"
362
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}close{{/if}}"
363
+ {{#if id}}id="{{id}}"{{/if}}
364
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
365
+ ></button>
366
+ `.trim(),
367
+ },
368
+ 'modal-card': {
369
+ isolated: true,
370
+ template: `<div class="modal-card {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
371
+ },
372
+ 'modal-card-head': {
373
+ isolated: true,
374
+ template: `<header class="modal-card-head {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</header>`,
375
+ },
376
+ 'modal-card-title': {
377
+ isolated: true,
378
+ template: `<p class="modal-card-title {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</p>`,
379
+ },
380
+ 'modal-card-body': {
381
+ isolated: true,
382
+ template: `<section class="modal-card-body {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</section>`,
383
+ },
384
+ 'modal-card-foot': {
385
+ isolated: true,
386
+ template: `<footer class="modal-card-foot {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</footer>`,
387
+ },
388
+
389
+ // Navbar
390
+ navbar: {
391
+ isolated: true,
392
+ template: `
393
+ <nav
394
+ class="navbar ${navbarModifiers} {{class}}"
395
+ role="{{#if role}}{{role}}{{else}}navigation{{/if}}"
396
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}main navigation{{/if}}"
397
+ {{#if id}}id="{{id}}"{{/if}}
398
+ >
399
+ {{yield}}
400
+ </nav>
401
+ `.trim(),
402
+ },
403
+ 'navbar-brand': {
404
+ isolated: true,
405
+ template: `<div class="navbar-brand {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
406
+ },
407
+ 'navbar-burger': {
408
+ isolated: true,
409
+ template: `
410
+ <button
411
+ type="button"
412
+ class="navbar-burger {{#active}}is-active{{/}} {{class}}"
413
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}menu{{/if}}"
414
+ aria-expanded="{{#if expanded}}true{{else}}false{{/if}}"
415
+ {{#if dataTarget}}data-target="{{dataTarget}}"{{/if}}
416
+ {{#if id}}id="{{id}}"{{/if}}
417
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
418
+ >{{yield}}</button>
419
+ `.trim(),
420
+ },
421
+ 'navbar-menu': {
422
+ isolated: true,
423
+ template: `<div class="navbar-menu {{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
424
+ },
425
+ 'navbar-start': {
426
+ isolated: true,
427
+ template: `<div class="navbar-start {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
428
+ },
429
+ 'navbar-end': {
430
+ isolated: true,
431
+ template: `<div class="navbar-end {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>`,
432
+ },
433
+ 'navbar-item': {
434
+ isolated: true,
435
+ template: `
436
+ {{#if safeHref}}
437
+ <a
438
+ class="navbar-item {{#active}}is-active{{/}} {{class}}"
439
+ href="{{safeHref}}"
440
+ {{#if id}}id="{{id}}"{{/if}}
441
+ {{#if onclick}}on-click="{{onclick}}"{{/if}}
442
+ >{{yield}}</a>
443
+ {{else}}
444
+ <div class="navbar-item {{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>
445
+ {{/if}}
446
+ `.trim(),
447
+ },
448
+ 'navbar-item-dropdown': {
449
+ isolated: true,
450
+ template: `
451
+ <div class="navbar-item has-dropdown {{#active}}is-active{{/}} {{#hoverable}}is-hoverable{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
452
+ {{yield}}
453
+ </div>
454
+ `.trim(),
455
+ },
456
+ 'navbar-link': {
457
+ isolated: true,
458
+ template: `
459
+ {{#if safeHref}}
460
+ <a class="navbar-link {{#active}}is-active{{/}} {{class}}" href="{{safeHref}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</a>
461
+ {{else}}
462
+ <button type="button" class="navbar-link {{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</button>
463
+ {{/if}}
464
+ `.trim(),
465
+ },
466
+ 'navbar-dropdown': {
467
+ isolated: true,
468
+ template: `
469
+ <div class="navbar-dropdown {{#right}}is-right{{/}} {{#boxed}}is-boxed{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
470
+ {{yield}}
471
+ </div>
472
+ `.trim(),
473
+ },
474
+ 'navbar-divider': {
475
+ isolated: true,
476
+ template: `<hr class="navbar-divider {{class}}" {{#if id}}id="{{id}}"{{/if}} />`,
477
+ },
478
+
479
+ // Pagination
480
+ pagination: {
481
+ isolated: true,
482
+ template: `
483
+ <nav
484
+ class="pagination {{#centered}}is-centered{{/}} {{#right}}is-right{{/}} {{#rounded}}is-rounded{{/}} {{#small}}is-small{{/}} {{#medium}}is-medium{{/}} {{#large}}is-large{{/}} {{class}}"
485
+ role="{{#if role}}{{role}}{{else}}navigation{{/if}}"
486
+ aria-label="{{#if ariaLabel}}{{ariaLabel}}{{else}}pagination{{/if}}"
487
+ {{#if id}}id="{{id}}"{{/if}}
488
+ >
489
+ {{yield}}
490
+ </nav>
491
+ `.trim(),
492
+ },
493
+ 'pagination-previous': {
494
+ isolated: true,
495
+ template: `
496
+ <a
497
+ class="pagination-previous {{class}}"
498
+ {{#if id}}id="{{id}}"{{/if}}
499
+ {{#if title}}title="{{title}}"{{/if}}
500
+ {{#if disabled}}aria-disabled="true" tabindex="-1"{{/if}}
501
+ {{#if safeHref}}{{#if disabled}}{{else}}href="{{safeHref}}"{{/if}}{{/if}}
502
+ >{{yield}}</a>
503
+ `.trim(),
504
+ },
505
+ 'pagination-next': {
506
+ isolated: true,
507
+ template: `
508
+ <a
509
+ class="pagination-next {{class}}"
510
+ {{#if id}}id="{{id}}"{{/if}}
511
+ {{#if title}}title="{{title}}"{{/if}}
512
+ {{#if disabled}}aria-disabled="true" tabindex="-1"{{/if}}
513
+ {{#if safeHref}}{{#if disabled}}{{else}}href="{{safeHref}}"{{/if}}{{/if}}
514
+ >{{yield}}</a>
515
+ `.trim(),
516
+ },
517
+ 'pagination-list': {
518
+ isolated: true,
519
+ template: `<ul class="pagination-list {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</ul>`,
520
+ },
521
+ 'pagination-link': {
522
+ isolated: true,
523
+ template: `
524
+ <a
525
+ class="pagination-link {{#current}}is-current{{/}} {{class}}"
526
+ {{#if ariaLabel}}aria-label="{{ariaLabel}}"{{else}}{{#if page}}aria-label="Goto page {{page}}"{{/if}}{{/if}}
527
+ {{#if current}}aria-current="page"{{/if}}
528
+ {{#if id}}id="{{id}}"{{/if}}
529
+ {{#if safeHref}}href="{{safeHref}}"{{/if}}
530
+ >{{yield}}</a>
531
+ `.trim(),
532
+ },
533
+ 'pagination-ellipsis': {
534
+ isolated: true,
535
+ template: `<span class="pagination-ellipsis {{class}}" {{#if id}}id="{{id}}"{{/if}}>&hellip;</span>`,
536
+ },
537
+
538
+ // Panel
539
+ panel: {
540
+ isolated: true,
541
+ template: `
542
+ <nav class="panel ${panelModifiers} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
543
+ {{yield}}
544
+ </nav>
545
+ `.trim(),
546
+ },
547
+ 'panel-heading': {
548
+ isolated: true,
549
+ template: `<p class="panel-heading {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</p>`,
550
+ },
551
+ 'panel-tabs': {
552
+ isolated: true,
553
+ template: `<p class="panel-tabs {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</p>`,
554
+ },
555
+ 'panel-block': {
556
+ isolated: true,
557
+ template: `
558
+ {{#if safeHref}}
559
+ <a class="panel-block {{#active}}is-active{{/}} {{class}}" href="{{safeHref}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</a>
560
+ {{else}}
561
+ <div class="panel-block {{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</div>
562
+ {{/if}}
563
+ `.trim(),
564
+ },
565
+ 'panel-icon': {
566
+ isolated: true,
567
+ template: `<span class="panel-icon {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</span>`,
568
+ },
569
+
570
+ // Tabs
571
+ tabs: {
572
+ isolated: true,
573
+ template: `
574
+ <div class="tabs {{#centered}}is-centered{{/}} {{#right}}is-right{{/}} {{#boxed}}is-boxed{{/}} {{#toggle}}is-toggle{{/}} {{#toggle-rounded}}is-toggle-rounded{{/}} {{#fullwidth}}is-fullwidth{{/}} {{#small}}is-small{{/}} {{#medium}}is-medium{{/}} {{#large}}is-large{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>
575
+ <ul>{{yield}}</ul>
576
+ </div>
577
+ `.trim(),
578
+ },
579
+ tab: {
580
+ isolated: true,
581
+ template: `<li class="{{#active}}is-active{{/}} {{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</li>`,
582
+ },
583
+ 'tab-link': {
584
+ isolated: true,
585
+ template: `
586
+ {{#if safeHref}}
587
+ <a href="{{safeHref}}" class="{{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</a>
588
+ {{else}}
589
+ <button type="button" class="{{class}}" {{#if id}}id="{{id}}"{{/if}}>{{yield}}</button>
590
+ {{/if}}
591
+ `.trim(),
592
+ },
593
+
594
+ content: {
595
+ isolated: true,
596
+ template:
597
+ "<div class='content {{#small}}is-small{{/}} {{#medium}}is-medium{{/}} {{#large}}is-large{{/}} {{class}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
598
+ },
599
+
600
+ column: {
601
+ isolated: true,
602
+ computed: {
603
+ /** @this {RactiveInstance} */
604
+ columnModifiers() {
605
+ return buildModifierClassNames(this, columnModifierClassNames);
606
+ },
607
+ },
608
+ template:
609
+ "<div class='column {{class}} {{columnModifiers}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
610
+ },
611
+
612
+ columns: {
613
+ isolated: true,
614
+ computed: {
615
+ /** @this {RactiveInstance} */
616
+ columnsModifiers() {
617
+ return buildModifierClassNames(this, columnsModifierClassNames);
618
+ },
619
+ },
620
+ template:
621
+ "<div class='columns {{class}} {{columnsModifiers}}' {{#if id}} id='{{id}}' {{/if}}>{{yield}}</div>",
622
+ },
623
+ };
624
+
625
+ for (const definition of Object.values(componentDefinitions)) Object.freeze(definition);
626
+ Object.freeze(componentDefinitions);
627
+
628
+ const componentEntries = /** @type {Array<[string, RalmaComponentDefinition]>} */ (
629
+ Object.entries(componentDefinitions)
630
+ );
631
+ /** @type {Array<[string, RalmaComponentDefinition]>} */
632
+ const runtimeComponentEntries = componentEntries.map(([name, definition]) => [
633
+ name,
634
+ hrefAwareComponentNames.has(name) ? withSafeHref(definition) : definition,
635
+ ]);
636
+ export const componentNames = Object.freeze(componentEntries.map(([name]) => name));
637
+
638
+ /**
639
+ * Removes C0 control characters, space, and DEL.
640
+ *
641
+ * @param {string} value
642
+ * @returns {string}
643
+ */
644
+ function stripControlCharacters(value) {
645
+ return Array.from(value)
646
+ .filter((character) => {
647
+ const code = character.charCodeAt(0);
648
+ return code > 0x20 && code !== 0x7f;
649
+ })
650
+ .join('');
651
+ }
652
+
653
+ /**
654
+ * Returns the href if it is safe to render, or `null` if it must be dropped.
655
+ *
656
+ * @param {unknown} href
657
+ * @returns {string | null}
658
+ */
659
+ function sanitizeHref(href) {
660
+ if (typeof href !== 'string') return null;
661
+ const trimmedHref = href.trim();
662
+ if (trimmedHref === '') return null;
663
+ // Browsers ignore control characters inside a URL scheme, so "java<TAB>script:" still
664
+ // executes. Drop them before testing, but keep the original as the rendered value.
665
+ if (UNSAFE_HREF_SCHEME_PATTERN.test(stripControlCharacters(trimmedHref))) return null;
666
+ return trimmedHref;
667
+ }
668
+
669
+ /**
670
+ * Maps a component's truthy modifier properties to their Bulma class names.
671
+ *
672
+ * @param {{ get: (key: string) => unknown }} component
673
+ * @param {ReadonlyArray<readonly [string, string]>} modifierClassNames
674
+ * @returns {string}
675
+ */
676
+ function buildModifierClassNames(component, modifierClassNames) {
677
+ return modifierClassNames
678
+ .filter(([key]) => component.get(key))
679
+ .map(([, className]) => className)
680
+ .join(' ');
681
+ }
682
+
683
+ /**
684
+ * Wraps a component definition so its `href` is sanitized into `safeHref`, both at init and on
685
+ * every later change. Templates must render `safeHref`, never the raw `href`.
686
+ *
687
+ * @param {RalmaComponentDefinition} definition
688
+ * @returns {RalmaComponentDefinition}
689
+ */
690
+ function withSafeHref(definition) {
691
+ const existingData = typeof definition.data === 'function' ? definition.data : null;
692
+ const existingOninit = typeof definition.oninit === 'function' ? definition.oninit : null;
693
+
694
+ return {
695
+ ...definition,
696
+ /** @this {RactiveInstance} */
697
+ data() {
698
+ const baseData = existingData ? existingData.call(this) : {};
699
+ return {
700
+ ...baseData,
701
+ safeHref: sanitizeHref(baseData.href),
702
+ };
703
+ },
704
+ /** @this {RactiveInstance} */
705
+ oninit() {
706
+ if (existingOninit) existingOninit.call(this);
707
+
708
+ /** @param {unknown} href */
709
+ const syncSafeHref = (href) => {
710
+ const nextSafeHref = sanitizeHref(href);
711
+ if (this.get('safeHref') !== nextSafeHref) this.set('safeHref', nextSafeHref);
712
+ };
713
+
714
+ syncSafeHref(this.get('href'));
715
+ this.observe(
716
+ 'href',
717
+ (nextHref) => {
718
+ syncSafeHref(nextHref);
719
+ },
720
+ { init: false },
721
+ );
722
+ },
723
+ };
724
+ }
725
+
726
+ /**
727
+ * Registers Ralma components onto a Ractive constructor.
728
+ *
729
+ * @param {unknown} ractive - The `Ractive` constructor (global or imported).
730
+ * @param {{overwrite?: boolean, warnOnCollision?: boolean}} [options]
731
+ * - `overwrite`: overwrite already-registered component names.
732
+ * - `warnOnCollision`: emit console warnings when a name is skipped.
733
+ * @returns {unknown} The same `Ractive` reference.
734
+ */
735
+ export function registerRalma(ractive, options = {}) {
736
+ if (ractive == null || (typeof ractive !== 'object' && typeof ractive !== 'function')) {
737
+ throw new TypeError('registerRalma(Ractive): Ractive must be an object or function');
738
+ }
739
+
740
+ // The runtime guards above and below are the real contract; this cast only tells the type
741
+ // checker what shape those guards have already proven.
742
+ const target = /** @type {RactiveLike} */ (ractive);
743
+
744
+ const hasExtend = typeof target.extend === 'function';
745
+ const hasComponents = target.components != null && typeof target.components === 'object';
746
+ if (!hasExtend || !hasComponents) {
747
+ throw new TypeError('registerRalma(Ractive): Ractive must have .extend() and .components');
748
+ }
749
+
750
+ if (options == null || (typeof options !== 'object' && typeof options !== 'function')) {
751
+ throw new TypeError('registerRalma(Ractive, options): options must be an object when provided');
752
+ }
753
+
754
+ const overwrite = options.overwrite === true;
755
+ const warnOnCollision = options.warnOnCollision === true;
756
+
757
+ const registrationState = target.components[REGISTRY_MARKER];
758
+ if (
759
+ registrationState &&
760
+ typeof registrationState === 'object' &&
761
+ /** @type {{ definitions?: unknown }} */ (registrationState).definitions ===
762
+ componentDefinitions &&
763
+ /** @type {{ overwrite?: unknown }} */ (registrationState).overwrite === overwrite
764
+ ) {
765
+ return ractive;
766
+ }
767
+
768
+ for (const [name, def] of runtimeComponentEntries) {
769
+ if (!overwrite && Object.prototype.hasOwnProperty.call(target.components, name)) {
770
+ if (warnOnCollision) {
771
+ console.warn(`registerRalma: skipping existing component "${name}"`);
772
+ }
773
+ continue;
774
+ }
775
+ target.components[name] = target.extend(def);
776
+ }
777
+
778
+ target.components[REGISTRY_MARKER] = Object.freeze({
779
+ definitions: componentDefinitions,
780
+ overwrite,
781
+ });
782
+ return ractive;
783
+ }