@birdapi/velinstyle 0.7.0 → 0.8.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/README.de.md +26 -4
- package/README.md +26 -4
- package/cli/blueprint.js +8 -0
- package/cli/blueprints/bottom-nav-mobile.html +17 -0
- package/cli/blueprints/cookie-consent.html +9 -0
- package/cli/blueprints/empty-state.html +5 -0
- package/cli/blueprints/filter-bar.html +15 -0
- package/cli/blueprints/notification-center.html +13 -0
- package/cli/blueprints/onboarding.html +23 -0
- package/cli/blueprints/pricing-table.html +20 -0
- package/cli/blueprints/settings-panel.html +20 -0
- package/cli/index.js +116 -1
- package/cli/layout-audit.js +325 -0
- package/cli/scaffold-recipes.json +70 -0
- package/cli/scaffold.js +155 -0
- package/cli/scanner.js +68 -0
- package/components/index.js +19 -0
- package/components/sanitize.js +29 -3
- package/components/shadow-a11y-styles.js +18 -0
- package/components/velin-announcer.js +35 -0
- package/components/velin-bottom-nav.js +89 -0
- package/components/velin-combobox.js +149 -0
- package/components/velin-command.js +127 -0
- package/components/velin-counter.js +152 -0
- package/components/velin-flip.js +220 -0
- package/components/velin-icon.js +43 -9
- package/components/velin-live-dot.js +85 -0
- package/components/velin-menubar.js +83 -0
- package/components/velin-rating.js +91 -0
- package/components/velin-reveal.js +80 -0
- package/components/velin-segmented-control.js +108 -0
- package/components/velin-sheet.js +107 -0
- package/components/velin-sparkline.js +207 -0
- package/components/velin-theme-toggle.js +277 -60
- package/dist/velinstyle-components.iife.js +1629 -69
- package/dist/velinstyle-components.js +1649 -69
- package/dist/velinstyle-components.min.js +424 -80
- package/dist/velinstyle.css +472 -3
- package/dist/velinstyle.min.css +1 -1
- package/package.json +3 -2
- package/src/a11y/security.css +18 -0
- package/src/base/reset.css +12 -1
- package/src/components/nav.css +152 -151
- package/src/tokens/motion.css +7 -0
- package/src/utilities/animation.css +97 -1
- package/src/utilities/chart-animation.css +101 -0
- package/src/utilities/filter-effects.css +103 -0
- package/src/utilities/safe-area.css +39 -0
- package/src/velinstyle.css +3 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static layout audit for VelinStyle HTML — flex/grid, containers, responsive display.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
|
|
5
|
+
import { join, extname, relative } from 'path';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_IGNORE = ['node_modules', 'dist', '.git', '.next', '.nuxt', 'vendor', 'build'];
|
|
8
|
+
|
|
9
|
+
export function walkHtmlFiles(dir, ignore = DEFAULT_IGNORE) {
|
|
10
|
+
const results = [];
|
|
11
|
+
if (!existsSync(dir)) return results;
|
|
12
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
13
|
+
if (ignore.includes(entry.name)) continue;
|
|
14
|
+
const full = join(dir, entry.name);
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
results.push(...walkHtmlFiles(full, ignore));
|
|
17
|
+
} else if (extname(entry.name).toLowerCase() === '.html') {
|
|
18
|
+
results.push(full);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return results;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function lineOf(content, index) {
|
|
25
|
+
return content.slice(0, index).split('\n').length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractClassTokens(html) {
|
|
29
|
+
const tokens = new Set();
|
|
30
|
+
const re = /class\s*=\s*["']([^"']+)["']/gi;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(html)) !== null) {
|
|
33
|
+
for (const c of m[1].split(/\s+/)) {
|
|
34
|
+
if (c) tokens.add(c);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return tokens;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasAnyClass(tokens, patterns) {
|
|
41
|
+
return patterns.some((p) => {
|
|
42
|
+
if (typeof p === 'string') return tokens.has(p);
|
|
43
|
+
return [...tokens].some((t) => p.test(t));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @typedef {{ id: string, severity: string, message: string, line?: number, file?: string, suggestions?: object, responsive?: object }} LayoutIssue */
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} html
|
|
51
|
+
* @param {{ file?: string }} meta
|
|
52
|
+
* @returns {LayoutIssue[]}
|
|
53
|
+
*/
|
|
54
|
+
export function auditHtml(html, meta = {}) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
const file = meta.file || '';
|
|
57
|
+
const classes = extractClassTokens(html);
|
|
58
|
+
const classStr = html;
|
|
59
|
+
|
|
60
|
+
// velin-col-* without velin-row in nearby context (heuristic: same file has col but no row)
|
|
61
|
+
const hasCol = /\bvelin-(?:sm-|md-|lg-|xl-)?col(?:-\d+)?\b/.test(classStr);
|
|
62
|
+
const hasRow = /\bvelin-row\b/.test(classStr);
|
|
63
|
+
if (hasCol && !hasRow) {
|
|
64
|
+
const idx = classStr.search(/\bvelin-(?:sm-|md-|lg-|xl-)?col/);
|
|
65
|
+
issues.push({
|
|
66
|
+
id: 'grid-missing-row',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
message: 'Column classes (velin-col-*) found without a parent velin-row.',
|
|
69
|
+
line: idx >= 0 ? lineOf(html, idx) : undefined,
|
|
70
|
+
file,
|
|
71
|
+
suggestions: { addClasses: ['velin-row', 'velin-g-4'] },
|
|
72
|
+
responsive: {
|
|
73
|
+
mobile: 'Stack columns: velin-flex velin-flex--col on small screens or use velin-grid.',
|
|
74
|
+
tablet: 'velin-row with velin-md-col-* at 48rem+.',
|
|
75
|
+
desktop: 'velin-row + velin-col-* for 12-column layout.',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// flex without wrap and many flex children (heuristic: 6+ velin-btn or 8+ class on flex container)
|
|
81
|
+
const flexNoWrap =
|
|
82
|
+
/\bvelin-flex\b/.test(classStr) &&
|
|
83
|
+
!/\bvelin-flex--wrap\b/.test(classStr) &&
|
|
84
|
+
(classStr.match(/<a\b/gi)?.length || 0) + (classStr.match(/<button\b/gi)?.length || 0) >= 4;
|
|
85
|
+
if (flexNoWrap) {
|
|
86
|
+
issues.push({
|
|
87
|
+
id: 'flex-no-wrap-overflow',
|
|
88
|
+
severity: 'warning',
|
|
89
|
+
message: 'Flex layout without velin-flex--wrap may overflow on narrow viewports.',
|
|
90
|
+
file,
|
|
91
|
+
suggestions: { addClasses: ['velin-flex--wrap'], alt: ['velin-overflow-x-auto'] },
|
|
92
|
+
responsive: {
|
|
93
|
+
mobile: 'Add velin-flex--wrap or velin-flex--col for stacked mobile nav.',
|
|
94
|
+
tablet: 'velin-flex--wrap with velin-gap-4.',
|
|
95
|
+
desktop: 'Keep row layout with wrap for many items.',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// main or section without container
|
|
101
|
+
const hasMainOrSection = /<(?:main|section)\b/i.test(html);
|
|
102
|
+
const hasContainer = /\bvelin-container\b/.test(classStr);
|
|
103
|
+
if (hasMainOrSection && !hasContainer) {
|
|
104
|
+
issues.push({
|
|
105
|
+
id: 'missing-container',
|
|
106
|
+
severity: 'info',
|
|
107
|
+
message: 'Page sections without velin-container may span edge-to-edge unintentionally.',
|
|
108
|
+
file,
|
|
109
|
+
suggestions: { wrapWith: 'velin-container', alt: ['velin-container--fluid'] },
|
|
110
|
+
responsive: {
|
|
111
|
+
mobile: 'velin-container keeps readable padding on phones.',
|
|
112
|
+
tablet: 'Same container max-width scales at 48rem / 62rem breakpoints.',
|
|
113
|
+
desktop: 'Use velin-container--wide for marketing pages if needed.',
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// velin-hidden without responsive show (velin-md-block etc.)
|
|
119
|
+
if (hasAnyClass(classes, ['velin-hidden']) && !hasAnyClass(classes, [/^velin-(?:sm|md|lg)-(?:block|flex|grid|inline)/])) {
|
|
120
|
+
const idx = classStr.indexOf('velin-hidden');
|
|
121
|
+
issues.push({
|
|
122
|
+
id: 'mobile-hidden-only',
|
|
123
|
+
severity: 'warning',
|
|
124
|
+
message: 'velin-hidden without a matching velin-md-block / velin-md-flex (element may stay hidden on all breakpoints).',
|
|
125
|
+
line: idx >= 0 ? lineOf(html, idx) : undefined,
|
|
126
|
+
file,
|
|
127
|
+
suggestions: { pairWith: ['velin-hidden', 'velin-md-flex'], example: 'velin-hidden velin-md-flex' },
|
|
128
|
+
responsive: {
|
|
129
|
+
mobile: 'Hidden on default (mobile-first).',
|
|
130
|
+
tablet: 'Show from md (48rem) with velin-md-flex or velin-md-block.',
|
|
131
|
+
desktop: 'Adjust with velin-lg-* if needed.',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// desktop-only nav without mobile alternative hint
|
|
137
|
+
const desktopOnlyNav =
|
|
138
|
+
/\bvelin-desktop-only\b/.test(classStr) &&
|
|
139
|
+
/<nav\b/i.test(html) &&
|
|
140
|
+
!/\bvelin-mobile-only\b/.test(classStr) &&
|
|
141
|
+
!/bottom-nav|velin-bottom-nav/i.test(classStr);
|
|
142
|
+
if (desktopOnlyNav) {
|
|
143
|
+
issues.push({
|
|
144
|
+
id: 'desktop-nav-no-mobile',
|
|
145
|
+
severity: 'info',
|
|
146
|
+
message: 'Desktop-only navigation detected; consider velin-mobile-only + bottom-nav-mobile blueprint for phones.',
|
|
147
|
+
file,
|
|
148
|
+
suggestions: { blueprint: 'bottom-nav-mobile', addClasses: ['velin-mobile-only'] },
|
|
149
|
+
responsive: {
|
|
150
|
+
mobile: 'Use blueprint bottom-nav-mobile or velin-mobile-only block.',
|
|
151
|
+
tablet: 'velin-md-flex for nav from 48rem.',
|
|
152
|
+
desktop: 'velin-desktop-only for wide layouts.',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 100vw
|
|
158
|
+
if (/100vw|width:\s*100vw/i.test(html)) {
|
|
159
|
+
issues.push({
|
|
160
|
+
id: 'viewport-width',
|
|
161
|
+
severity: 'warning',
|
|
162
|
+
message: '100vw can cause horizontal scroll; prefer velin-w-full inside velin-container.',
|
|
163
|
+
file,
|
|
164
|
+
suggestions: { replaceWith: 'velin-w-full', wrapWith: 'velin-container' },
|
|
165
|
+
responsive: {
|
|
166
|
+
mobile: 'Avoid 100vw; use container + full width children.',
|
|
167
|
+
tablet: '—',
|
|
168
|
+
desktop: '—',
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// sticky + overflow hidden on same element or parent snippet
|
|
174
|
+
if (
|
|
175
|
+
(/position:\s*sticky|velin-position-sticky/i.test(html)) &&
|
|
176
|
+
(/overflow:\s*hidden|velin-overflow-hidden/i.test(html))
|
|
177
|
+
) {
|
|
178
|
+
issues.push({
|
|
179
|
+
id: 'sticky-overflow-parent',
|
|
180
|
+
severity: 'warning',
|
|
181
|
+
message: 'Sticky positioning may not work when an ancestor uses overflow: hidden.',
|
|
182
|
+
file,
|
|
183
|
+
suggestions: { doc: 'Remove overflow:hidden from sticky ancestors or use velin-position-sticky on a different wrapper.' },
|
|
184
|
+
responsive: { mobile: 'Test sticky headers on iOS Safari.', tablet: '—', desktop: '—' },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// table without responsive wrapper
|
|
189
|
+
if (/<table\b/i.test(html) && !/\btable-responsive\b|velin-table-responsive|overflow-x-auto|velin-overflow-x-auto/i.test(html)) {
|
|
190
|
+
issues.push({
|
|
191
|
+
id: 'table-not-responsive',
|
|
192
|
+
severity: 'info',
|
|
193
|
+
message: 'Tables should scroll horizontally on small screens (blueprint table-responsive or velin-overflow-x-auto).',
|
|
194
|
+
file,
|
|
195
|
+
suggestions: { blueprint: 'table-responsive', wrapWith: 'velin-overflow-x-auto' },
|
|
196
|
+
responsive: {
|
|
197
|
+
mobile: 'Wrap table in scroll container.',
|
|
198
|
+
tablet: 'Full table visible from md if columns fit.',
|
|
199
|
+
desktop: '—',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} targetPath file or directory
|
|
209
|
+
* @returns {{ issues: LayoutIssue[], files: string[] }}
|
|
210
|
+
*/
|
|
211
|
+
export function auditPath(targetPath) {
|
|
212
|
+
let files = [];
|
|
213
|
+
if (!existsSync(targetPath)) {
|
|
214
|
+
return { issues: [], files: [] };
|
|
215
|
+
}
|
|
216
|
+
const st = statSync(targetPath);
|
|
217
|
+
if (st.isFile()) {
|
|
218
|
+
files = extname(targetPath).toLowerCase() === '.html' ? [targetPath] : [];
|
|
219
|
+
} else {
|
|
220
|
+
files = walkHtmlFiles(targetPath);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const issues = [];
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
const html = readFileSync(file, 'utf-8');
|
|
226
|
+
issues.push(...auditHtml(html, { file }));
|
|
227
|
+
}
|
|
228
|
+
return { issues, files };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {LayoutIssue[]} issues
|
|
233
|
+
*/
|
|
234
|
+
export function suggestFromIssues(issues) {
|
|
235
|
+
return issues.map((i) => ({
|
|
236
|
+
...i,
|
|
237
|
+
fix:
|
|
238
|
+
i.suggestions?.addClasses?.length ?
|
|
239
|
+
`Add classes: ${i.suggestions.addClasses.join(', ')}`
|
|
240
|
+
: i.suggestions?.pairWith ?
|
|
241
|
+
`Use: ${i.suggestions.pairWith.join(' ')}`
|
|
242
|
+
: i.suggestions?.wrapWith ?
|
|
243
|
+
`Wrap content in <div class="${i.suggestions.wrapWith}">…</div>`
|
|
244
|
+
: i.suggestions?.blueprint ?
|
|
245
|
+
`Consider blueprint: ${i.suggestions.blueprint}`
|
|
246
|
+
: i.suggestions?.doc || 'See responsive-layout guide.',
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whitelisted safe fixes on HTML string.
|
|
252
|
+
* @returns {{ html: string, changes: string[] }}
|
|
253
|
+
*/
|
|
254
|
+
export function applySafeFixes(html) {
|
|
255
|
+
const changes = [];
|
|
256
|
+
let out = html;
|
|
257
|
+
|
|
258
|
+
// Add velin-flex--wrap to velin-flex that lacks wrap/nowrap
|
|
259
|
+
out = out.replace(
|
|
260
|
+
/class\s*=\s*["']([^"']*\bvelin-flex\b[^"']*)["']/gi,
|
|
261
|
+
(match, cls) => {
|
|
262
|
+
if (/\bvelin-flex--(?:wrap|nowrap)\b/.test(cls)) return match;
|
|
263
|
+
changes.push('Added velin-flex--wrap to flex container');
|
|
264
|
+
return `class="${cls} velin-flex--wrap"`;
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// velin-hidden alone on same element -> add velin-md-block (conservative: only if exactly velin-hidden)
|
|
269
|
+
out = out.replace(
|
|
270
|
+
/class\s*=\s*["']velin-hidden["']/gi,
|
|
271
|
+
() => {
|
|
272
|
+
changes.push('Paired velin-hidden with velin-md-block');
|
|
273
|
+
return 'class="velin-hidden velin-md-block"';
|
|
274
|
+
},
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Wrap bare <main> children - skip (too invasive)
|
|
278
|
+
|
|
279
|
+
return { html: out, changes };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @param {string} targetPath
|
|
284
|
+
* @param {{ write?: boolean, dryRun?: boolean }} opts
|
|
285
|
+
*/
|
|
286
|
+
export function fixPath(targetPath, opts = {}) {
|
|
287
|
+
const { issues, files } = auditPath(targetPath);
|
|
288
|
+
if (files.length === 0) {
|
|
289
|
+
return { ok: false, error: 'No HTML files found.', changes: [] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const allChanges = [];
|
|
293
|
+
for (const file of files) {
|
|
294
|
+
const html = readFileSync(file, 'utf-8');
|
|
295
|
+
const { html: next, changes } = applySafeFixes(html);
|
|
296
|
+
if (changes.length) {
|
|
297
|
+
allChanges.push({ file, changes });
|
|
298
|
+
if (opts.write && !opts.dryRun) {
|
|
299
|
+
writeFileSync(file, next, 'utf-8');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return { ok: true, issues, changes: allChanges, dryRun: !!opts.dryRun && !opts.write };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function formatTextReport(issues, files) {
|
|
308
|
+
const lines = [];
|
|
309
|
+
lines.push(`\nLayout audit: ${files.length} file(s), ${issues.length} issue(s)\n`);
|
|
310
|
+
if (issues.length === 0) {
|
|
311
|
+
lines.push(' No layout issues detected.\n');
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
}
|
|
314
|
+
for (const i of issues) {
|
|
315
|
+
const loc = i.file ? ` ${relative(process.cwd(), i.file)}` : '';
|
|
316
|
+
const ln = i.line ? `:${i.line}` : '';
|
|
317
|
+
lines.push(` [${i.severity.toUpperCase()}] ${i.id}${loc}${ln}`);
|
|
318
|
+
lines.push(` ${i.message}`);
|
|
319
|
+
if (i.suggestions?.addClasses) {
|
|
320
|
+
lines.push(` → Add: ${i.suggestions.addClasses.join(', ')}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
lines.push('');
|
|
324
|
+
return lines.join('\n');
|
|
325
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"intents": {
|
|
3
|
+
"navbar": {
|
|
4
|
+
"keywords": ["navbar", "navigation", "nav bar", "nav-bar", "header", "menü", "menu bar", "top bar"],
|
|
5
|
+
"blueprints": ["navbar-header"],
|
|
6
|
+
"optional": ["search-field"],
|
|
7
|
+
"optionalIf": { "search": ["search-field"], "suche": ["search-field"] },
|
|
8
|
+
"confidence": "high"
|
|
9
|
+
},
|
|
10
|
+
"modal": {
|
|
11
|
+
"keywords": ["modal", "dialog", "popup", "bestätigung", "confirm", "lightbox"],
|
|
12
|
+
"blueprints": ["modal"],
|
|
13
|
+
"confidence": "high"
|
|
14
|
+
},
|
|
15
|
+
"card": {
|
|
16
|
+
"keywords": ["card", "cards", "karte", "karten", "grid", "kacheln"],
|
|
17
|
+
"blueprints": ["card-grid"],
|
|
18
|
+
"confidence": "high"
|
|
19
|
+
},
|
|
20
|
+
"dashboard": {
|
|
21
|
+
"keywords": ["dashboard", "admin", "shell", "sidebar layout", "app shell"],
|
|
22
|
+
"blueprints": ["navbar-header", "layout-dashboard"],
|
|
23
|
+
"confidence": "high"
|
|
24
|
+
},
|
|
25
|
+
"login": {
|
|
26
|
+
"keywords": ["login", "sign in", "signin", "anmelden", "auth"],
|
|
27
|
+
"blueprints": ["form-login"],
|
|
28
|
+
"confidence": "high"
|
|
29
|
+
},
|
|
30
|
+
"footer": {
|
|
31
|
+
"keywords": ["footer", "fußzeile", "fusszeile", "site footer"],
|
|
32
|
+
"blueprints": ["footer-simple"],
|
|
33
|
+
"confidence": "high"
|
|
34
|
+
},
|
|
35
|
+
"pricing": {
|
|
36
|
+
"keywords": ["pricing", "preise", "plans", "tarife", "subscription"],
|
|
37
|
+
"blueprints": ["pricing-table"],
|
|
38
|
+
"confidence": "high"
|
|
39
|
+
},
|
|
40
|
+
"empty": {
|
|
41
|
+
"keywords": ["empty state", "empty-state", "keine daten", "no results", "leer"],
|
|
42
|
+
"blueprints": ["empty-state"],
|
|
43
|
+
"confidence": "high"
|
|
44
|
+
},
|
|
45
|
+
"hero": {
|
|
46
|
+
"keywords": ["hero", "landing", "above the fold", "cta section"],
|
|
47
|
+
"blueprints": ["hero-section"],
|
|
48
|
+
"confidence": "medium"
|
|
49
|
+
},
|
|
50
|
+
"table": {
|
|
51
|
+
"keywords": ["table", "datatable", "data table", "tabelle"],
|
|
52
|
+
"blueprints": ["table-responsive"],
|
|
53
|
+
"confidence": "high"
|
|
54
|
+
},
|
|
55
|
+
"onboarding": {
|
|
56
|
+
"keywords": ["onboarding", "wizard", "steps", "tutorial flow"],
|
|
57
|
+
"blueprints": ["onboarding"],
|
|
58
|
+
"confidence": "high"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"fallback": {
|
|
62
|
+
"intent": "hero",
|
|
63
|
+
"confidence": "low"
|
|
64
|
+
},
|
|
65
|
+
"slots": {
|
|
66
|
+
"brand": ["brand", "logo", "marke"],
|
|
67
|
+
"title": ["title", "titel", "heading", "überschrift"],
|
|
68
|
+
"cta": ["cta", "button", "get started", "loslegen", "start"]
|
|
69
|
+
}
|
|
70
|
+
}
|
package/cli/scaffold.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-based scaffolding: map natural language to VelinStyle blueprint composition.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { emitBlueprint } from './blueprint.js';
|
|
8
|
+
import { auditHtml, suggestFromIssues } from './layout-audit.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const RECIPES_PATH = join(__dirname, 'scaffold-recipes.json');
|
|
12
|
+
|
|
13
|
+
function loadRecipes() {
|
|
14
|
+
return JSON.parse(readFileSync(RECIPES_PATH, 'utf-8'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} prompt
|
|
19
|
+
*/
|
|
20
|
+
export function parseIntent(prompt) {
|
|
21
|
+
const recipes = loadRecipes();
|
|
22
|
+
const lower = prompt.toLowerCase();
|
|
23
|
+
let best = { id: recipes.fallback.intent, confidence: recipes.fallback.confidence, score: 0 };
|
|
24
|
+
|
|
25
|
+
for (const [id, def] of Object.entries(recipes.intents)) {
|
|
26
|
+
let score = 0;
|
|
27
|
+
for (const kw of def.keywords) {
|
|
28
|
+
if (lower.includes(kw.toLowerCase())) score += kw.length;
|
|
29
|
+
}
|
|
30
|
+
if (score > best.score) {
|
|
31
|
+
best = { id, confidence: def.confidence || 'medium', score };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { id: best.id, confidence: best.score > 0 ? best.confidence : 'low' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} prompt
|
|
40
|
+
*/
|
|
41
|
+
export function extractSlots(prompt) {
|
|
42
|
+
const recipes = loadRecipes();
|
|
43
|
+
const lower = prompt.toLowerCase();
|
|
44
|
+
const slots = {
|
|
45
|
+
brand: 'Brand',
|
|
46
|
+
title: 'Welcome',
|
|
47
|
+
cta: 'Get started',
|
|
48
|
+
columns: 3,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const brandMatch = prompt.match(/(?:brand|logo|marke)\s*[:\-]?\s*["']?([^"'\n,]+)/i);
|
|
52
|
+
if (brandMatch) slots.brand = brandMatch[1].trim();
|
|
53
|
+
|
|
54
|
+
const titleMatch = prompt.match(/(?:title|titel|heading)\s*[:\-]?\s*["']?([^"'\n]+)/i);
|
|
55
|
+
if (titleMatch) slots.title = titleMatch[1].trim();
|
|
56
|
+
|
|
57
|
+
const colMatch = lower.match(/(\d+)\s*(?:spalten|columns|cols|karten|cards)/);
|
|
58
|
+
if (colMatch) slots.columns = Math.min(12, Math.max(1, parseInt(colMatch[1], 10)));
|
|
59
|
+
|
|
60
|
+
for (const [slot, keys] of Object.entries(recipes.slots || {})) {
|
|
61
|
+
if (keys.some((k) => lower.includes(k)) && slot === 'cta') {
|
|
62
|
+
slots.cta = 'Get started';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (/suche|search/i.test(prompt)) slots.includeSearch = true;
|
|
67
|
+
|
|
68
|
+
return slots;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} intentId
|
|
73
|
+
* @param {{ prompt?: string, slots?: object }} options
|
|
74
|
+
*/
|
|
75
|
+
export function resolveBlueprints(intentId, options = {}) {
|
|
76
|
+
const recipes = loadRecipes();
|
|
77
|
+
const def = recipes.intents[intentId] || recipes.intents[recipes.fallback.intent];
|
|
78
|
+
const ids = [...def.blueprints];
|
|
79
|
+
|
|
80
|
+
const lower = (options.prompt || '').toLowerCase();
|
|
81
|
+
if (def.optionalIf) {
|
|
82
|
+
for (const [key, bps] of Object.entries(def.optionalIf)) {
|
|
83
|
+
if (lower.includes(key)) ids.push(...bps);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (options.slots?.includeSearch && intentId === 'navbar' && !ids.includes('search-field')) {
|
|
87
|
+
ids.push('search-field');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return [...new Set(ids)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applySlots(html, slots) {
|
|
94
|
+
let out = html;
|
|
95
|
+
out = out.replace(/\bBrand\b/g, slots.brand);
|
|
96
|
+
out = out.replace(/Card one/gi, `${slots.title} — 1`);
|
|
97
|
+
out = out.replace(/Welcome/gi, slots.title);
|
|
98
|
+
out = out.replace(/Get started/gi, slots.cta);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} prompt
|
|
104
|
+
* @param {{ pkgRoot?: string }} options
|
|
105
|
+
*/
|
|
106
|
+
export function scaffoldFromPrompt(prompt, options = {}) {
|
|
107
|
+
if (!prompt || !prompt.trim()) {
|
|
108
|
+
return { ok: false, error: 'Prompt is required.' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const intent = parseIntent(prompt);
|
|
112
|
+
const slots = extractSlots(prompt);
|
|
113
|
+
const blueprintIds = resolveBlueprints(intent.id, { prompt, slots });
|
|
114
|
+
|
|
115
|
+
const parts = [];
|
|
116
|
+
for (const id of blueprintIds) {
|
|
117
|
+
const r = emitBlueprint(id, {});
|
|
118
|
+
if (!r.ok) {
|
|
119
|
+
return { ok: false, error: r.error };
|
|
120
|
+
}
|
|
121
|
+
parts.push(r.text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let html = parts.join('\n\n');
|
|
125
|
+
html = applySlots(html, slots);
|
|
126
|
+
|
|
127
|
+
const banner =
|
|
128
|
+
`<!-- Generated by velinstyle scaffold (0.8.0) — intent: ${intent.id}, confidence: ${intent.confidence} -->\n` +
|
|
129
|
+
`<!-- Blueprints: ${blueprintIds.join(', ')} — run: velinstyle layout suggest & velinstyle scan -->\n`;
|
|
130
|
+
|
|
131
|
+
const fullHtml = banner + html;
|
|
132
|
+
const issues = auditHtml(fullHtml);
|
|
133
|
+
const responsiveHints = suggestFromIssues(issues);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
intent: intent.id,
|
|
138
|
+
confidence: intent.confidence,
|
|
139
|
+
blueprints: blueprintIds,
|
|
140
|
+
html: fullHtml,
|
|
141
|
+
slots,
|
|
142
|
+
responsiveHints,
|
|
143
|
+
nextSteps: ['velinstyle layout suggest <file>', 'velinstyle scan <file>'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function listIntents() {
|
|
148
|
+
const recipes = loadRecipes();
|
|
149
|
+
return Object.entries(recipes.intents).map(([id, def]) => ({
|
|
150
|
+
id,
|
|
151
|
+
keywords: def.keywords,
|
|
152
|
+
blueprints: def.blueprints,
|
|
153
|
+
confidence: def.confidence,
|
|
154
|
+
}));
|
|
155
|
+
}
|
package/cli/scanner.js
CHANGED
|
@@ -80,6 +80,51 @@ function scanSecurityHTML(content, file) {
|
|
|
80
80
|
fix: (currentLine) => fixSafeExternalLinkLine(currentLine),
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
if (/<meta\s[^>]*http-equiv\s*=\s*["']refresh["']/i.test(line)) {
|
|
85
|
+
issues.push({
|
|
86
|
+
file, line: ln, severity: 0,
|
|
87
|
+
rule: 'security/no-meta-refresh',
|
|
88
|
+
message: '<meta http-equiv="refresh"> can redirect users without consent.',
|
|
89
|
+
fixable: false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (/\sstyle\s*=\s*["'][^"']+["']/i.test(line) && !/velin-user-content/i.test(line)) {
|
|
94
|
+
issues.push({
|
|
95
|
+
file, line: ln, severity: 1,
|
|
96
|
+
rule: 'security/no-inline-style',
|
|
97
|
+
message: 'Inline style attribute. Prefer CSS classes to reduce XSS surface.',
|
|
98
|
+
fixable: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (/(?:href|src)\s*=\s*["']data:text\/html/i.test(line)) {
|
|
103
|
+
issues.push({
|
|
104
|
+
file, line: ln, severity: 0,
|
|
105
|
+
rule: 'security/no-data-html-uri',
|
|
106
|
+
message: 'data:text/html URI can execute script when mishandled.',
|
|
107
|
+
fixable: false,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (/<form\b[^>]*\btarget\s*=\s*["']_blank["']/i.test(line)) {
|
|
112
|
+
issues.push({
|
|
113
|
+
file, line: ln, severity: 1,
|
|
114
|
+
rule: 'security/dangerous-target',
|
|
115
|
+
message: '<form target="_blank"> is unusual and can be abused. Prefer same-tab navigation.',
|
|
116
|
+
fixable: false,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (/<script\b[^>]*\bsrc\s*=\s*["']https?:\/\//i.test(line) && !/\bintegrity\s*=/i.test(line)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
file, line: ln, severity: 2,
|
|
123
|
+
rule: 'security/integrity-missing',
|
|
124
|
+
message: 'External <script> without integrity attribute. Use SRI for CDN scripts.',
|
|
125
|
+
fixable: false,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
83
128
|
});
|
|
84
129
|
|
|
85
130
|
if (!/<meta\s[^>]*http-equiv\s*=\s*["']Content-Security-Policy["']/i.test(content)) {
|
|
@@ -136,11 +181,27 @@ function scanSecurityJS(content, file) {
|
|
|
136
181
|
fixable: false,
|
|
137
182
|
});
|
|
138
183
|
}
|
|
184
|
+
|
|
185
|
+
if (/\.postMessage\s*\([^)]*,\s*['"]\*['"]\s*\)/.test(line)) {
|
|
186
|
+
issues.push({
|
|
187
|
+
file, line: ln, severity: 1,
|
|
188
|
+
rule: 'security/postmessage-wildcard',
|
|
189
|
+
message: 'postMessage with targetOrigin "*" accepts any origin.',
|
|
190
|
+
fixable: false,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
139
193
|
});
|
|
140
194
|
|
|
141
195
|
return issues;
|
|
142
196
|
}
|
|
143
197
|
|
|
198
|
+
function issueCategory(rule) {
|
|
199
|
+
if (rule.startsWith('security/')) return 'security';
|
|
200
|
+
if (rule.startsWith('a11y/')) return 'a11y';
|
|
201
|
+
if (rule.startsWith('css/')) return 'css';
|
|
202
|
+
return 'other';
|
|
203
|
+
}
|
|
204
|
+
|
|
144
205
|
// ── Accessibility Scanner ────────────────────────────────────────────────────
|
|
145
206
|
|
|
146
207
|
function scanA11yHTML(content, file) {
|
|
@@ -325,6 +386,9 @@ export function scan(targetPath, options = {}) {
|
|
|
325
386
|
const writeFixes = doFix && !fixDryRun;
|
|
326
387
|
const runFixPipeline = doFix || fixDryRun;
|
|
327
388
|
const ignore = options.ignore || DEFAULT_IGNORE;
|
|
389
|
+
const onlyCategories = options.only
|
|
390
|
+
? options.only.split(',').map((s) => s.trim().toLowerCase())
|
|
391
|
+
: null;
|
|
328
392
|
|
|
329
393
|
const htmlFiles = walkFiles(targetPath, ['.html', '.htm'], ignore);
|
|
330
394
|
const cssFiles = walkFiles(targetPath, ['.css'], ignore);
|
|
@@ -349,6 +413,9 @@ export function scan(targetPath, options = {}) {
|
|
|
349
413
|
}
|
|
350
414
|
|
|
351
415
|
allIssues = allIssues.filter(i => i.severity <= minSeverity);
|
|
416
|
+
if (onlyCategories?.length) {
|
|
417
|
+
allIssues = allIssues.filter((i) => onlyCategories.includes(issueCategory(i.rule)));
|
|
418
|
+
}
|
|
352
419
|
allIssues.sort((a, b) => a.severity - b.severity || a.file.localeCompare(b.file) || a.line - b.line);
|
|
353
420
|
|
|
354
421
|
let fixSummary = null;
|
|
@@ -368,6 +435,7 @@ export function scan(targetPath, options = {}) {
|
|
|
368
435
|
file: relative(targetPath, i.file),
|
|
369
436
|
line: i.line,
|
|
370
437
|
severity: SEVERITY_LABEL[i.severity],
|
|
438
|
+
category: issueCategory(i.rule),
|
|
371
439
|
rule: i.rule,
|
|
372
440
|
message: i.message,
|
|
373
441
|
fixable: !!i.fixable,
|
package/components/index.js
CHANGED
|
@@ -19,9 +19,28 @@ export { default as VelinDialog } from './velin-dialog.js';
|
|
|
19
19
|
export { default as VelinCountdown } from './velin-countdown.js';
|
|
20
20
|
export { default as VelinProgressRing } from './velin-progress-ring.js';
|
|
21
21
|
export { default as VelinPersist } from './velin-persist.js';
|
|
22
|
+
export { default as VelinCombobox } from './velin-combobox.js';
|
|
23
|
+
export { default as VelinBottomNav } from './velin-bottom-nav.js';
|
|
24
|
+
export { default as VelinSheet } from './velin-sheet.js';
|
|
25
|
+
export { default as VelinSegmentedControl } from './velin-segmented-control.js';
|
|
26
|
+
export { default as VelinRating } from './velin-rating.js';
|
|
27
|
+
export { default as VelinMenubar } from './velin-menubar.js';
|
|
28
|
+
export { default as VelinCommand } from './velin-command.js';
|
|
29
|
+
export { default as VelinAnnouncer } from './velin-announcer.js';
|
|
30
|
+
export { default as VelinSparkline } from './velin-sparkline.js';
|
|
31
|
+
export { default as VelinCounter } from './velin-counter.js';
|
|
32
|
+
export { default as VelinLiveDot } from './velin-live-dot.js';
|
|
33
|
+
export { initReveal } from './velin-reveal.js';
|
|
34
|
+
export { flipReorder, filterList } from './velin-flip.js';
|
|
35
|
+
export { escapeHTML, escapeHTMLAttribute, sanitizeURL, stripControlChars, createSafeHTML, getTrustedPolicy } from './sanitize.js';
|
|
22
36
|
export { VelinHapticObserver, vibrate, applyHaptic, PATTERNS as HapticPatterns } from './velin-haptic.js';
|
|
23
37
|
export { trapFocus, rovingTabindex, saveFocus, restoreFocus, getFocusableElements, setBackgroundInert, clearBackgroundInert } from './focus-manager.js';
|
|
24
38
|
|
|
39
|
+
import './velin-sparkline.js';
|
|
40
|
+
import './velin-counter.js';
|
|
41
|
+
import './velin-live-dot.js';
|
|
42
|
+
import './velin-reveal.js';
|
|
43
|
+
import './velin-flip.js';
|
|
25
44
|
import { VelinHapticObserver } from './velin-haptic.js';
|
|
26
45
|
if (typeof document !== 'undefined') {
|
|
27
46
|
const _hapticInit = () => { new VelinHapticObserver().start(document.body); };
|