@raylang/elements 0.5.1 → 0.6.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/check/check.d.ts +45 -0
- package/check/check.js +419 -0
- package/check/cli.js +145 -0
- package/components/button/button.js +17 -0
- package/components/number-input/number-input.js +54 -8
- package/components/radio/radio.js +86 -6
- package/components/ring-progress/ring-progress.js +127 -0
- package/components/step-dots/step-dots.js +86 -0
- package/custom-elements.json +1457 -144
- package/index.js +3 -1
- package/llms-full.txt +97 -14
- package/llms.txt +15 -3
- package/package.json +16 -1
- package/ray-elements.d.ts +57 -9
- package/vscode.html-custom-data.json +612 -62
package/check/check.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Types of @raylang/elements/check: the markup checker behind `ray-elements check`.
|
|
2
|
+
|
|
3
|
+
export interface Diagnostic {
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
severity: "error" | "warning";
|
|
8
|
+
rule: keyof typeof RULES;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CheckOptions {
|
|
13
|
+
/** custom-elements.json, parsed (several: yours too). */
|
|
14
|
+
manifest: object | object[];
|
|
15
|
+
/** The registered icon names; without them icons are not checked. */
|
|
16
|
+
icons?: string[];
|
|
17
|
+
/** More values of an attribute, for every element: an app's own variant, { variant: ["brand"] }. */
|
|
18
|
+
allow?: Record<string, string[]>;
|
|
19
|
+
/** Reported in the diagnostics; its extension picks the language. */
|
|
20
|
+
file?: string;
|
|
21
|
+
lang?: "html" | "vue" | "jsx";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The rules and what each reports. */
|
|
25
|
+
export declare const RULES: {
|
|
26
|
+
"unknown-element": string;
|
|
27
|
+
"unknown-attribute": string;
|
|
28
|
+
"property-only": string;
|
|
29
|
+
"invalid-value": string;
|
|
30
|
+
"boolean-value": string;
|
|
31
|
+
"unknown-slot": string;
|
|
32
|
+
"unknown-event": string;
|
|
33
|
+
"unknown-icon": string;
|
|
34
|
+
"missing-label": string;
|
|
35
|
+
"literal-color": string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Checks one file's markup against the manifest. */
|
|
39
|
+
export declare function checkMarkup(source: string, options: CheckOptions): Diagnostic[];
|
|
40
|
+
|
|
41
|
+
/** The values an app adds to attributes in its CSS, from selectors such as [variant="brand"]. */
|
|
42
|
+
export declare function customValues(source: string): Record<string, string[]>;
|
|
43
|
+
|
|
44
|
+
/** The language of a file by its extension. */
|
|
45
|
+
export declare function languageOf(file: string): "html" | "vue" | "jsx";
|
package/check/check.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// Checks markup that uses ray-elements against custom-elements.json: unknown
|
|
2
|
+
// elements, attributes, values, slots and events, icon names, icon buttons
|
|
3
|
+
// without a label, literal colors. No dependencies and no DOM, so it runs in
|
|
4
|
+
// Node (the `ray-elements check` command), in a browser or in an editor.
|
|
5
|
+
//
|
|
6
|
+
// It reads HTML, and the templates of Vue, Svelte and Astro files and of JSX
|
|
7
|
+
// (`<ray-button>` and the wrappers, `<RayButton>`). It is a scanner, not a
|
|
8
|
+
// parser of each language: dynamic values (`:size="s"`, `size={s}`) are not
|
|
9
|
+
// checked, only their names.
|
|
10
|
+
|
|
11
|
+
/** @typedef {{ file: string, line: number, column: number, severity: "error" | "warning", rule: string, message: string }} Diagnostic */
|
|
12
|
+
|
|
13
|
+
export const RULES = {
|
|
14
|
+
"unknown-element": "a <ray-*> element (or a Ray* wrapper) that does not exist",
|
|
15
|
+
"unknown-attribute": "an attribute the element does not have",
|
|
16
|
+
"property-only": "an attribute for a property that has none (set it from JS)",
|
|
17
|
+
"invalid-value": "a value outside the attribute's list",
|
|
18
|
+
"boolean-value": "a boolean attribute written as \"false\" (present means true)",
|
|
19
|
+
"unknown-slot": "a slot name the parent element does not have",
|
|
20
|
+
"unknown-event": "a ray-* event the element does not fire",
|
|
21
|
+
"unknown-icon": "an icon name that is not registered",
|
|
22
|
+
"missing-label": "an icon-only button without its accessible name",
|
|
23
|
+
"literal-color": "a literal color in CSS instead of a token",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const GLOBAL = new Set([
|
|
27
|
+
"accesskey", "autocapitalize", "autofocus", "class", "contenteditable", "dir", "draggable",
|
|
28
|
+
"enterkeyhint", "exportparts", "hidden", "id", "inert", "inputmode", "is", "itemid", "itemprop",
|
|
29
|
+
"itemref", "itemscope", "itemtype", "lang", "nonce", "part", "popover", "role", "slot",
|
|
30
|
+
"spellcheck", "style", "tabindex", "title", "translate", "writingsuggestions", "xmlns",
|
|
31
|
+
// ray-elements: closes the dialog it is in, with its value as returnValue
|
|
32
|
+
"data-dialog-close",
|
|
33
|
+
]);
|
|
34
|
+
// Framework attributes (React, Vue, Svelte) that never reach the element.
|
|
35
|
+
const FRAMEWORK = new Set(["key", "ref", "classname", "htmlfor", "dangerouslysetinnerhtml", "suppresshydrationwarning", "children"]);
|
|
36
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
37
|
+
const RAW = new Set(["script", "style", "textarea", "title"]);
|
|
38
|
+
const COLOR = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(/g;
|
|
39
|
+
|
|
40
|
+
const indexes = new WeakMap();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The elements of one or more manifests by tag, with what they inherit.
|
|
44
|
+
* @param {object | object[]} manifests custom-elements.json, parsed
|
|
45
|
+
*/
|
|
46
|
+
function indexManifest(manifests) {
|
|
47
|
+
const list = Array.isArray(manifests) ? manifests : [manifests];
|
|
48
|
+
const key = list[0];
|
|
49
|
+
if (list.length === 1 && indexes.has(key)) return indexes.get(key);
|
|
50
|
+
const byClass = new Map();
|
|
51
|
+
for (const m of list) {
|
|
52
|
+
for (const mod of m.modules ?? []) {
|
|
53
|
+
for (const d of mod.declarations ?? []) {
|
|
54
|
+
if (d.kind === "class") byClass.set(d.name, d);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const tags = new Map();
|
|
59
|
+
const classes = new Map();
|
|
60
|
+
for (const d of byClass.values()) {
|
|
61
|
+
if (!d.tagName) continue;
|
|
62
|
+
const el = { tag: d.tagName, className: d.name, attrs: new Map(), fields: new Set(), events: new Set(), slots: new Set() };
|
|
63
|
+
for (let c = d, depth = 0; c && depth < 8; c = byClass.get(c.superclass?.name), depth++) {
|
|
64
|
+
for (const a of c.attributes ?? []) if (!el.attrs.has(a.name)) el.attrs.set(a.name, attribute(a));
|
|
65
|
+
for (const m of c.members ?? []) if (m.kind === "field") el.fields.add(kebab(m.name));
|
|
66
|
+
for (const e of c.events ?? []) el.events.add(e.name);
|
|
67
|
+
for (const s of c.slots ?? []) el.slots.add(s.name ?? "");
|
|
68
|
+
}
|
|
69
|
+
tags.set(el.tag, el);
|
|
70
|
+
classes.set(el.className, el);
|
|
71
|
+
}
|
|
72
|
+
const index = { tags, classes };
|
|
73
|
+
if (list.length === 1) indexes.set(key, index);
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The literals of a union are its values. `(string & {})` beside them (keeps
|
|
78
|
+
// the literals in autocompletion) is the system's "or any CSS length" (size
|
|
79
|
+
// of buttons, dialogs, drawers).
|
|
80
|
+
function attribute(a) {
|
|
81
|
+
const text = a.type?.text ?? "string";
|
|
82
|
+
const parts = text.split("|").map((s) => s.trim());
|
|
83
|
+
const literals = parts.filter((s) => /^"[^"]*"$/.test(s)).map((s) => s.slice(1, -1));
|
|
84
|
+
const others = parts.filter((s) => !/^"[^"]*"$/.test(s) && s !== "undefined" && s !== "null");
|
|
85
|
+
const length = others.length === 1 && others[0] === "(string & {})";
|
|
86
|
+
return { name: a.name, boolean: text === "boolean", values: literals.length > 0 && (others.length === 0 || length) ? literals : null, length };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const LENGTH = /^(?:-?(?:\d+\.?\d*|\.\d+)(?:px|r?em|ex|ch|lh|vw|vh|vmin|vmax|dvh|svh|lvh|cm|mm|in|pt|pc|q|%)|0|(?:calc|min|max|clamp|var)\(.*\))$/i;
|
|
90
|
+
|
|
91
|
+
/** The language of a file by its extension: "html" (also Vue, Svelte, Astro) or "jsx". */
|
|
92
|
+
export function languageOf(file) {
|
|
93
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
94
|
+
if (["jsx", "tsx", "js", "ts", "mjs", "mts"].includes(ext)) return "jsx";
|
|
95
|
+
if (ext === "vue") return "vue";
|
|
96
|
+
return "html";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Checks one file's markup.
|
|
101
|
+
* @param {string} source
|
|
102
|
+
* @param {{ manifest: object | object[], icons?: string[], allow?: Record<string, string[]>, file?: string, lang?: "html" | "vue" | "jsx" }} options
|
|
103
|
+
* allow: more values of an attribute, for every element (an app's own variant: { variant: ["brand"] })
|
|
104
|
+
* @returns {Diagnostic[]}
|
|
105
|
+
*/
|
|
106
|
+
export function checkMarkup(source, { manifest, icons, allow = {}, file = "<input>", lang } = {}) {
|
|
107
|
+
const index = indexManifest(manifest);
|
|
108
|
+
const language = lang ?? languageOf(file);
|
|
109
|
+
const iconSet = icons ? new Set(icons) : null;
|
|
110
|
+
const lines = lineStarts(source);
|
|
111
|
+
const out = [];
|
|
112
|
+
const report = (pos, severity, rule, message) => {
|
|
113
|
+
const { line, column } = locate(lines, pos);
|
|
114
|
+
out.push({ file, line, column, severity, rule, message });
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const stack = [];
|
|
118
|
+
for (const tok of scan(source, language)) {
|
|
119
|
+
if (tok.type === "close") {
|
|
120
|
+
const i = stack.findLastIndex((t) => t.name === tok.name);
|
|
121
|
+
if (i >= 0) stack.length = i;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (tok.type === "css") {
|
|
125
|
+
colors(tok.text, tok.pos, report);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const parent = stack.at(-1);
|
|
129
|
+
const el = resolve(index, tok.name, language);
|
|
130
|
+
if (el === undefined && isRayName(tok.name, language)) {
|
|
131
|
+
const known = /^ray-/.test(tok.name) ? [...index.tags.keys()] : [...index.classes.keys()];
|
|
132
|
+
report(tok.pos, "error", "unknown-element", `<${tok.name}> is not a ray-elements element${hint(tok.name, known)}`);
|
|
133
|
+
}
|
|
134
|
+
for (const a of tok.attrs) {
|
|
135
|
+
attributeChecks(a, tok, el, parent, language, iconSet, allow, report);
|
|
136
|
+
}
|
|
137
|
+
if (el?.tag === "ray-icon-button") {
|
|
138
|
+
const named = tok.attrs.some((a) => ["label", "aria-label", "aria-labelledby"].includes(normalize(a.name, language).name));
|
|
139
|
+
if (!named) report(tok.pos, "error", "missing-label", `<${tok.name}> needs a label: it is the button's accessible name (and its tooltip)`);
|
|
140
|
+
}
|
|
141
|
+
if (!tok.selfClosing && !(language !== "jsx" && VOID.has(tok.name.toLowerCase()))) {
|
|
142
|
+
stack.push({ name: tok.name, el });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isRayName(name, language) {
|
|
149
|
+
return /^ray-/.test(name) || (language !== "html" && /^Ray[A-Z]/.test(name));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolve(index, name, language) {
|
|
153
|
+
if (/^ray-/.test(name)) return index.tags.get(name);
|
|
154
|
+
if (language !== "html" && /^Ray[A-Z]/.test(name)) return index.classes.get(name);
|
|
155
|
+
return null; // not ours
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// An attribute's name as the element sees it, and how it was written.
|
|
159
|
+
function normalize(raw, language) {
|
|
160
|
+
let name = raw;
|
|
161
|
+
let bound = false;
|
|
162
|
+
let event = null;
|
|
163
|
+
let skip = false;
|
|
164
|
+
if (name.startsWith("@") || name.startsWith("v-on:")) {
|
|
165
|
+
event = name.replace(/^@|^v-on:/, "");
|
|
166
|
+
} else if (name.startsWith("on:")) {
|
|
167
|
+
event = name.slice(3); // Svelte
|
|
168
|
+
} else if (name.startsWith("#") || name.startsWith("v-") || name.startsWith("{")) {
|
|
169
|
+
skip = true; // slots, directives, spreads
|
|
170
|
+
} else if (name.startsWith(":") || name.startsWith("v-bind:") || name.startsWith("bind:")) {
|
|
171
|
+
name = name.replace(/^:|^v-bind:|^bind:/, "");
|
|
172
|
+
bound = true;
|
|
173
|
+
} else if (name.includes(":")) {
|
|
174
|
+
skip = true; // class:, use:, client:, set:… (Svelte, Astro)
|
|
175
|
+
} else if (/^on[A-Z]/.test(name)) {
|
|
176
|
+
event = kebab(name.slice(2)); // React: onRaySelect
|
|
177
|
+
}
|
|
178
|
+
if (event !== null) event = event.split(".")[0];
|
|
179
|
+
name = name.split(".")[0]; // Vue modifiers (.prop, .camel)
|
|
180
|
+
// HTML lowercases attribute names; Vue and React 19 set a camelCase one as the property.
|
|
181
|
+
if (language === "html") name = name.toLowerCase();
|
|
182
|
+
else if (/[A-Z]/.test(name)) name = kebab(name);
|
|
183
|
+
return { name, bound, event, skip };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function attributeChecks(a, tok, el, parent, language, icons, allow, report) {
|
|
187
|
+
const n = normalize(a.name, language);
|
|
188
|
+
if (n.skip) {
|
|
189
|
+
// Vue: <template #left> passes nothing to a wrapper, which forwards only the default slot.
|
|
190
|
+
const slot = a.name.match(/^(?:#|v-slot:)([\w-]+)/);
|
|
191
|
+
if (slot && slot[1] !== "default" && tok.name === "template" && parent?.el) {
|
|
192
|
+
report(a.pos, "error", "unknown-slot", `the Vue wrappers forward only the default slot: put slot="${slot[1]}" on the element instead of <template #${slot[1]}>`);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// slot="…" on a child of one of ours.
|
|
197
|
+
if (n.name === "slot" && !n.bound && a.value !== null && !a.dynamic && parent?.el && parent.el.slots.size > 0) {
|
|
198
|
+
if (!parent.el.slots.has(a.value)) {
|
|
199
|
+
const names = [...parent.el.slots].filter((s) => s !== "");
|
|
200
|
+
report(a.pos, "error", "unknown-slot", `<${parent.name}> has no slot "${a.value}"${hint(a.value, names, "slots")}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!el) return;
|
|
204
|
+
if (n.event !== null) {
|
|
205
|
+
if (n.event.startsWith("ray-") && !el.events.has(n.event)) {
|
|
206
|
+
const own = [...el.events].filter((e) => e.startsWith("ray-"));
|
|
207
|
+
report(a.pos, "error", "unknown-event", `<${tok.name}> does not fire "${n.event}"${hint(n.event, own, "events")}`);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const name = n.name;
|
|
212
|
+
if (GLOBAL.has(name) || FRAMEWORK.has(name.replace(/-/g, "")) || /^(aria|data)-/.test(name) || /^on[a-z]+$/.test(name)) return;
|
|
213
|
+
if (name === "model-value" && language !== "html") return;
|
|
214
|
+
const attr = el.attrs.get(name);
|
|
215
|
+
// A wrapper, JSX (React 19) or a bound attribute can set a property too.
|
|
216
|
+
const viaProperty = n.bound || a.dynamic || language === "jsx" || tok.name !== el.tag;
|
|
217
|
+
if (!attr) {
|
|
218
|
+
if (el.fields.has(name)) {
|
|
219
|
+
if (!viaProperty) report(a.pos, "error", "property-only", `${camel(name)} of <${tok.name}> is a property only: set it from JS (el.${camel(name)} = …)`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const known = [...el.attrs.keys(), ...(viaProperty ? el.fields : [])];
|
|
223
|
+
report(a.pos, "error", "unknown-attribute", `<${tok.name}> has no attribute "${name}"${hint(name, known)}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (a.value === null || a.dynamic || n.bound) return;
|
|
227
|
+
if (attr.boolean && a.value === "false") {
|
|
228
|
+
report(a.pos, "error", "boolean-value", `${name}="false" still means true on <${tok.name}>: a boolean attribute is present or absent`);
|
|
229
|
+
} else if (attr.values && !attr.values.includes(a.value) && !allow[name]?.includes(a.value) && !(attr.length && LENGTH.test(a.value.trim()))) {
|
|
230
|
+
const or = attr.length ? " or a CSS length" : "";
|
|
231
|
+
report(a.pos, "error", "invalid-value", `${name}="${a.value}" is not valid on <${tok.name}>: one of ${attr.values.map((v) => `"${v}"`).join(", ")}${or}`);
|
|
232
|
+
}
|
|
233
|
+
// "none" (or nothing) draws no icon: a blank that keeps the alignment.
|
|
234
|
+
if (el.tag === "ray-icon" && name === "name" && icons && a.value !== "" && a.value !== "none" && !icons.has(a.value)) {
|
|
235
|
+
report(a.pos, "warning", "unknown-icon", `no icon named "${a.value}"${hint(a.value, [...icons], "icons")} (register it with setIcons)`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function colors(text, pos, report) {
|
|
240
|
+
for (const m of text.matchAll(COLOR)) {
|
|
241
|
+
report(pos + m.index, "warning", "literal-color", `literal color ${m[0].replace(/\($/, "(…)")}: use a token (var(--ray-foreground), var(--ray-border)…) so it follows the theme`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- the scanner
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The tags of a source, in order: { type: "open", name, attrs, selfClosing, pos },
|
|
249
|
+
* { type: "close", name }, and { type: "css", text, pos } for <style> and style="…".
|
|
250
|
+
*/
|
|
251
|
+
function* scan(src, language = "html") {
|
|
252
|
+
const jsx = language === "jsx";
|
|
253
|
+
let i = 0;
|
|
254
|
+
while (i < src.length) {
|
|
255
|
+
const lt = src.indexOf("<", i);
|
|
256
|
+
if (lt < 0) return;
|
|
257
|
+
if (src.startsWith("<!--", lt)) {
|
|
258
|
+
const end = src.indexOf("-->", lt + 4);
|
|
259
|
+
i = end < 0 ? src.length : end + 3;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (jsx) {
|
|
263
|
+
// After an identifier, `<` is a generic or a comparison (useRef<RayDialog>,
|
|
264
|
+
// a < b) unless the name has a dash (text before a tag: "Press <ray-kbd>").
|
|
265
|
+
let p = lt - 1;
|
|
266
|
+
while (p >= 0 && /\s/.test(src[p])) p--;
|
|
267
|
+
const afterIdentifier = p >= 0 && /[\w$)\]]/.test(src[p]) && !/\breturn$/.test(src.slice(Math.max(0, p - 6), p + 1));
|
|
268
|
+
if (afterIdentifier && !/^<[a-z][\w]*-/.test(src.slice(lt, lt + 100))) {
|
|
269
|
+
i = lt + 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (src[lt + 1] === "/") {
|
|
274
|
+
const m = /^<\/([A-Za-z][\w.:-]*)\s*>/.exec(src.slice(lt, lt + 200));
|
|
275
|
+
if (m) yield { type: "close", name: m[1] };
|
|
276
|
+
i = lt + (m ? m[0].length : 2);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const m = /^<([A-Za-z][\w.:-]*)/.exec(src.slice(lt, lt + 200));
|
|
280
|
+
if (!m || m[1].endsWith("-")) {
|
|
281
|
+
i = lt + 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const name = m[1];
|
|
285
|
+
let j = lt + m[0].length;
|
|
286
|
+
const attrs = [];
|
|
287
|
+
let selfClosing = false;
|
|
288
|
+
while (j < src.length) {
|
|
289
|
+
while (j < src.length && /\s/.test(src[j])) j++;
|
|
290
|
+
if (src[j] === ">") { j++; break; }
|
|
291
|
+
if (src[j] === "/" && src[j + 1] === ">") { selfClosing = true; j += 2; break; }
|
|
292
|
+
if (src[j] === "{") { j = skipBraces(src, j); attrs.push({ name: "{", value: null, dynamic: true, pos: j }); continue; }
|
|
293
|
+
const start = j;
|
|
294
|
+
while (j < src.length && !/[\s=>]/.test(src[j]) && !(src[j] === "/" && src[j + 1] === ">")) j++;
|
|
295
|
+
const attrName = src.slice(start, j);
|
|
296
|
+
if (attrName === "") { j++; continue; }
|
|
297
|
+
let value = null;
|
|
298
|
+
let dynamic = false;
|
|
299
|
+
let valuePos = start;
|
|
300
|
+
let k = j;
|
|
301
|
+
while (k < src.length && /\s/.test(src[k])) k++;
|
|
302
|
+
if (src[k] === "=") {
|
|
303
|
+
k++;
|
|
304
|
+
while (k < src.length && /\s/.test(src[k])) k++;
|
|
305
|
+
valuePos = k;
|
|
306
|
+
if (src[k] === '"' || src[k] === "'") {
|
|
307
|
+
const end = src.indexOf(src[k], k + 1);
|
|
308
|
+
value = src.slice(k + 1, end < 0 ? src.length : end);
|
|
309
|
+
j = end < 0 ? src.length : end + 1;
|
|
310
|
+
valuePos = k + 1;
|
|
311
|
+
} else if (src[k] === "{") {
|
|
312
|
+
j = skipBraces(src, k);
|
|
313
|
+
value = src.slice(k, j);
|
|
314
|
+
dynamic = true;
|
|
315
|
+
} else {
|
|
316
|
+
let e = k;
|
|
317
|
+
while (e < src.length && !/[\s>]/.test(src[e])) e++;
|
|
318
|
+
value = src.slice(k, e);
|
|
319
|
+
j = e;
|
|
320
|
+
}
|
|
321
|
+
if (/\{|\$\{/.test(value)) dynamic = true;
|
|
322
|
+
}
|
|
323
|
+
attrs.push({ name: attrName, value, dynamic, pos: start });
|
|
324
|
+
if (attrName.toLowerCase() === "style" && value !== null && !dynamic) yield { type: "css", text: value, pos: valuePos };
|
|
325
|
+
}
|
|
326
|
+
yield { type: "open", name, attrs, selfClosing, pos: lt };
|
|
327
|
+
const lower = name.toLowerCase();
|
|
328
|
+
if (!jsx && !selfClosing && RAW.has(lower)) {
|
|
329
|
+
const end = src.toLowerCase().indexOf(`</${lower}`, j);
|
|
330
|
+
const stop = end < 0 ? src.length : end;
|
|
331
|
+
if (lower === "style") yield { type: "css", text: src.slice(j, stop), pos: j };
|
|
332
|
+
i = stop;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
i = j;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// From an opening brace to after its match, past strings and template literals.
|
|
340
|
+
function skipBraces(src, i) {
|
|
341
|
+
let depth = 0;
|
|
342
|
+
for (; i < src.length; i++) {
|
|
343
|
+
const c = src[i];
|
|
344
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
345
|
+
for (i++; i < src.length && src[i] !== c; i++) if (src[i] === "\\") i++;
|
|
346
|
+
} else if (c === "{") depth++;
|
|
347
|
+
else if (c === "}" && --depth === 0) return i + 1;
|
|
348
|
+
}
|
|
349
|
+
return src.length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- helpers
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The values an app adds to attributes in its own CSS, as selectors
|
|
356
|
+
* ([variant="brand"], [data-variant="brand"] in extendStyles):
|
|
357
|
+
* { variant: ["brand"] }.
|
|
358
|
+
* @param {string} src
|
|
359
|
+
* @returns {Record<string, string[]>}
|
|
360
|
+
*/
|
|
361
|
+
export function customValues(src) {
|
|
362
|
+
const out = {};
|
|
363
|
+
for (const m of src.matchAll(/\[(?:data-)?([a-z][\w-]*)=["']([^"'\]]+)["']\]/g)) {
|
|
364
|
+
(out[m[1]] ??= []).push(m[2]);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function lineStarts(src) {
|
|
370
|
+
const out = [0];
|
|
371
|
+
for (let i = 0; i < src.length; i++) if (src[i] === "\n") out.push(i + 1);
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function locate(starts, pos) {
|
|
376
|
+
let lo = 0;
|
|
377
|
+
let hi = starts.length - 1;
|
|
378
|
+
while (lo < hi) {
|
|
379
|
+
const mid = (lo + hi + 1) >> 1;
|
|
380
|
+
if (starts[mid] <= pos) lo = mid;
|
|
381
|
+
else hi = mid - 1;
|
|
382
|
+
}
|
|
383
|
+
return { line: lo + 1, column: pos - starts[lo] + 1 };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function kebab(s) {
|
|
387
|
+
return s.replace(/[A-Z]/g, (c, i) => (i === 0 ? "" : "-") + c.toLowerCase());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function camel(s) {
|
|
391
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// " (did you mean "x"?)", or the list when it is short.
|
|
395
|
+
function hint(word, candidates, what = "") {
|
|
396
|
+
let best = null;
|
|
397
|
+
let bestDistance = Infinity;
|
|
398
|
+
for (const c of candidates) {
|
|
399
|
+
const d = distance(word.toLowerCase(), String(c).toLowerCase());
|
|
400
|
+
if (d < bestDistance) { best = c; bestDistance = d; }
|
|
401
|
+
}
|
|
402
|
+
if (best !== null && bestDistance <= Math.max(2, Math.floor(word.length / 3))) return ` (did you mean "${best}"?)`;
|
|
403
|
+
if (what && candidates.length > 0 && candidates.length <= 12) return `; its ${what}: ${candidates.map((c) => `"${c}"`).join(", ")}`;
|
|
404
|
+
return "";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function distance(a, b) {
|
|
408
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
409
|
+
for (let i = 1; i <= a.length; i++) {
|
|
410
|
+
let prev = row[0];
|
|
411
|
+
row[0] = i;
|
|
412
|
+
for (let j = 1; j <= b.length; j++) {
|
|
413
|
+
const tmp = row[j];
|
|
414
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
415
|
+
prev = tmp;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return row[b.length];
|
|
419
|
+
}
|
package/check/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ray-elements check [paths…]: checks the markup of an app against this
|
|
3
|
+
// version of @raylang/elements (its custom-elements.json and icon set).
|
|
4
|
+
//
|
|
5
|
+
// npx @raylang/elements check src/
|
|
6
|
+
// node node_modules/@raylang/elements/check/cli.js check index.html (no npm: a raylang app)
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import { checkMarkup, customValues, RULES } from "./check.js";
|
|
11
|
+
import { iconNames } from "../core/icons.js";
|
|
12
|
+
|
|
13
|
+
const EXTENSIONS = new Set(["html", "htm", "vue", "svelte", "astro", "jsx", "tsx"]);
|
|
14
|
+
// Read for what an app adds: setIcons({ … }) and its own variants in CSS.
|
|
15
|
+
const SCRIPTS = new Set(["js", "mjs", "ts", "mts", "jsx", "tsx", "vue", "svelte", "astro", "html", "htm", "css", "scss", "less"]);
|
|
16
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "target", ".next", ".nuxt", ".svelte-kit"]);
|
|
17
|
+
|
|
18
|
+
const USAGE = `Usage: ray-elements check [paths…] [options]
|
|
19
|
+
|
|
20
|
+
Checks the <ray-*> markup (and the Ray* wrappers) of HTML, Vue, Svelte, Astro,
|
|
21
|
+
JSX and TSX files against @raylang/elements ${version()}. A directory is walked
|
|
22
|
+
(not node_modules, dist…); without paths, the current directory.
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--format json the diagnostics as JSON
|
|
26
|
+
--icons a,b icon names your app registers (setIcons calls are found by themselves)
|
|
27
|
+
--allow attr=v,… more values of an attribute: your own variant=brand (selectors such as
|
|
28
|
+
[variant="brand"] in your CSS and extendStyles are found by themselves)
|
|
29
|
+
--manifest FILE another custom-elements.json (your own components)
|
|
30
|
+
--ignore RULE,… rules to skip
|
|
31
|
+
--rules list the rules
|
|
32
|
+
|
|
33
|
+
Exit status: 1 when there is an error, 0 otherwise (warnings included).`;
|
|
34
|
+
|
|
35
|
+
function version() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function main(argv) {
|
|
44
|
+
const [command, ...rest] = argv;
|
|
45
|
+
if (command === undefined || command === "--help" || command === "-h" || command === "help") {
|
|
46
|
+
console.log(USAGE);
|
|
47
|
+
return command === undefined ? 2 : 0;
|
|
48
|
+
}
|
|
49
|
+
if (command !== "check") {
|
|
50
|
+
console.error(`ray-elements: unknown command "${command}"\n\n${USAGE}`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
const paths = [];
|
|
54
|
+
const manifests = [JSON.parse(readFileSync(new URL("../custom-elements.json", import.meta.url), "utf8"))];
|
|
55
|
+
const extraIcons = [];
|
|
56
|
+
const ignore = new Set();
|
|
57
|
+
const allow = {};
|
|
58
|
+
const allowValue = (attr, v) => (allow[attr] ??= []).push(v);
|
|
59
|
+
let format = "text";
|
|
60
|
+
for (let i = 0; i < rest.length; i++) {
|
|
61
|
+
const arg = rest[i];
|
|
62
|
+
const value = () => {
|
|
63
|
+
if (i + 1 >= rest.length) throw new Error(`${arg} needs a value`);
|
|
64
|
+
return rest[++i];
|
|
65
|
+
};
|
|
66
|
+
if (arg === "--format") format = value();
|
|
67
|
+
else if (arg === "--icons") extraIcons.push(...value().split(",").map((s) => s.trim()).filter(Boolean));
|
|
68
|
+
else if (arg === "--allow") {
|
|
69
|
+
for (const pair of value().split(",")) {
|
|
70
|
+
const [attr, v] = pair.split("=").map((s) => s.trim());
|
|
71
|
+
if (!attr || v === undefined) throw new Error(`--allow takes attr=value (variant=brand)`);
|
|
72
|
+
allowValue(attr, v);
|
|
73
|
+
}
|
|
74
|
+
} else if (arg === "--manifest") manifests.push(JSON.parse(readFileSync(value(), "utf8")));
|
|
75
|
+
else if (arg === "--ignore") value().split(",").forEach((r) => ignore.add(r.trim()));
|
|
76
|
+
else if (arg === "--rules") {
|
|
77
|
+
for (const [rule, what] of Object.entries(RULES)) console.log(`${rule.padEnd(18)} ${what}`);
|
|
78
|
+
return 0;
|
|
79
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
80
|
+
console.log(USAGE);
|
|
81
|
+
return 0;
|
|
82
|
+
} else if (arg.startsWith("--")) throw new Error(`unknown option ${arg}`);
|
|
83
|
+
else paths.push(arg);
|
|
84
|
+
}
|
|
85
|
+
if (format !== "text" && format !== "json") throw new Error(`--format is "text" or "json"`);
|
|
86
|
+
for (const r of ignore) if (!(r in RULES)) throw new Error(`unknown rule "${r}" (see --rules)`);
|
|
87
|
+
|
|
88
|
+
const files = [];
|
|
89
|
+
const scripts = [];
|
|
90
|
+
for (const p of paths.length > 0 ? paths : ["."]) collect(p, files, scripts, true);
|
|
91
|
+
const icons = new Set([...iconNames(), ...extraIcons]);
|
|
92
|
+
for (const f of scripts) {
|
|
93
|
+
const src = readFileSync(f, "utf8");
|
|
94
|
+
for (const name of registeredIcons(src)) icons.add(name);
|
|
95
|
+
for (const [attr, values] of Object.entries(customValues(src))) values.forEach((v) => allowValue(attr, v));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const diagnostics = [];
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
const source = readFileSync(f, "utf8");
|
|
101
|
+
const file = relative(process.cwd(), f) || f;
|
|
102
|
+
for (const d of checkMarkup(source, { manifest: manifests, icons: [...icons], allow, file })) {
|
|
103
|
+
if (!ignore.has(d.rule)) diagnostics.push(d);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
107
|
+
if (format === "json") {
|
|
108
|
+
console.log(JSON.stringify({ files: files.length, errors, warnings: diagnostics.length - errors, diagnostics }, null, 2));
|
|
109
|
+
} else {
|
|
110
|
+
for (const d of diagnostics) console.log(`${d.file}:${d.line}:${d.column}: ${d.severity}: ${d.message} [${d.rule}]`);
|
|
111
|
+
const warnings = diagnostics.length - errors;
|
|
112
|
+
console.log(`${files.length} file${files.length === 1 ? "" : "s"} checked: ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`);
|
|
113
|
+
}
|
|
114
|
+
return errors > 0 ? 1 : 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collect(path, files, scripts, explicit) {
|
|
118
|
+
const st = statSync(path);
|
|
119
|
+
if (st.isDirectory()) {
|
|
120
|
+
for (const name of readdirSync(path).sort()) {
|
|
121
|
+
if (SKIP.has(name) || name.startsWith(".")) continue;
|
|
122
|
+
collect(join(path, name), files, scripts, false);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
|
|
127
|
+
if (explicit || EXTENSIONS.has(ext)) files.push(path);
|
|
128
|
+
if (SCRIPTS.has(ext)) scripts.push(path);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The keys of setIcons({ inbox: '<svg…>', "file-text": … }) calls.
|
|
132
|
+
function registeredIcons(src) {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const call of src.matchAll(/setIcons\(\s*\{([\s\S]*?)\}\s*\)/g)) {
|
|
135
|
+
for (const key of call[1].matchAll(/(?:^|[,{\s])(["']?)([A-Za-z][\w-]*)\1\s*:/g)) out.push(key[2]);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
process.exitCode = main(process.argv.slice(2));
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`ray-elements: ${e.message}`);
|
|
144
|
+
process.exitCode = 2;
|
|
145
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// <ray-button>: a button (or a link, with href) in Mantine's variants.
|
|
2
|
+
// `size` is xs…xl or any CSS length: the height, with the font, the padding
|
|
3
|
+
// and the gap in the proportions of the scale (a big round main action:
|
|
4
|
+
// <ray-icon-button size="5.5rem" radius="full">).
|
|
2
5
|
|
|
3
6
|
import { RayElement, css, define } from "../../core/core.js";
|
|
4
7
|
import { applyColor, applyGradient, sizeStyles, radiusStyles, variantStyles, loaderStyles } from "../../core/styles.js";
|
|
5
8
|
|
|
9
|
+
const SIZES = ["xs", "sm", "md", "lg", "xl"];
|
|
10
|
+
|
|
6
11
|
const styles = css`
|
|
7
12
|
:host {
|
|
8
13
|
display: inline-flex;
|
|
@@ -190,6 +195,7 @@ export class RayButton extends RayElement {
|
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
el.toggleAttribute("aria-busy", Boolean(this.loading));
|
|
198
|
+
this._applySize();
|
|
193
199
|
// ARIA put on the host (by the app, a popover, a menu…) belongs to the inner button.
|
|
194
200
|
for (const attr of MIRRORED_ARIA) {
|
|
195
201
|
const v = this.getAttribute(attr);
|
|
@@ -204,6 +210,17 @@ export class RayButton extends RayElement {
|
|
|
204
210
|
}
|
|
205
211
|
}
|
|
206
212
|
|
|
213
|
+
// A length sets sizeStyles' variables inline (over its :host rules).
|
|
214
|
+
_applySize() {
|
|
215
|
+
const length = this.size && !SIZES.includes(this.size) ? this.size : "";
|
|
216
|
+
if (length === (this._length ?? "")) return;
|
|
217
|
+
this._length = length;
|
|
218
|
+
for (const [prop, ratio] of [["--_h", 1], ["--_fs", 0.4], ["--_px", 0.5], ["--_gap", 0.22]]) {
|
|
219
|
+
if (length) this.style.setProperty(prop, ratio === 1 ? length : `calc(${length} * ${ratio})`);
|
|
220
|
+
else this.style.removeProperty(prop);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
_render(tag) {
|
|
208
225
|
this._el?.remove();
|
|
209
226
|
const el = document.createElement(tag);
|