@navette-js/support 1.0.0-beta.3 → 1.0.0-beta.5
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/THIRD-PARTY-NOTICES.md +2 -1
- package/data/lang.en-fr.json +184 -10
- package/icons/filament/LICENSE.md +21 -0
- package/icons/filament/m-pencil-slash.svg +4 -0
- package/icons/filament/o-align-center.svg +5 -0
- package/icons/filament/o-align-end.svg +5 -0
- package/icons/filament/o-align-justify.svg +5 -0
- package/icons/filament/o-align-start.svg +5 -0
- package/icons/filament/o-arrows-move.svg +11 -0
- package/icons/filament/o-clear-formatting.svg +6 -0
- package/icons/filament/o-code-block.svg +5 -0
- package/icons/filament/o-code.svg +5 -0
- package/icons/filament/o-columns-delete.svg +11 -0
- package/icons/filament/o-columns.svg +10 -0
- package/icons/filament/o-crop.svg +5 -0
- package/icons/filament/o-details.svg +5 -0
- package/icons/filament/o-flip-horizontal.svg +3 -0
- package/icons/filament/o-flip-vertical.svg +3 -0
- package/icons/filament/o-h1.svg +11 -0
- package/icons/filament/o-h2.svg +11 -0
- package/icons/filament/o-h3.svg +12 -0
- package/icons/filament/o-h4.svg +11 -0
- package/icons/filament/o-h5.svg +11 -0
- package/icons/filament/o-h6.svg +12 -0
- package/icons/filament/o-heading.svg +9 -0
- package/icons/filament/o-highlight.svg +6 -0
- package/icons/filament/o-lead.svg +6 -0
- package/icons/filament/o-merge-tag.svg +7 -0
- package/icons/filament/o-paragraph.svg +5 -0
- package/icons/filament/o-small.svg +5 -0
- package/icons/filament/o-subscript.svg +5 -0
- package/icons/filament/o-superscript.svg +5 -0
- package/icons/filament/o-table-add-column-after.svg +5 -0
- package/icons/filament/o-table-add-column-before.svg +5 -0
- package/icons/filament/o-table-add-row-after.svg +5 -0
- package/icons/filament/o-table-add-row-before.svg +5 -0
- package/icons/filament/o-table-delete-column.svg +5 -0
- package/icons/filament/o-table-delete-row.svg +5 -0
- package/icons/filament/o-table-delete.svg +6 -0
- package/icons/filament/o-table-merge-cells.svg +7 -0
- package/icons/filament/o-table-split-cell.svg +6 -0
- package/icons/filament/o-table-toggle-header-cell.svg +4 -0
- package/icons/filament/o-table-toggle-header-row.svg +7 -0
- package/icons/filament/o-table.svg +5 -0
- package/lib/components.d.ts +6 -0
- package/lib/components.js +130 -0
- package/lib/html.d.ts +12 -0
- package/lib/html.js +180 -0
- package/lib/index.d.ts +26 -2
- package/lib/index.js +68 -9
- package/lib/ui.d.ts +2 -0
- package/lib/ui.js +32 -1
- package/package.json +1 -1
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-slot="icon">
|
|
2
|
+
<path d="M12 21v-6"/>
|
|
3
|
+
<path d="M12 9V3"/>
|
|
4
|
+
<path d="M3 15h18"/>
|
|
5
|
+
<path d="M3 9h18"/>
|
|
6
|
+
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-slot="icon">
|
|
2
|
+
<path d="M12 15V9"/>
|
|
3
|
+
<path d="M3 15h18"/>
|
|
4
|
+
<path d="M3 9h18"/>
|
|
5
|
+
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-slot="icon">
|
|
2
|
+
<path d="M12 3v17a1 1 0 0 1-1 1H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a1 1 0 0 1-1 1H3"/>
|
|
3
|
+
<path d="m16 19 2 2 4-4"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-slot="icon">
|
|
2
|
+
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
|
3
|
+
<path d="M14 9h1"/>
|
|
4
|
+
<path d="M19 9h2"/>
|
|
5
|
+
<path d="M3 9h2"/>
|
|
6
|
+
<path d="M9 9h1"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-slot="icon">
|
|
2
|
+
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z"/>
|
|
3
|
+
<path d="M3 10h18"/>
|
|
4
|
+
<path d="M10 3v18"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Rewrites component tags to EJS. Templates without `<x-` are returned unchanged. */
|
|
2
|
+
export declare function compileComponentTags(source: string, file?: string): string;
|
|
3
|
+
/** Attribute names that go to the component's attribute bag instead of its props (HTML, Alpine and Livewire attributes). */
|
|
4
|
+
export declare const isHtmlAttribute: (name: string) => boolean;
|
|
5
|
+
/** `icon-position` → `iconPosition`. */
|
|
6
|
+
export declare const camel: (name: string) => string;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Component tags in EJS views: `<x-name …>…</x-name>`, `<x-name … />` and named slots (`<x-slot name="footer">` or `<x-slot:footer>`),
|
|
2
|
+
// compiled to plain EJS before the template itself is compiled.
|
|
3
|
+
//
|
|
4
|
+
// A component's content is captured from the template's output buffer (`__output`, EJS's own variable): the
|
|
5
|
+
// generated code remembers where the content starts, cuts it out once it is rendered, and passes it as the slot.
|
|
6
|
+
const NAME = /^[A-Za-z0-9_.:-]+/;
|
|
7
|
+
/** Escapes a string for a JavaScript string literal placed inside an EJS tag. */
|
|
8
|
+
const literal = (value) => JSON.stringify(value).replace(/%>/g, '%\\x3e');
|
|
9
|
+
function parseAttributes(source, tag) {
|
|
10
|
+
const attributes = [];
|
|
11
|
+
let rest = source.trim();
|
|
12
|
+
while (rest) {
|
|
13
|
+
const match = /^(:?)([A-Za-z_@][\w.:@-]*)(?:\s*=\s*("([^"]*)"|'([^']*)'))?/.exec(rest);
|
|
14
|
+
if (!match)
|
|
15
|
+
throw new Error(`Invalid attribute in <x-${tag}>: ${rest.slice(0, 40)}`);
|
|
16
|
+
const [whole, bound, name, quoted, double, single] = match;
|
|
17
|
+
const value = double ?? single ?? '';
|
|
18
|
+
attributes.push({ name, value, kind: bound ? 'expression' : quoted === undefined ? 'flag' : 'string' });
|
|
19
|
+
rest = rest.slice(whole.length).trim();
|
|
20
|
+
}
|
|
21
|
+
return attributes;
|
|
22
|
+
}
|
|
23
|
+
/** JavaScript object literal of a tag's attributes, evaluated in the template's scope. */
|
|
24
|
+
function propsExpression(attributes) {
|
|
25
|
+
return `{ ${attributes.map((attribute) => `${literal(attribute.name)}: ${attribute.kind === 'expression' ? `(${attribute.value})` : attribute.kind === 'flag' ? 'true' : literal(attribute.value)}`).join(', ')} }`;
|
|
26
|
+
}
|
|
27
|
+
/** Finds the `>` that ends a tag, skipping quoted attribute values. */
|
|
28
|
+
function tagEnd(source, from) {
|
|
29
|
+
let quote = null;
|
|
30
|
+
for (let index = from; index < source.length; index++) {
|
|
31
|
+
const char = source[index];
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (char === quote)
|
|
34
|
+
quote = null;
|
|
35
|
+
}
|
|
36
|
+
else if (char === '"' || char === "'")
|
|
37
|
+
quote = char;
|
|
38
|
+
else if (char === '>')
|
|
39
|
+
return index;
|
|
40
|
+
}
|
|
41
|
+
return -1;
|
|
42
|
+
}
|
|
43
|
+
/** Rewrites component tags to EJS. Templates without `<x-` are returned unchanged. */
|
|
44
|
+
export function compileComponentTags(source, file = 'template') {
|
|
45
|
+
if (!source.includes('<x-'))
|
|
46
|
+
return source;
|
|
47
|
+
let out = '<% var __xs = [], __xn = []; %>';
|
|
48
|
+
const open = [];
|
|
49
|
+
let index = 0;
|
|
50
|
+
const capture = '<% __xs.push(__output.length); %>';
|
|
51
|
+
const cut = 'const __s = __output.slice(__xs.pop()); __output = __output.slice(0, __output.length - __s.length);';
|
|
52
|
+
while (index < source.length) {
|
|
53
|
+
const next = source.indexOf('<', index);
|
|
54
|
+
if (next === -1) {
|
|
55
|
+
out += source.slice(index);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
out += source.slice(index, next);
|
|
59
|
+
// EJS tags (code, output, comments) are copied as they are: a tag quoted in a comment is not a component.
|
|
60
|
+
if (source.startsWith('<%', next)) {
|
|
61
|
+
const ejsEnd = source.indexOf('%>', next + 2);
|
|
62
|
+
if (ejsEnd === -1) {
|
|
63
|
+
out += source.slice(next);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
out += source.slice(next, ejsEnd + 2);
|
|
67
|
+
index = ejsEnd + 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const closing = source.startsWith('</x-', next);
|
|
71
|
+
if (!closing && !source.startsWith('<x-', next)) {
|
|
72
|
+
out += '<';
|
|
73
|
+
index = next + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const nameStart = next + (closing ? 4 : 3);
|
|
77
|
+
const name = NAME.exec(source.slice(nameStart))?.[0];
|
|
78
|
+
if (!name) {
|
|
79
|
+
out += '<';
|
|
80
|
+
index = next + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const end = tagEnd(source, nameStart + name.length);
|
|
84
|
+
if (end === -1)
|
|
85
|
+
throw new Error(`${file}: unclosed tag <x-${name}`);
|
|
86
|
+
const body = source.slice(nameStart + name.length, end);
|
|
87
|
+
index = end + 1;
|
|
88
|
+
if (closing) {
|
|
89
|
+
const current = open.pop();
|
|
90
|
+
const closesSlot = name === 'slot' && current?.name.startsWith('slot');
|
|
91
|
+
if (!current || (current.name !== name && !closesSlot))
|
|
92
|
+
throw new Error(`${file}: </x-${name}> does not close ${current ? `<x-${current.name}>` : 'any tag'}`);
|
|
93
|
+
if (name === 'slot' || name.startsWith('slot:'))
|
|
94
|
+
out += `<% { ${cut} __xn[__xn.length - 1][${current.props}] = __s; } %>`;
|
|
95
|
+
else
|
|
96
|
+
out += `<% { ${cut} const __n = __xn.pop(); %><%- __component(${literal(name)}, ${current.props}, __s, __n) %><% } %>`;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const selfClosing = body.trimEnd().endsWith('/');
|
|
100
|
+
const attributes = parseAttributes(selfClosing ? body.trimEnd().slice(0, -1) : body, name);
|
|
101
|
+
if (name === 'slot' || name.startsWith('slot:')) {
|
|
102
|
+
const slotName = name.startsWith('slot:') ? name.slice(5) : attributes.find((attribute) => attribute.name === 'name')?.value;
|
|
103
|
+
if (!slotName)
|
|
104
|
+
throw new Error(`${file}: <x-slot> needs a name`);
|
|
105
|
+
if (!open.length)
|
|
106
|
+
throw new Error(`${file}: <x-slot name="${slotName}"> must be inside a component`);
|
|
107
|
+
if (selfClosing) {
|
|
108
|
+
out += `<% __xn[__xn.length - 1][${literal(slotName)}] = ''; %>`;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
open.push({ name, props: literal(slotName) });
|
|
112
|
+
out += capture;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const props = propsExpression(attributes);
|
|
116
|
+
if (selfClosing) {
|
|
117
|
+
out += `<%- __component(${literal(name)}, ${props}, '', {}) %>`;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
open.push({ name, props });
|
|
121
|
+
out += `<% __xn.push({}); %>${capture}`;
|
|
122
|
+
}
|
|
123
|
+
if (open.length)
|
|
124
|
+
throw new Error(`${file}: <x-${open[open.length - 1].name}> is never closed`);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** Attribute names that go to the component's attribute bag instead of its props (HTML, Alpine and Livewire attributes). */
|
|
128
|
+
export const isHtmlAttribute = (name) => /^(class|style|id)$/.test(name) || /^(x-|data-|aria-|wire:|@)/.test(name) || name.includes(':');
|
|
129
|
+
/** `icon-position` → `iconPosition`. */
|
|
130
|
+
export const camel = (name) => name.replace(/-([a-z0-9])/g, (_match, char) => char.toUpperCase());
|
package/lib/html.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Link targets: http(s), mailto, site paths, anchors and relative paths; never `javascript:`, `data:` or another scheme. */
|
|
2
|
+
export declare function isSafeUrl(value: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Removes everything but a small set of formatting elements (paragraphs, emphasis, lists, links, headings, code...) and
|
|
5
|
+
* their safe attributes. No `style`, `class`, event handler, image or form survives, and links only keep safe URLs.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sanitizeHtml(html: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Renders Markdown (paragraphs, headings, lists, quotes, code blocks, emphasis, links). Raw HTML is never passed through:
|
|
10
|
+
* it is shown as text. Links only keep safe URLs.
|
|
11
|
+
*/
|
|
12
|
+
export declare function markdownToHtml(markdown: string): string;
|
package/lib/html.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// HTML from application data: an allowlist sanitizer, and a small Markdown renderer whose output is safe by construction.
|
|
2
|
+
/** Allowed elements and, for each one, its allowed attributes. Everything else is removed (the text inside is kept). */
|
|
3
|
+
const ALLOWED = {
|
|
4
|
+
a: ['href', 'title'], abbr: ['title'], b: [], blockquote: [], br: [], code: [], del: [], div: [], em: [], h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
|
|
5
|
+
hr: [], i: [], kbd: [], li: [], mark: [], ol: ['start'], p: [], pre: [], s: [], small: [], span: [], strong: [], sub: [], sup: [], u: [], ul: [],
|
|
6
|
+
};
|
|
7
|
+
const VOID = new Set(['br', 'hr']);
|
|
8
|
+
/** Elements removed together with their content. */
|
|
9
|
+
const DROPPED = new Set(['script', 'style', 'iframe', 'object', 'embed', 'template', 'textarea', 'title', 'noscript', 'svg', 'math', 'select', 'head', 'frame', 'frameset', 'applet', 'noembed', 'xmp', 'plaintext']);
|
|
10
|
+
const escapeAttribute = (value) => value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
11
|
+
/** Text between tags: entities are kept, anything else that could start markup is escaped. */
|
|
12
|
+
const escapeText = (text) => text.replace(/&(?!(?:#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});)/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
13
|
+
const decodeEntities = (value) => value
|
|
14
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCodePoint(Math.min(Number.parseInt(hex, 16), 0x10ffff)))
|
|
15
|
+
.replace(/&#(\d+);?/g, (_, dec) => String.fromCodePoint(Math.min(Number(dec), 0x10ffff)))
|
|
16
|
+
.replace(/:/gi, ':').replace(/&tab;/gi, '\t').replace(/&newline;/gi, '\n').replace(/&/gi, '&');
|
|
17
|
+
/** Link targets: http(s), mailto, site paths, anchors and relative paths; never `javascript:`, `data:` or another scheme. */
|
|
18
|
+
export function isSafeUrl(value) {
|
|
19
|
+
const url = decodeEntities(value).replace(/[\u0000- \u007f-\u009f]/g, '');
|
|
20
|
+
if (/^(https?:|mailto:)/i.test(url))
|
|
21
|
+
return true;
|
|
22
|
+
if (url.startsWith('//'))
|
|
23
|
+
return false;
|
|
24
|
+
// No scheme at all: a colon may only appear after a `/`, `?` or `#`.
|
|
25
|
+
return !/^[^/?#]*:/.test(url);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Removes everything but a small set of formatting elements (paragraphs, emphasis, lists, links, headings, code...) and
|
|
29
|
+
* their safe attributes. No `style`, `class`, event handler, image or form survives, and links only keep safe URLs.
|
|
30
|
+
*/
|
|
31
|
+
export function sanitizeHtml(html) {
|
|
32
|
+
const out = [];
|
|
33
|
+
const open = [];
|
|
34
|
+
const source = String(html ?? '');
|
|
35
|
+
let position = 0;
|
|
36
|
+
while (position < source.length) {
|
|
37
|
+
const next = source.indexOf('<', position);
|
|
38
|
+
if (next < 0) {
|
|
39
|
+
out.push(escapeText(source.slice(position)));
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
out.push(escapeText(source.slice(position, next)));
|
|
43
|
+
position = next;
|
|
44
|
+
const rest = source.slice(position);
|
|
45
|
+
if (rest.startsWith('<!--')) {
|
|
46
|
+
const end = source.indexOf('-->', position + 4);
|
|
47
|
+
position = end < 0 ? source.length : end + 3;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const tag = /^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/.exec(rest);
|
|
51
|
+
if (!tag) {
|
|
52
|
+
out.push('<');
|
|
53
|
+
position++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
position += tag[0].length;
|
|
57
|
+
const [, closing, rawName, rawAttributes] = tag;
|
|
58
|
+
const name = rawName.toLowerCase();
|
|
59
|
+
if (DROPPED.has(name)) {
|
|
60
|
+
if (!closing) {
|
|
61
|
+
const end = source.toLowerCase().indexOf(`</${name}`, position);
|
|
62
|
+
position = end < 0 ? source.length : (source.indexOf('>', end) < 0 ? source.length : source.indexOf('>', end) + 1);
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!Object.hasOwn(ALLOWED, name))
|
|
67
|
+
continue;
|
|
68
|
+
if (closing) {
|
|
69
|
+
const index = open.lastIndexOf(name);
|
|
70
|
+
if (index >= 0)
|
|
71
|
+
while (open.length > index)
|
|
72
|
+
out.push(`</${open.pop()}>`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const attributes = [];
|
|
76
|
+
for (const match of rawAttributes.matchAll(/([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)))?/g)) {
|
|
77
|
+
const attribute = match[1].toLowerCase();
|
|
78
|
+
const value = match[2] ?? match[3] ?? match[4] ?? '';
|
|
79
|
+
if (!ALLOWED[name].includes(attribute) || attributes.some((existing) => existing.startsWith(`${attribute}=`)))
|
|
80
|
+
continue;
|
|
81
|
+
if (attribute === 'href' && !isSafeUrl(value))
|
|
82
|
+
continue;
|
|
83
|
+
if (attribute === 'start' && !/^\d{1,9}$/.test(value))
|
|
84
|
+
continue;
|
|
85
|
+
attributes.push(`${attribute}="${escapeAttribute(decodeEntities(value))}"`);
|
|
86
|
+
}
|
|
87
|
+
if (name === 'a' && attributes.some((attribute) => attribute.startsWith('href=')))
|
|
88
|
+
attributes.push('rel="noopener noreferrer nofollow"');
|
|
89
|
+
out.push(`<${name}${attributes.length ? ` ${attributes.join(' ')}` : ''}>`);
|
|
90
|
+
if (!VOID.has(name))
|
|
91
|
+
open.push(name);
|
|
92
|
+
}
|
|
93
|
+
while (open.length)
|
|
94
|
+
out.push(`</${open.pop()}>`);
|
|
95
|
+
return out.join('');
|
|
96
|
+
}
|
|
97
|
+
// ------------------------------------------------------------------ Markdown
|
|
98
|
+
const escapeHtml = (value) => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
99
|
+
/** Inline Markdown: code spans, links, bold, italic, strikethrough and hard line breaks. The text is escaped first. */
|
|
100
|
+
function inlineMarkdown(source) {
|
|
101
|
+
const codes = [];
|
|
102
|
+
let text = source.replace(/`([^`]+)`/g, (_, code) => `\u0000${codes.push(`<code>${escapeHtml(code)}</code>`) - 1}\u0000`);
|
|
103
|
+
text = escapeHtml(text);
|
|
104
|
+
text = text.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (_, label, href, title) => {
|
|
105
|
+
const url = href.replace(/&/g, '&');
|
|
106
|
+
if (!isSafeUrl(url))
|
|
107
|
+
return label;
|
|
108
|
+
return `<a href="${escapeHtml(url)}"${title ? ` title="${title}"` : ''} rel="noopener noreferrer nofollow">${label}</a>`;
|
|
109
|
+
});
|
|
110
|
+
// Bare URLs become links; they stop before an escaped quote or angle bracket.
|
|
111
|
+
text = text.replace(/(^|[\s(])(https?:\/\/(?:(?!"|<|>)[^\s<])*(?:(?!"|<|>)[^\s<.,;:!?)]))/g, (_, before, url) => `${before}<a href="${url}" rel="noopener noreferrer nofollow">${url}</a>`);
|
|
112
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>').replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
|
113
|
+
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>').replace(/(^|[^\w])_([^_\s][^_]*)_(?=[^\w]|$)/g, '$1<em>$2</em>')
|
|
114
|
+
.replace(/~~([^~]+)~~/g, '<del>$1</del>').replace(/(?: {2,}|\\)\n/g, '<br>\n');
|
|
115
|
+
return text.replace(/\u0000(\d+)\u0000/g, (_, index) => codes[Number(index)]);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Renders Markdown (paragraphs, headings, lists, quotes, code blocks, emphasis, links). Raw HTML is never passed through:
|
|
119
|
+
* it is shown as text. Links only keep safe URLs.
|
|
120
|
+
*/
|
|
121
|
+
export function markdownToHtml(markdown) {
|
|
122
|
+
const lines = String(markdown ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
123
|
+
const out = [];
|
|
124
|
+
let index = 0;
|
|
125
|
+
const isBlockStart = (line) => /^(#{1,6}\s|>\s?|```|\s*([-*+]|\d{1,9}[.)])\s+|\s*([-*_])(\s*\3){2,}\s*$)/.test(line);
|
|
126
|
+
while (index < lines.length) {
|
|
127
|
+
const line = lines[index];
|
|
128
|
+
if (line.trim() === '') {
|
|
129
|
+
index++;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (line.startsWith('```')) {
|
|
133
|
+
const code = [];
|
|
134
|
+
index++;
|
|
135
|
+
while (index < lines.length && !lines[index].startsWith('```'))
|
|
136
|
+
code.push(lines[index++]);
|
|
137
|
+
index++;
|
|
138
|
+
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
142
|
+
if (heading) {
|
|
143
|
+
out.push(`<h${heading[1].length}>${inlineMarkdown(heading[2])}</h${heading[1].length}>`);
|
|
144
|
+
index++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
|
148
|
+
out.push('<hr>');
|
|
149
|
+
index++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (/^>\s?/.test(line)) {
|
|
153
|
+
const quote = [];
|
|
154
|
+
while (index < lines.length && /^>\s?/.test(lines[index]))
|
|
155
|
+
quote.push(lines[index++].replace(/^>\s?/, ''));
|
|
156
|
+
out.push(`<blockquote>${markdownToHtml(quote.join('\n'))}</blockquote>`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const listItem = /^\s*([-*+]|(\d{1,9})[.)])\s+(.*)$/.exec(line);
|
|
160
|
+
if (listItem) {
|
|
161
|
+
const ordered = listItem[2] !== undefined;
|
|
162
|
+
const items = [];
|
|
163
|
+
while (index < lines.length) {
|
|
164
|
+
const match = /^\s*([-*+]|(\d{1,9})[.)])\s+(.*)$/.exec(lines[index]);
|
|
165
|
+
if (!match || (match[2] !== undefined) !== ordered)
|
|
166
|
+
break;
|
|
167
|
+
items.push(`<li>${inlineMarkdown(match[3])}</li>`);
|
|
168
|
+
index++;
|
|
169
|
+
}
|
|
170
|
+
const start = ordered && listItem[2] !== '1' ? ` start="${Number(listItem[2])}"` : '';
|
|
171
|
+
out.push(ordered ? `<ol${start}>${items.join('')}</ol>` : `<ul>${items.join('')}</ul>`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const paragraph = [];
|
|
175
|
+
while (index < lines.length && lines[index].trim() !== '' && (paragraph.length === 0 || !isBlockStart(lines[index])))
|
|
176
|
+
paragraph.push(lines[index++]);
|
|
177
|
+
out.push(`<p>${inlineMarkdown(paragraph.join('\n'))}</p>`);
|
|
178
|
+
}
|
|
179
|
+
return out.join('');
|
|
180
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -119,18 +119,42 @@ export declare function icon(name: string, options?: {
|
|
|
119
119
|
export declare function registerIcon(name: string, svg: string): void;
|
|
120
120
|
/** Loading spinner SVG. */
|
|
121
121
|
export declare function loadingIndicator(attributes?: Attributes, size?: string): string;
|
|
122
|
-
/**
|
|
122
|
+
/** Renders a component from its props (camelCased, named slots included), its attribute bag and its default slot (HTML). */
|
|
123
|
+
export type ComponentRenderer = (props: Record<string, any> & {
|
|
124
|
+
attributes: Record<string, any>;
|
|
125
|
+
}, slot: string) => string;
|
|
126
|
+
/**
|
|
127
|
+
* View registry: `filament-forms::components.text-input` resolves to an EJS file; override directories are checked first.
|
|
128
|
+
* Views may use component tags (`<x-section heading="Orders">…</x-section>`): built-in components, components registered with
|
|
129
|
+
* `component()`, and EJS files of the directories given to `components()`.
|
|
130
|
+
*/
|
|
123
131
|
export declare class ViewRegistry {
|
|
124
132
|
private namespaces;
|
|
125
133
|
private overrides;
|
|
126
134
|
private compiled;
|
|
127
135
|
private shared;
|
|
136
|
+
private componentRenderers;
|
|
137
|
+
private componentDirectories;
|
|
138
|
+
/** Registers a component rendered by a function: `<x-name>` in any view. Registering an existing name replaces it. */
|
|
139
|
+
component(name: string, renderer: ComponentRenderer): this;
|
|
140
|
+
/**
|
|
141
|
+
* Directory of EJS components: `<x-card>` renders `<dir>/card.ejs`, `<x-forms.field>` renders `<dir>/forms/field.ejs`.
|
|
142
|
+
* The view receives its props, `slot` (the content, already rendered), `slots` (named slots) and `attributes` (HTML attributes).
|
|
143
|
+
* Directories registered later win, and files win over components registered with `component()`.
|
|
144
|
+
*/
|
|
145
|
+
components(directory: string | URL): this;
|
|
146
|
+
hasComponent(name: string): boolean;
|
|
147
|
+
private componentFile;
|
|
148
|
+
/** Renders a component tag. HTML, Alpine and Livewire attributes go to the attribute bag; the others become camelCased props. */
|
|
149
|
+
renderComponent(name: string, raw: Record<string, unknown>, slot?: string, slots?: Record<string, string>): string;
|
|
128
150
|
namespace(name: string, dir: string): this;
|
|
129
151
|
/** Application override directory: `<dir>/<namespace>/<path>.ejs` replaces the package view. */
|
|
130
152
|
overrideFrom(dir: string): this;
|
|
131
153
|
share(data: Record<string, unknown>): this;
|
|
132
154
|
resolve(view: string): string;
|
|
133
155
|
render(view: string, data?: Record<string, unknown>): string;
|
|
156
|
+
/** Renders an EJS file directly (component files, page views given by path). */
|
|
157
|
+
renderFile(file: string, data?: Record<string, unknown>, cacheKey?: string): string;
|
|
134
158
|
}
|
|
135
159
|
export declare const views: ViewRegistry;
|
|
136
160
|
/** Registers a callback applied to every instance of the class (and its subclasses) created afterwards by `make()`. */
|
|
@@ -162,4 +186,4 @@ export declare function runWithRenderHooks<T>(registry: RenderHookRegistry, fn:
|
|
|
162
186
|
/** Renders a slot: process-wide hooks, then the current panel's hooks. */
|
|
163
187
|
export declare function renderHook(name: string, scopes?: unknown | unknown[] | null, data?: Record<string, any>): string;
|
|
164
188
|
export declare function hasRenderHook(name: string, scopes?: unknown | unknown[] | null): boolean;
|
|
165
|
-
export {};
|
|
189
|
+
export { isSafeUrl, markdownToHtml, sanitizeHtml } from './html.ts';
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { camel, compileComponentTags, isHtmlAttribute } from "./components.js";
|
|
2
3
|
// @navette-js/support — shared building blocks: escaping, attribute bags, JS serialization, lazy evaluation, colors, icons,
|
|
3
4
|
// namespaced EJS views.
|
|
4
5
|
import ejs from 'ejs';
|
|
@@ -391,7 +392,8 @@ const iconCache = new Map();
|
|
|
391
392
|
export function icon(name, options = {}) {
|
|
392
393
|
let svg = iconCache.get(name);
|
|
393
394
|
if (svg === undefined) {
|
|
394
|
-
|
|
395
|
+
// `fi-o-crop`: Filament's own icons (MIT), shipped next to Heroicons.
|
|
396
|
+
const file = name.startsWith('fi-') ? here(`../icons/filament/${name.slice(3).replace(/[^a-z0-9-]/g, '')}.svg`) : here(`../icons/heroicons/${name.replace(/^heroicon-/, '').replace(/[^a-z0-9-]/g, '')}.svg`);
|
|
395
397
|
if (!existsSync(file))
|
|
396
398
|
throw new Error(`Icon not found: ${name}`);
|
|
397
399
|
svg = readFileSync(file, 'utf8').trim();
|
|
@@ -411,13 +413,68 @@ export function loadingIndicator(attributes = new Attributes(), size = 'md') {
|
|
|
411
413
|
const a = attributes.class(['fi-icon fi-loading-indicator', `fi-size-${size}`]);
|
|
412
414
|
return `<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" ${a.toHtml()}><path clip-rule="evenodd" d="M12 19C15.866 19 19 15.866 19 12C19 8.13401 15.866 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19ZM12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" fill-rule="evenodd" fill="currentColor" opacity="0.2"></path><path d="M2 12C2 6.47715 6.47715 2 12 2V5C8.13401 5 5 8.13401 5 12H2Z" fill="currentColor"></path></svg>`;
|
|
413
415
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
/**
|
|
417
|
+
* View registry: `filament-forms::components.text-input` resolves to an EJS file; override directories are checked first.
|
|
418
|
+
* Views may use component tags (`<x-section heading="Orders">…</x-section>`): built-in components, components registered with
|
|
419
|
+
* `component()`, and EJS files of the directories given to `components()`.
|
|
420
|
+
*/
|
|
416
421
|
export class ViewRegistry {
|
|
417
422
|
namespaces = new Map();
|
|
418
423
|
overrides = [];
|
|
419
424
|
compiled = new Map();
|
|
420
425
|
shared = {};
|
|
426
|
+
componentRenderers = new Map();
|
|
427
|
+
componentDirectories = [];
|
|
428
|
+
/** Registers a component rendered by a function: `<x-name>` in any view. Registering an existing name replaces it. */
|
|
429
|
+
component(name, renderer) {
|
|
430
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name))
|
|
431
|
+
throw new Error(`Invalid component name: ${name}`);
|
|
432
|
+
this.componentRenderers.set(name, renderer);
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Directory of EJS components: `<x-card>` renders `<dir>/card.ejs`, `<x-forms.field>` renders `<dir>/forms/field.ejs`.
|
|
437
|
+
* The view receives its props, `slot` (the content, already rendered), `slots` (named slots) and `attributes` (HTML attributes).
|
|
438
|
+
* Directories registered later win, and files win over components registered with `component()`.
|
|
439
|
+
*/
|
|
440
|
+
components(directory) {
|
|
441
|
+
this.componentDirectories.unshift(directory instanceof URL ? fileURLToPath(directory) : directory);
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
hasComponent(name) { return this.componentRenderers.has(name) || this.componentFile(name) !== null; }
|
|
445
|
+
componentFile(name) {
|
|
446
|
+
if (!/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/.test(name))
|
|
447
|
+
return null;
|
|
448
|
+
for (const dir of this.componentDirectories) {
|
|
449
|
+
const file = path.join(dir, ...name.split('.')) + '.ejs';
|
|
450
|
+
if (existsSync(file))
|
|
451
|
+
return file;
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
/** Renders a component tag. HTML, Alpine and Livewire attributes go to the attribute bag; the others become camelCased props. */
|
|
456
|
+
renderComponent(name, raw, slot = '', slots = {}) {
|
|
457
|
+
const props = {};
|
|
458
|
+
const attributes = {};
|
|
459
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
460
|
+
if (isHtmlAttribute(key))
|
|
461
|
+
attributes[key] = value;
|
|
462
|
+
else
|
|
463
|
+
props[camel(key)] = value;
|
|
464
|
+
}
|
|
465
|
+
// A slot made of whitespace only (line breaks around named slots) counts as empty.
|
|
466
|
+
for (const [key, value] of Object.entries(slots))
|
|
467
|
+
props[camel(key)] = value.trim() === '' ? '' : value;
|
|
468
|
+
if (slot.trim() === '')
|
|
469
|
+
slot = '';
|
|
470
|
+
const file = this.componentFile(name);
|
|
471
|
+
if (file)
|
|
472
|
+
return this.renderFile(file, { ...props, slot, slots, attributes: new Attributes(attributes) });
|
|
473
|
+
const renderer = this.componentRenderers.get(name);
|
|
474
|
+
if (!renderer)
|
|
475
|
+
throw new Error(`Unknown component <x-${name}>: register it with views.component() or views.components(directory).`);
|
|
476
|
+
return renderer({ ...props, attributes }, slot);
|
|
477
|
+
}
|
|
421
478
|
namespace(name, dir) { this.namespaces.set(name, dir); return this; }
|
|
422
479
|
/** Application override directory: `<dir>/<namespace>/<path>.ejs` replaces the package view. */
|
|
423
480
|
overrideFrom(dir) { this.overrides.unshift(dir); this.compiled.clear(); return this; }
|
|
@@ -435,14 +492,15 @@ export class ViewRegistry {
|
|
|
435
492
|
throw new Error(`Unknown view namespace: ${ns}`);
|
|
436
493
|
return path.join(base, rel);
|
|
437
494
|
}
|
|
438
|
-
render(view, data = {}) {
|
|
439
|
-
|
|
495
|
+
render(view, data = {}) { return this.renderFile(this.resolve(view), data, view); }
|
|
496
|
+
/** Renders an EJS file directly (component files, page views given by path). */
|
|
497
|
+
renderFile(file, data = {}, cacheKey = file) {
|
|
498
|
+
let fn = this.compiled.get(cacheKey);
|
|
440
499
|
if (!fn) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
this.compiled.set(view, fn);
|
|
500
|
+
fn = ejs.compile(compileComponentTags(readFileSync(file, 'utf8'), file), { filename: file });
|
|
501
|
+
this.compiled.set(cacheKey, fn);
|
|
444
502
|
}
|
|
445
|
-
return fn({ e, js, cls, attrs, icon, loadingIndicator, view: (v, d) => this.render(v, d), ...this.shared, ...data });
|
|
503
|
+
return fn({ e, js, cls, attrs, icon, loadingIndicator, view: (v, d) => this.render(v, d), __component: (name, props, slot, slots) => this.renderComponent(name, props, slot, slots), ...this.shared, ...data });
|
|
446
504
|
}
|
|
447
505
|
}
|
|
448
506
|
export const views = new ViewRegistry();
|
|
@@ -512,3 +570,4 @@ export function hasRenderHook(name, scopes = null) {
|
|
|
512
570
|
return activeRegistries().some((registry) => registry.has(name, scopes));
|
|
513
571
|
}
|
|
514
572
|
views.share({ renderHook, hasRenderHook });
|
|
573
|
+
export { isSafeUrl, markdownToHtml, sanitizeHtml } from "./html.js";
|
package/lib/ui.d.ts
CHANGED
|
@@ -196,6 +196,8 @@ export type DropdownItem = {
|
|
|
196
196
|
href?: string | null;
|
|
197
197
|
disabled?: boolean;
|
|
198
198
|
attributes?: Attrs;
|
|
199
|
+
/** Label as HTML already escaped (the content of a `<x-dropdown-item>` tag), used instead of `label`. */
|
|
200
|
+
labelHtml?: string | null;
|
|
199
201
|
};
|
|
200
202
|
/** Dropdown menu item. */
|
|
201
203
|
export declare function dropdownItem(item: DropdownItem): string;
|
package/lib/ui.js
CHANGED
|
@@ -211,7 +211,7 @@ export function dropdownItem(item) {
|
|
|
211
211
|
const iconColor = item.iconColor ?? (color !== 'gray' ? color : null);
|
|
212
212
|
const lead = item.icon ? icon(item.icon, { attributes: new Attributes().class(colorMark(iconColor)) }) : item.image ? `<div class="fi-dropdown-list-item-image" style="background-image: url('${e(item.image)}')"></div>` : '';
|
|
213
213
|
const badgeHtml = item.badge !== null && item.badge !== undefined ? `<span class="${cls([...colorsOf('badge', item.badgeColor ?? 'primary'), 'fi-badge'])}">${e(String(item.badge))}</span>` : '';
|
|
214
|
-
return `<${tag} ${attributes.toHtml()}>${lead}<span class="fi-dropdown-list-item-label">${e(item.label)}</span>${badgeHtml}</${tag}>`;
|
|
214
|
+
return `<${tag} ${attributes.toHtml()}>${lead}<span class="fi-dropdown-list-item-label">${item.labelHtml ?? e(item.label)}</span>${badgeHtml}</${tag}>`;
|
|
215
215
|
}
|
|
216
216
|
/** List of dropdown menu items. */
|
|
217
217
|
export function dropdownList(items) { return `<div class="fi-dropdown-list">${items.map(dropdownItem).join('')}</div>`; }
|
|
@@ -250,5 +250,36 @@ export function modal(props, slot = '') {
|
|
|
250
250
|
.class(['fi-modal', { 'fi-absolute-positioning-context': !props.slideOver, 'fi-modal-slide-over': props.slideOver === true, 'fi-modal-has-sticky-header': props.stickyHeader === true, 'fi-modal-has-sticky-footer': props.stickyFooter === true }]);
|
|
251
251
|
return `<div ${outer.toHtml()}><div aria-hidden="true" x-show="isOpen" x-transition.duration.300ms.opacity class="fi-modal-close-overlay"></div><div${trapOnWindow ? '' : ' tabindex="-1"'}${closeByClickingAway ? ` x-on:click.self="${e(close)}"` : ''}${trapOnWindow ? '' : ' x-trap.noreturn="isTrapActive"'} class="${cls(['fi-modal-window-ctn', { 'fi-clickable': closeByClickingAway }])}"><div${closeByEscaping ? ` x-on:keydown.window.escape="if (isTopmost()) ${e(close)}"` : ''} x-show="isWindowVisible" x-transition:enter="fi-transition-enter" x-transition:leave="fi-transition-leave" x-transition:enter-start="fi-transition-enter-start" x-transition:enter-end="fi-transition-enter-end" x-transition:leave-start="fi-transition-leave-start" x-transition:leave-end="fi-transition-leave-end" wire:key="${e(key('window'))}"${trapOnWindow ? ' x-trap.noreturn="isTrapActive" tabindex="0"' : ''} class="${windowClasses}">${header}${content}${footer}</div></div></div>`;
|
|
252
252
|
}
|
|
253
|
+
// ------------------------------------------------------------------ component tags
|
|
254
|
+
// Built-in components for EJS views: `<x-button color="danger">Delete</x-button>`, `<x-section heading="Orders">…</x-section>`.
|
|
255
|
+
// The default slot is HTML already rendered by the template; props from `name="text"` are plain strings and are escaped by the components.
|
|
256
|
+
const withSlot = (render) => (props, slot) => render(props, slot);
|
|
257
|
+
views
|
|
258
|
+
.component('avatar', (props) => avatar(props))
|
|
259
|
+
// A badge rendered as a link takes its `href` from the tag.
|
|
260
|
+
.component('badge', (props, slot) => badge({ ...props, attributes: { ...props.attributes, href: props.href ?? props.attributes?.href } }, slot))
|
|
261
|
+
.component('breadcrumbs', (props) => breadcrumbs(props.items ?? [], props.label))
|
|
262
|
+
.component('button', withSlot(button))
|
|
263
|
+
.component('callout', (props, slot) => callout({ ...props, footer: props.footer ?? (slot || null) }))
|
|
264
|
+
// Every prop other than `checked` and `valid` is an attribute of the input (`name`, `value`, `disabled`…).
|
|
265
|
+
.component('checkbox', ({ checked, valid, attributes, ...rest }) => checkbox({ checked, valid, attributes: { ...rest, ...attributes } }))
|
|
266
|
+
.component('dropdown', withSlot(dropdown))
|
|
267
|
+
.component('dropdown-list', (props, slot) => (props.items ? dropdownList(props.items) : `<div class="fi-dropdown-list">${slot}</div>`))
|
|
268
|
+
.component('dropdown-item', (props, slot) => dropdownItem({ ...props, label: props.label ?? '', labelHtml: props.label === undefined ? slot.trim() : null }))
|
|
269
|
+
.component('empty-state', (props, slot) => emptyState({ ...props, footer: props.footer ?? (slot || null) }))
|
|
270
|
+
.component('fieldset', withSlot(fieldset))
|
|
271
|
+
.component('icon', (props) => icon(props.name ?? props.icon, { size: props.size, attributes: new Attributes(props.attributes) }))
|
|
272
|
+
.component('icon-button', (props) => iconButton(props))
|
|
273
|
+
.component('input', (props) => input({ ...props.attributes, ...Object.fromEntries(Object.entries(props).filter(([key]) => key !== 'attributes')) }))
|
|
274
|
+
.component('input-wrapper', withSlot(inputWrapper))
|
|
275
|
+
.component('link', withSlot(link))
|
|
276
|
+
.component('loading-indicator', (props) => loadingIndicator(new Attributes(props.attributes), props.size))
|
|
277
|
+
// `id` is an HTML attribute name, so it lands in the attribute bag: these two components need it as a prop.
|
|
278
|
+
.component('modal', (props, slot) => modal({ ...props, id: props.id ?? props.attributes?.id }, slot))
|
|
279
|
+
.component('pagination', (props) => pagination({ ...props, id: props.id ?? props.attributes?.id }))
|
|
280
|
+
.component('section', withSlot(section))
|
|
281
|
+
.component('select', (props, slot) => select({ ...props.attributes, ...Object.fromEntries(Object.entries(props).filter(([key]) => key !== 'attributes')) }, slot))
|
|
282
|
+
.component('spinner', (props) => spinner(props.attributes))
|
|
283
|
+
.component('tabs', (props) => tabs(props, props.items ?? []));
|
|
253
284
|
export const ui = { avatar, badge, breadcrumbs, button, callout, checkbox, dropdown, dropdownItem, dropdownList, emptyState, fieldset, iconButton, input, inputWrapper, link, modal, pagination, section, select, spinner, tabs };
|
|
254
285
|
export { cls, e, js, loadingIndicator };
|