@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.
Files changed (53) hide show
  1. package/THIRD-PARTY-NOTICES.md +2 -1
  2. package/data/lang.en-fr.json +184 -10
  3. package/icons/filament/LICENSE.md +21 -0
  4. package/icons/filament/m-pencil-slash.svg +4 -0
  5. package/icons/filament/o-align-center.svg +5 -0
  6. package/icons/filament/o-align-end.svg +5 -0
  7. package/icons/filament/o-align-justify.svg +5 -0
  8. package/icons/filament/o-align-start.svg +5 -0
  9. package/icons/filament/o-arrows-move.svg +11 -0
  10. package/icons/filament/o-clear-formatting.svg +6 -0
  11. package/icons/filament/o-code-block.svg +5 -0
  12. package/icons/filament/o-code.svg +5 -0
  13. package/icons/filament/o-columns-delete.svg +11 -0
  14. package/icons/filament/o-columns.svg +10 -0
  15. package/icons/filament/o-crop.svg +5 -0
  16. package/icons/filament/o-details.svg +5 -0
  17. package/icons/filament/o-flip-horizontal.svg +3 -0
  18. package/icons/filament/o-flip-vertical.svg +3 -0
  19. package/icons/filament/o-h1.svg +11 -0
  20. package/icons/filament/o-h2.svg +11 -0
  21. package/icons/filament/o-h3.svg +12 -0
  22. package/icons/filament/o-h4.svg +11 -0
  23. package/icons/filament/o-h5.svg +11 -0
  24. package/icons/filament/o-h6.svg +12 -0
  25. package/icons/filament/o-heading.svg +9 -0
  26. package/icons/filament/o-highlight.svg +6 -0
  27. package/icons/filament/o-lead.svg +6 -0
  28. package/icons/filament/o-merge-tag.svg +7 -0
  29. package/icons/filament/o-paragraph.svg +5 -0
  30. package/icons/filament/o-small.svg +5 -0
  31. package/icons/filament/o-subscript.svg +5 -0
  32. package/icons/filament/o-superscript.svg +5 -0
  33. package/icons/filament/o-table-add-column-after.svg +5 -0
  34. package/icons/filament/o-table-add-column-before.svg +5 -0
  35. package/icons/filament/o-table-add-row-after.svg +5 -0
  36. package/icons/filament/o-table-add-row-before.svg +5 -0
  37. package/icons/filament/o-table-delete-column.svg +5 -0
  38. package/icons/filament/o-table-delete-row.svg +5 -0
  39. package/icons/filament/o-table-delete.svg +6 -0
  40. package/icons/filament/o-table-merge-cells.svg +7 -0
  41. package/icons/filament/o-table-split-cell.svg +6 -0
  42. package/icons/filament/o-table-toggle-header-cell.svg +4 -0
  43. package/icons/filament/o-table-toggle-header-row.svg +7 -0
  44. package/icons/filament/o-table.svg +5 -0
  45. package/lib/components.d.ts +6 -0
  46. package/lib/components.js +130 -0
  47. package/lib/html.d.ts +12 -0
  48. package/lib/html.js +180 -0
  49. package/lib/index.d.ts +26 -2
  50. package/lib/index.js +68 -9
  51. package/lib/ui.d.ts +2 -0
  52. package/lib/ui.js +32 -1
  53. 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(/&colon;/gi, ':').replace(/&tab;/gi, '\t').replace(/&newline;/gi, '\n').replace(/&amp;/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('&lt;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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+&quot;([^&]*)&quot;)?\)/g, (_, label, href, title) => {
105
+ const url = href.replace(/&amp;/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?:\/\/(?:(?!&quot;|&lt;|&gt;)[^\s<])*(?:(?!&quot;|&lt;|&gt;)[^\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
- /** View registry: `filament-forms::components.text-input` resolves to an EJS file; override directories are checked first. */
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
- const file = here(`../icons/heroicons/${name.replace(/^heroicon-/, '')}.svg`);
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
- // ------------------------------------------------------------------ namespaced, overridable EJS views
415
- /** View registry: `filament-forms::components.text-input` resolves to an EJS file; override directories are checked first. */
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
- let fn = this.compiled.get(view);
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
- const file = this.resolve(view);
442
- fn = ejs.compile(readFileSync(file, 'utf8'), { filename: file });
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navette-js/support",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Shared utilities, colors, icons, views and translations for Navette.",
5
5
  "license": "MIT",
6
6
  "type": "module",