@raylang/elements 0.5.0 → 0.5.2
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 +412 -0
- package/check/cli.js +145 -0
- package/components/radio/radio.js +18 -1
- package/custom-elements.json +733 -23
- package/llms-full.txt +3808 -0
- package/llms.txt +342 -0
- package/package.json +10 -1
- package/ray-elements.d.ts +3 -3
- package/vscode.html-custom-data.json +422 -1
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,412 @@
|
|
|
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
|
+
function attribute(a) {
|
|
78
|
+
const text = a.type?.text ?? "string";
|
|
79
|
+
const parts = text.split("|").map((s) => s.trim());
|
|
80
|
+
const literals = parts.filter((s) => /^"[^"]*"$/.test(s)).map((s) => s.slice(1, -1));
|
|
81
|
+
const others = parts.filter((s) => !/^"[^"]*"$/.test(s) && s !== "undefined" && s !== "null");
|
|
82
|
+
return { name: a.name, boolean: text === "boolean", values: literals.length > 0 && others.length === 0 ? literals : null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The language of a file by its extension: "html" (also Vue, Svelte, Astro) or "jsx". */
|
|
86
|
+
export function languageOf(file) {
|
|
87
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
88
|
+
if (["jsx", "tsx", "js", "ts", "mjs", "mts"].includes(ext)) return "jsx";
|
|
89
|
+
if (ext === "vue") return "vue";
|
|
90
|
+
return "html";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Checks one file's markup.
|
|
95
|
+
* @param {string} source
|
|
96
|
+
* @param {{ manifest: object | object[], icons?: string[], allow?: Record<string, string[]>, file?: string, lang?: "html" | "vue" | "jsx" }} options
|
|
97
|
+
* allow: more values of an attribute, for every element (an app's own variant: { variant: ["brand"] })
|
|
98
|
+
* @returns {Diagnostic[]}
|
|
99
|
+
*/
|
|
100
|
+
export function checkMarkup(source, { manifest, icons, allow = {}, file = "<input>", lang } = {}) {
|
|
101
|
+
const index = indexManifest(manifest);
|
|
102
|
+
const language = lang ?? languageOf(file);
|
|
103
|
+
const iconSet = icons ? new Set(icons) : null;
|
|
104
|
+
const lines = lineStarts(source);
|
|
105
|
+
const out = [];
|
|
106
|
+
const report = (pos, severity, rule, message) => {
|
|
107
|
+
const { line, column } = locate(lines, pos);
|
|
108
|
+
out.push({ file, line, column, severity, rule, message });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const stack = [];
|
|
112
|
+
for (const tok of scan(source, language)) {
|
|
113
|
+
if (tok.type === "close") {
|
|
114
|
+
const i = stack.findLastIndex((t) => t.name === tok.name);
|
|
115
|
+
if (i >= 0) stack.length = i;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (tok.type === "css") {
|
|
119
|
+
colors(tok.text, tok.pos, report);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const parent = stack.at(-1);
|
|
123
|
+
const el = resolve(index, tok.name, language);
|
|
124
|
+
if (el === undefined && isRayName(tok.name, language)) {
|
|
125
|
+
const known = /^ray-/.test(tok.name) ? [...index.tags.keys()] : [...index.classes.keys()];
|
|
126
|
+
report(tok.pos, "error", "unknown-element", `<${tok.name}> is not a ray-elements element${hint(tok.name, known)}`);
|
|
127
|
+
}
|
|
128
|
+
for (const a of tok.attrs) {
|
|
129
|
+
attributeChecks(a, tok, el, parent, language, iconSet, allow, report);
|
|
130
|
+
}
|
|
131
|
+
if (el?.tag === "ray-icon-button") {
|
|
132
|
+
const named = tok.attrs.some((a) => ["label", "aria-label", "aria-labelledby"].includes(normalize(a.name, language).name));
|
|
133
|
+
if (!named) report(tok.pos, "error", "missing-label", `<${tok.name}> needs a label: it is the button's accessible name (and its tooltip)`);
|
|
134
|
+
}
|
|
135
|
+
if (!tok.selfClosing && !(language !== "jsx" && VOID.has(tok.name.toLowerCase()))) {
|
|
136
|
+
stack.push({ name: tok.name, el });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isRayName(name, language) {
|
|
143
|
+
return /^ray-/.test(name) || (language !== "html" && /^Ray[A-Z]/.test(name));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolve(index, name, language) {
|
|
147
|
+
if (/^ray-/.test(name)) return index.tags.get(name);
|
|
148
|
+
if (language !== "html" && /^Ray[A-Z]/.test(name)) return index.classes.get(name);
|
|
149
|
+
return null; // not ours
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// An attribute's name as the element sees it, and how it was written.
|
|
153
|
+
function normalize(raw, language) {
|
|
154
|
+
let name = raw;
|
|
155
|
+
let bound = false;
|
|
156
|
+
let event = null;
|
|
157
|
+
let skip = false;
|
|
158
|
+
if (name.startsWith("@") || name.startsWith("v-on:")) {
|
|
159
|
+
event = name.replace(/^@|^v-on:/, "");
|
|
160
|
+
} else if (name.startsWith("on:")) {
|
|
161
|
+
event = name.slice(3); // Svelte
|
|
162
|
+
} else if (name.startsWith("#") || name.startsWith("v-") || name.startsWith("{")) {
|
|
163
|
+
skip = true; // slots, directives, spreads
|
|
164
|
+
} else if (name.startsWith(":") || name.startsWith("v-bind:") || name.startsWith("bind:")) {
|
|
165
|
+
name = name.replace(/^:|^v-bind:|^bind:/, "");
|
|
166
|
+
bound = true;
|
|
167
|
+
} else if (name.includes(":")) {
|
|
168
|
+
skip = true; // class:, use:, client:, set:… (Svelte, Astro)
|
|
169
|
+
} else if (/^on[A-Z]/.test(name)) {
|
|
170
|
+
event = kebab(name.slice(2)); // React: onRaySelect
|
|
171
|
+
}
|
|
172
|
+
if (event !== null) event = event.split(".")[0];
|
|
173
|
+
name = name.split(".")[0]; // Vue modifiers (.prop, .camel)
|
|
174
|
+
// HTML lowercases attribute names; Vue and React 19 set a camelCase one as the property.
|
|
175
|
+
if (language === "html") name = name.toLowerCase();
|
|
176
|
+
else if (/[A-Z]/.test(name)) name = kebab(name);
|
|
177
|
+
return { name, bound, event, skip };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function attributeChecks(a, tok, el, parent, language, icons, allow, report) {
|
|
181
|
+
const n = normalize(a.name, language);
|
|
182
|
+
if (n.skip) {
|
|
183
|
+
// Vue: <template #left> passes nothing to a wrapper, which forwards only the default slot.
|
|
184
|
+
const slot = a.name.match(/^(?:#|v-slot:)([\w-]+)/);
|
|
185
|
+
if (slot && slot[1] !== "default" && tok.name === "template" && parent?.el) {
|
|
186
|
+
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]}>`);
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// slot="…" on a child of one of ours.
|
|
191
|
+
if (n.name === "slot" && !n.bound && a.value !== null && !a.dynamic && parent?.el && parent.el.slots.size > 0) {
|
|
192
|
+
if (!parent.el.slots.has(a.value)) {
|
|
193
|
+
const names = [...parent.el.slots].filter((s) => s !== "");
|
|
194
|
+
report(a.pos, "error", "unknown-slot", `<${parent.name}> has no slot "${a.value}"${hint(a.value, names, "slots")}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!el) return;
|
|
198
|
+
if (n.event !== null) {
|
|
199
|
+
if (n.event.startsWith("ray-") && !el.events.has(n.event)) {
|
|
200
|
+
const own = [...el.events].filter((e) => e.startsWith("ray-"));
|
|
201
|
+
report(a.pos, "error", "unknown-event", `<${tok.name}> does not fire "${n.event}"${hint(n.event, own, "events")}`);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const name = n.name;
|
|
206
|
+
if (GLOBAL.has(name) || FRAMEWORK.has(name.replace(/-/g, "")) || /^(aria|data)-/.test(name) || /^on[a-z]+$/.test(name)) return;
|
|
207
|
+
if (name === "model-value" && language !== "html") return;
|
|
208
|
+
const attr = el.attrs.get(name);
|
|
209
|
+
// A wrapper, JSX (React 19) or a bound attribute can set a property too.
|
|
210
|
+
const viaProperty = n.bound || a.dynamic || language === "jsx" || tok.name !== el.tag;
|
|
211
|
+
if (!attr) {
|
|
212
|
+
if (el.fields.has(name)) {
|
|
213
|
+
if (!viaProperty) report(a.pos, "error", "property-only", `${camel(name)} of <${tok.name}> is a property only: set it from JS (el.${camel(name)} = …)`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const known = [...el.attrs.keys(), ...(viaProperty ? el.fields : [])];
|
|
217
|
+
report(a.pos, "error", "unknown-attribute", `<${tok.name}> has no attribute "${name}"${hint(name, known)}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (a.value === null || a.dynamic || n.bound) return;
|
|
221
|
+
if (attr.boolean && a.value === "false") {
|
|
222
|
+
report(a.pos, "error", "boolean-value", `${name}="false" still means true on <${tok.name}>: a boolean attribute is present or absent`);
|
|
223
|
+
} else if (attr.values && !attr.values.includes(a.value) && !allow[name]?.includes(a.value)) {
|
|
224
|
+
report(a.pos, "error", "invalid-value", `${name}="${a.value}" is not valid on <${tok.name}>: one of ${attr.values.map((v) => `"${v}"`).join(", ")}`);
|
|
225
|
+
}
|
|
226
|
+
// "none" (or nothing) draws no icon: a blank that keeps the alignment.
|
|
227
|
+
if (el.tag === "ray-icon" && name === "name" && icons && a.value !== "" && a.value !== "none" && !icons.has(a.value)) {
|
|
228
|
+
report(a.pos, "warning", "unknown-icon", `no icon named "${a.value}"${hint(a.value, [...icons], "icons")} (register it with setIcons)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function colors(text, pos, report) {
|
|
233
|
+
for (const m of text.matchAll(COLOR)) {
|
|
234
|
+
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`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- the scanner
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The tags of a source, in order: { type: "open", name, attrs, selfClosing, pos },
|
|
242
|
+
* { type: "close", name }, and { type: "css", text, pos } for <style> and style="…".
|
|
243
|
+
*/
|
|
244
|
+
function* scan(src, language = "html") {
|
|
245
|
+
const jsx = language === "jsx";
|
|
246
|
+
let i = 0;
|
|
247
|
+
while (i < src.length) {
|
|
248
|
+
const lt = src.indexOf("<", i);
|
|
249
|
+
if (lt < 0) return;
|
|
250
|
+
if (src.startsWith("<!--", lt)) {
|
|
251
|
+
const end = src.indexOf("-->", lt + 4);
|
|
252
|
+
i = end < 0 ? src.length : end + 3;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (jsx) {
|
|
256
|
+
// After an identifier, `<` is a generic or a comparison (useRef<RayDialog>,
|
|
257
|
+
// a < b) unless the name has a dash (text before a tag: "Press <ray-kbd>").
|
|
258
|
+
let p = lt - 1;
|
|
259
|
+
while (p >= 0 && /\s/.test(src[p])) p--;
|
|
260
|
+
const afterIdentifier = p >= 0 && /[\w$)\]]/.test(src[p]) && !/\breturn$/.test(src.slice(Math.max(0, p - 6), p + 1));
|
|
261
|
+
if (afterIdentifier && !/^<[a-z][\w]*-/.test(src.slice(lt, lt + 100))) {
|
|
262
|
+
i = lt + 1;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (src[lt + 1] === "/") {
|
|
267
|
+
const m = /^<\/([A-Za-z][\w.:-]*)\s*>/.exec(src.slice(lt, lt + 200));
|
|
268
|
+
if (m) yield { type: "close", name: m[1] };
|
|
269
|
+
i = lt + (m ? m[0].length : 2);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const m = /^<([A-Za-z][\w.:-]*)/.exec(src.slice(lt, lt + 200));
|
|
273
|
+
if (!m || m[1].endsWith("-")) {
|
|
274
|
+
i = lt + 1;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const name = m[1];
|
|
278
|
+
let j = lt + m[0].length;
|
|
279
|
+
const attrs = [];
|
|
280
|
+
let selfClosing = false;
|
|
281
|
+
while (j < src.length) {
|
|
282
|
+
while (j < src.length && /\s/.test(src[j])) j++;
|
|
283
|
+
if (src[j] === ">") { j++; break; }
|
|
284
|
+
if (src[j] === "/" && src[j + 1] === ">") { selfClosing = true; j += 2; break; }
|
|
285
|
+
if (src[j] === "{") { j = skipBraces(src, j); attrs.push({ name: "{", value: null, dynamic: true, pos: j }); continue; }
|
|
286
|
+
const start = j;
|
|
287
|
+
while (j < src.length && !/[\s=>]/.test(src[j]) && !(src[j] === "/" && src[j + 1] === ">")) j++;
|
|
288
|
+
const attrName = src.slice(start, j);
|
|
289
|
+
if (attrName === "") { j++; continue; }
|
|
290
|
+
let value = null;
|
|
291
|
+
let dynamic = false;
|
|
292
|
+
let valuePos = start;
|
|
293
|
+
let k = j;
|
|
294
|
+
while (k < src.length && /\s/.test(src[k])) k++;
|
|
295
|
+
if (src[k] === "=") {
|
|
296
|
+
k++;
|
|
297
|
+
while (k < src.length && /\s/.test(src[k])) k++;
|
|
298
|
+
valuePos = k;
|
|
299
|
+
if (src[k] === '"' || src[k] === "'") {
|
|
300
|
+
const end = src.indexOf(src[k], k + 1);
|
|
301
|
+
value = src.slice(k + 1, end < 0 ? src.length : end);
|
|
302
|
+
j = end < 0 ? src.length : end + 1;
|
|
303
|
+
valuePos = k + 1;
|
|
304
|
+
} else if (src[k] === "{") {
|
|
305
|
+
j = skipBraces(src, k);
|
|
306
|
+
value = src.slice(k, j);
|
|
307
|
+
dynamic = true;
|
|
308
|
+
} else {
|
|
309
|
+
let e = k;
|
|
310
|
+
while (e < src.length && !/[\s>]/.test(src[e])) e++;
|
|
311
|
+
value = src.slice(k, e);
|
|
312
|
+
j = e;
|
|
313
|
+
}
|
|
314
|
+
if (/\{|\$\{/.test(value)) dynamic = true;
|
|
315
|
+
}
|
|
316
|
+
attrs.push({ name: attrName, value, dynamic, pos: start });
|
|
317
|
+
if (attrName.toLowerCase() === "style" && value !== null && !dynamic) yield { type: "css", text: value, pos: valuePos };
|
|
318
|
+
}
|
|
319
|
+
yield { type: "open", name, attrs, selfClosing, pos: lt };
|
|
320
|
+
const lower = name.toLowerCase();
|
|
321
|
+
if (!jsx && !selfClosing && RAW.has(lower)) {
|
|
322
|
+
const end = src.toLowerCase().indexOf(`</${lower}`, j);
|
|
323
|
+
const stop = end < 0 ? src.length : end;
|
|
324
|
+
if (lower === "style") yield { type: "css", text: src.slice(j, stop), pos: j };
|
|
325
|
+
i = stop;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
i = j;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// From an opening brace to after its match, past strings and template literals.
|
|
333
|
+
function skipBraces(src, i) {
|
|
334
|
+
let depth = 0;
|
|
335
|
+
for (; i < src.length; i++) {
|
|
336
|
+
const c = src[i];
|
|
337
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
338
|
+
for (i++; i < src.length && src[i] !== c; i++) if (src[i] === "\\") i++;
|
|
339
|
+
} else if (c === "{") depth++;
|
|
340
|
+
else if (c === "}" && --depth === 0) return i + 1;
|
|
341
|
+
}
|
|
342
|
+
return src.length;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- helpers
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The values an app adds to attributes in its own CSS, as selectors
|
|
349
|
+
* ([variant="brand"], [data-variant="brand"] in extendStyles):
|
|
350
|
+
* { variant: ["brand"] }.
|
|
351
|
+
* @param {string} src
|
|
352
|
+
* @returns {Record<string, string[]>}
|
|
353
|
+
*/
|
|
354
|
+
export function customValues(src) {
|
|
355
|
+
const out = {};
|
|
356
|
+
for (const m of src.matchAll(/\[(?:data-)?([a-z][\w-]*)=["']([^"'\]]+)["']\]/g)) {
|
|
357
|
+
(out[m[1]] ??= []).push(m[2]);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function lineStarts(src) {
|
|
363
|
+
const out = [0];
|
|
364
|
+
for (let i = 0; i < src.length; i++) if (src[i] === "\n") out.push(i + 1);
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function locate(starts, pos) {
|
|
369
|
+
let lo = 0;
|
|
370
|
+
let hi = starts.length - 1;
|
|
371
|
+
while (lo < hi) {
|
|
372
|
+
const mid = (lo + hi + 1) >> 1;
|
|
373
|
+
if (starts[mid] <= pos) lo = mid;
|
|
374
|
+
else hi = mid - 1;
|
|
375
|
+
}
|
|
376
|
+
return { line: lo + 1, column: pos - starts[lo] + 1 };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function kebab(s) {
|
|
380
|
+
return s.replace(/[A-Z]/g, (c, i) => (i === 0 ? "" : "-") + c.toLowerCase());
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function camel(s) {
|
|
384
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// " (did you mean "x"?)", or the list when it is short.
|
|
388
|
+
function hint(word, candidates, what = "") {
|
|
389
|
+
let best = null;
|
|
390
|
+
let bestDistance = Infinity;
|
|
391
|
+
for (const c of candidates) {
|
|
392
|
+
const d = distance(word.toLowerCase(), String(c).toLowerCase());
|
|
393
|
+
if (d < bestDistance) { best = c; bestDistance = d; }
|
|
394
|
+
}
|
|
395
|
+
if (best !== null && bestDistance <= Math.max(2, Math.floor(word.length / 3))) return ` (did you mean "${best}"?)`;
|
|
396
|
+
if (what && candidates.length > 0 && candidates.length <= 12) return `; its ${what}: ${candidates.map((c) => `"${c}"`).join(", ")}`;
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function distance(a, b) {
|
|
401
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
402
|
+
for (let i = 1; i <= a.length; i++) {
|
|
403
|
+
let prev = row[0];
|
|
404
|
+
row[0] = i;
|
|
405
|
+
for (let j = 1; j <= b.length; j++) {
|
|
406
|
+
const tmp = row[j];
|
|
407
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
408
|
+
prev = tmp;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return row[b.length];
|
|
412
|
+
}
|
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
|
+
}
|
|
@@ -217,11 +217,28 @@ export class RayRadio extends RayElement {
|
|
|
217
217
|
this._groupDisabled = false;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/** Whether this radio is the group's choice
|
|
220
|
+
/** Whether this radio is the group's choice. Setting it sets the group's
|
|
221
|
+
* value, like `checked` on a native radio (no events). */
|
|
221
222
|
get checked() {
|
|
222
223
|
return this._checked;
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
set checked(on) {
|
|
227
|
+
on = Boolean(on);
|
|
228
|
+
const group = this.closest("ray-radio-group");
|
|
229
|
+
if (!group) {
|
|
230
|
+
this._setChecked(on);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (on) {
|
|
234
|
+
group.value = this.value;
|
|
235
|
+
for (const r of group.radios) r._setChecked(r === this);
|
|
236
|
+
} else if (this._checked) {
|
|
237
|
+
group.value = undefined;
|
|
238
|
+
this._setChecked(false);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
225
242
|
_setChecked(on) {
|
|
226
243
|
this._checked = on;
|
|
227
244
|
this.toggleAttribute("data-checked", on);
|