@keenmate/web-components-devkit 1.0.0-rc05 → 1.0.0-rc07
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/CHANGELOG.md +114 -0
- package/README.md +81 -23
- package/package.json +1 -1
- package/src/cli/args.js +32 -9
- package/src/cli/commands/check-pages.js +189 -0
- package/src/cli/commands/check-vars.js +24 -8
- package/src/cli/commands/doctor.js +129 -16
- package/src/cli/kmwc.js +80 -33
- package/src/cli/repo-checks.js +249 -0
- package/src/cli/sync-plan.js +3 -2
- package/src/config.d.ts +14 -4
- package/src/config.js +13 -2
- package/src/examples/css/base.css +28 -0
- package/src/examples/css/content.css +44 -0
- package/src/examples/css/form-controls.css +4 -0
- package/src/examples/css/index-page.css +4 -0
- package/src/examples/css/widgets.css +12 -4
- package/src/vite/index.js +10 -7
- package/src/vite/page-composer.js +30 -1
- package/templates/Makefile.tmpl +4 -1
- package/templates/claude-block.md +6 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo checks behind `kmwc doctor` that read a component's own files (and its
|
|
3
|
+
* registry siblings): accepted rulebook deviations, library output names, ports,
|
|
4
|
+
* analytics ids and page CSS the kit already covers. Each check is a pure
|
|
5
|
+
* function over text / config where it can be (unit-tested), plus a thin
|
|
6
|
+
* file-reading wrapper.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { DEVKIT_ROOT } from '../config.js';
|
|
11
|
+
|
|
12
|
+
export const VALIDATION_NOTES_FILE = 'VALIDATION-NOTES.md';
|
|
13
|
+
const PORT_BLOCK_SIZE = 100;
|
|
14
|
+
const PLAUSIBLE_SNIPPET_FILE = 'plausible-snippet.html';
|
|
15
|
+
const DOC_FILES = ['README.md', 'CLAUDE.md'];
|
|
16
|
+
const DOCS_DIR = 'docs';
|
|
17
|
+
const SIBLING_PORT_FILES = ['vite.config.ts', 'vite.config.mjs', 'vite.config.js', 'playwright.config.ts', 'playwright.config.mjs', 'playwright.config.js', 'Makefile', 'make.bat'];
|
|
18
|
+
const PORTED_SCRIPTS = { dev: 'dev', preview: 'preview', 'preview:examples': 'preview' };
|
|
19
|
+
const CHECK_ID_PATTERN = /\bC-[A-Z]+-\d+\b/g;
|
|
20
|
+
|
|
21
|
+
const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, 'utf8') : null);
|
|
22
|
+
const getUniqueSorted = (values) => [...new Set(values)].sort((left, right) => (left > right ? 1 : left < right ? -1 : 0));
|
|
23
|
+
|
|
24
|
+
// ── accepted deviations ──────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check ids accepted in VALIDATION-NOTES.md: every `C-XX-N` in a `##` heading
|
|
28
|
+
* (`## C-CST-4 / C-CST-10 — …` accepts both). Only headings count, so prose that
|
|
29
|
+
* merely mentions a check doesn't accept it.
|
|
30
|
+
*/
|
|
31
|
+
export function getAcceptedCheckIds(notesText) {
|
|
32
|
+
if (!notesText) return new Set();
|
|
33
|
+
const headings = notesText.split(/\r?\n/).filter((line) => /^##\s/.test(line));
|
|
34
|
+
return new Set(headings.flatMap((heading) => heading.match(CHECK_ID_PATTERN) || []));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadAcceptedCheckIds(root) {
|
|
38
|
+
return getAcceptedCheckIds(readText(join(root, VALIDATION_NOTES_FILE)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── library output names ─────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** The dist/ files the library build emits for a resolved `config.library`. */
|
|
44
|
+
export function getLibraryOutputPaths(library) {
|
|
45
|
+
const outputPaths = library.formats.map((format) => `dist/${library.fileName}.${format === 'es' ? 'js' : `${format}.js`}`);
|
|
46
|
+
outputPaths.push(`dist/${library.cssFileName}.css`);
|
|
47
|
+
if (library.shouldEmitTypes) outputPaths.push('dist/index.d.ts');
|
|
48
|
+
return outputPaths;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every `./dist/…` target package.json points at, as [{ field, target }] (globs skipped). */
|
|
52
|
+
export function getPackageDistTargets(packageJson) {
|
|
53
|
+
const targets = [];
|
|
54
|
+
const addTarget = (field, target) => {
|
|
55
|
+
if (typeof target !== 'string') return;
|
|
56
|
+
const normalizedTarget = target.replace(/^\.\//, '');
|
|
57
|
+
if (normalizedTarget.startsWith('dist/') && !normalizedTarget.includes('*') && !normalizedTarget.endsWith('/')) targets.push({ field, target: normalizedTarget });
|
|
58
|
+
};
|
|
59
|
+
for (const field of ['main', 'module', 'types', 'typings', 'style', 'browser', 'unpkg', 'jsdelivr']) addTarget(field, packageJson[field]);
|
|
60
|
+
const visitExports = (value, path) => {
|
|
61
|
+
if (typeof value === 'string') addTarget(path, value);
|
|
62
|
+
else if (value && typeof value === 'object') for (const [key, child] of Object.entries(value)) visitExports(child, `${path}[${JSON.stringify(key)}]`);
|
|
63
|
+
};
|
|
64
|
+
visitExports(packageJson.exports, 'exports');
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* package.json targets the library build won't produce — a renamed UMD/ES file
|
|
70
|
+
* or CSS breaks consumers silently (Vite 7 names the CSS after the package
|
|
71
|
+
* unless kmwc.library.cssFileName says otherwise). Pure.
|
|
72
|
+
*/
|
|
73
|
+
export function getLibraryOutputMismatches(packageJson, library) {
|
|
74
|
+
const outputPaths = new Set(getLibraryOutputPaths(library));
|
|
75
|
+
// Types emitted by the repo's own step (`tsc`) aren't the preset's to know.
|
|
76
|
+
const isOwnTypes = (target) => !library.shouldEmitTypes && target.endsWith('.d.ts');
|
|
77
|
+
return getPackageDistTargets(packageJson).filter(({ target }) => !outputPaths.has(target) && !target.endsWith('.map') && !isOwnTypes(target));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A hand-written vite.config's `lib.name` (the UMD global) — migration check: the
|
|
82
|
+
* preset derives the global from kmwc.library.globalName. null when not found.
|
|
83
|
+
*/
|
|
84
|
+
export function getLegacyGlobalName(viteConfigText) {
|
|
85
|
+
const libraryBlock = viteConfigText.match(/\blib\s*:\s*\{([\s\S]*?)\}/);
|
|
86
|
+
return libraryBlock?.[1].match(/\bname\s*:\s*['"]([A-Za-z_$][\w$]*)['"]/)?.[1] || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── ports ────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/** [first, last] port of a registry block (blocks are numbered by 100). */
|
|
92
|
+
export function getPortBlockRange(portBlock) {
|
|
93
|
+
return [portBlock, portBlock + PORT_BLOCK_SIZE - 1];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const checkIsInRange = (port, [firstPort, lastPort]) => port >= firstPort && port <= lastPort;
|
|
97
|
+
|
|
98
|
+
/** Scripts that pin a `--port` different from kmwc.ports → [{ scriptName, port, expectedPort }]. Pure. */
|
|
99
|
+
export function getScriptPortMismatches(scripts = {}, ports) {
|
|
100
|
+
return Object.entries(PORTED_SCRIPTS).flatMap(([scriptName, portName]) => {
|
|
101
|
+
const port = Number(String(scripts[scriptName] || '').match(/--port[=\s]+(\d+)/)?.[1]);
|
|
102
|
+
return port && port !== ports[portName] ? [{ scriptName, port, expectedPort: ports[portName] }] : [];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Ports a text names as `localhost:N`, `--port N`, `port: N`, `port N` or `PORT ?= N`. Pure. */
|
|
107
|
+
export function getMentionedPorts(text) {
|
|
108
|
+
const patterns = [/localhost:(\d{4,5})\b/g, /--port[=\s]+(\d{4,5})\b/g, /\bport\s*[:=]\s*(\d{4,5})\b/gi, /\bport\s+(\d{4,5})\b/gi, /PORT\s*\??=\s*(\d{4,5})\b/g];
|
|
109
|
+
return getUniqueSorted(patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => Number(match[1]))));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Mentions in README.md / CLAUDE.md / docs/*.md of ports outside the repo's block → [{ file, port }]. */
|
|
113
|
+
export function loadStalePortMentions(root, portBlock) {
|
|
114
|
+
const blockRange = getPortBlockRange(portBlock);
|
|
115
|
+
const docsDir = join(root, DOCS_DIR);
|
|
116
|
+
const docFiles = [...DOC_FILES, ...(existsSync(docsDir) ? readdirSync(docsDir).filter((name) => name.endsWith('.md')).map((name) => `${DOCS_DIR}/${name}`) : [])];
|
|
117
|
+
return docFiles.flatMap((file) => {
|
|
118
|
+
const text = readText(join(root, file));
|
|
119
|
+
return text ? getMentionedPorts(text).filter((port) => !checkIsInRange(port, blockRange)).map((port) => ({ file, port })) : [];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ports a repo listens on: kmwc.ports (+ preview), script --port flags, ports in its vite / playwright configs and Makefile. */
|
|
124
|
+
export function loadRepoPorts(repoRoot) {
|
|
125
|
+
const repoPorts = [];
|
|
126
|
+
const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
|
|
127
|
+
const kmwcPorts = packageJson.kmwc?.ports || {};
|
|
128
|
+
if (kmwcPorts.dev) repoPorts.push({ port: kmwcPorts.dev, file: 'package.json#kmwc.ports.dev' }, { port: kmwcPorts.dev + 1, file: 'package.json#kmwc.ports (preview)' });
|
|
129
|
+
if (kmwcPorts.image) repoPorts.push({ port: kmwcPorts.image, file: 'package.json#kmwc.ports.image' });
|
|
130
|
+
for (const [scriptName, command] of Object.entries(packageJson.scripts || {})) {
|
|
131
|
+
for (const port of getMentionedPorts(command)) repoPorts.push({ port, file: `package.json#scripts.${scriptName}` });
|
|
132
|
+
}
|
|
133
|
+
for (const file of SIBLING_PORT_FILES) {
|
|
134
|
+
const text = readText(join(repoRoot, file));
|
|
135
|
+
if (text) for (const port of getMentionedPorts(text)) repoPorts.push({ port, file });
|
|
136
|
+
}
|
|
137
|
+
return repoPorts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A sibling's checkout, resolved next to this repo. Registry paths are relative
|
|
142
|
+
* to the devkit checkout, which sits beside the components — so `../<id>` works
|
|
143
|
+
* from any component root. Entries inside the devkit (`.`, `playground`) and the
|
|
144
|
+
* repo itself are not siblings.
|
|
145
|
+
*/
|
|
146
|
+
function getSiblingRoot(root, registryEntry) {
|
|
147
|
+
if (!registryEntry.path?.startsWith('../')) return null;
|
|
148
|
+
const siblingRoot = resolve(root, registryEntry.path);
|
|
149
|
+
return siblingRoot === resolve(root) ? null : siblingRoot;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Sibling repos listening on a port inside this repo's block → [{ id, port, file }]. */
|
|
153
|
+
export function loadSiblingPortCollisions(config, registry) {
|
|
154
|
+
const ownEntry = registry.components.find((entry) => entry.id === config.id);
|
|
155
|
+
if (!ownEntry?.port) return [];
|
|
156
|
+
const blockRange = getPortBlockRange(ownEntry.port);
|
|
157
|
+
return registry.components
|
|
158
|
+
.filter((entry) => entry.id !== config.id)
|
|
159
|
+
.flatMap((entry) => {
|
|
160
|
+
const siblingRoot = getSiblingRoot(config.root, entry);
|
|
161
|
+
if (!siblingRoot || !existsSync(join(siblingRoot, 'package.json'))) return [];
|
|
162
|
+
return loadRepoPorts(siblingRoot)
|
|
163
|
+
.filter(({ port }) => checkIsInRange(port, blockRange))
|
|
164
|
+
.map(({ port, file }) => ({ id: entry.id, port, file }));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** This repo's own configs naming a port outside its block (a hand-written vite.config before migration). */
|
|
169
|
+
export function loadOwnPortsOutsideBlock(config, portBlock) {
|
|
170
|
+
const blockRange = getPortBlockRange(portBlock);
|
|
171
|
+
return loadRepoPorts(config.root).filter(({ port, file }) => !checkIsInRange(port, blockRange) && !file.startsWith('package.json#kmwc'));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── analytics ────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/** A repo's Plausible site id: kmwc.analytics.plausibleSiteId, else the legacy snippet's `pa-…` script. */
|
|
177
|
+
export function loadPlausibleSiteId(repoRoot) {
|
|
178
|
+
const packageJson = JSON.parse(readText(join(repoRoot, 'package.json')) || '{}');
|
|
179
|
+
if (packageJson.kmwc?.analytics?.plausibleSiteId) return packageJson.kmwc.analytics.plausibleSiteId;
|
|
180
|
+
return readText(join(repoRoot, PLAUSIBLE_SNIPPET_FILE))?.match(/\/js\/(pa-[\w-]+)\.js/)?.[1] || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Siblings using the same Plausible site id (their traffic would be counted as this site's) → [id]. */
|
|
184
|
+
export function loadSharedPlausibleSiteIds(config, registry, siteId) {
|
|
185
|
+
if (!siteId) return [];
|
|
186
|
+
return registry.components
|
|
187
|
+
.filter((entry) => entry.id !== config.id)
|
|
188
|
+
.filter((entry) => {
|
|
189
|
+
const siblingRoot = getSiblingRoot(config.root, entry);
|
|
190
|
+
return siblingRoot && existsSync(join(siblingRoot, 'package.json')) && loadPlausibleSiteId(siblingRoot) === siteId;
|
|
191
|
+
})
|
|
192
|
+
.map((entry) => entry.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── page CSS the kit already covers ──────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/** Page-local selectors with a kit replacement (key → advice). */
|
|
198
|
+
export const KIT_REPLACED_SELECTORS = {
|
|
199
|
+
'.kbd': 'use <kbd> — the kit styles it',
|
|
200
|
+
kbd: 'the kit styles <kbd>',
|
|
201
|
+
mark: 'the kit styles <mark>',
|
|
202
|
+
'.mt-1': 'the kit has .mt-1',
|
|
203
|
+
'.push-end': 'the kit has .push-end',
|
|
204
|
+
'.mono': 'the kit has .mono',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The kit's page vocabulary — every unprefixed class its stylesheets define. */
|
|
208
|
+
export function loadKitClassNames() {
|
|
209
|
+
const cssDir = join(DEVKIT_ROOT, 'src', 'examples', 'css');
|
|
210
|
+
const cssText = readdirSync(cssDir).map((name) => readFileSync(join(cssDir, name), 'utf8')).join('\n');
|
|
211
|
+
return new Set([...cssText.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/\.([a-z][a-z0-9-]*)/g)].map((match) => match[1]).filter((name) => !name.startsWith('kmwc')));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Top-level selectors of a stylesheet (one per comma part, comments and at-rule wrappers ignored). Pure. */
|
|
215
|
+
export function getRuleSelectors(cssText) {
|
|
216
|
+
const plainText = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
217
|
+
return [...plainText.matchAll(/(^|[{};])\s*([^{};@]+?)\s*\{/g)].flatMap((match) => match[2].split(',').map((part) => part.trim())).filter(Boolean);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Map an inline width to the nearest kit width helper (4 / 6 / 9 / 12rem). */
|
|
221
|
+
function getWidthClass(value, unit) {
|
|
222
|
+
const rem = unit === 'px' ? value / 16 : value;
|
|
223
|
+
return rem <= 5 ? 'w-xs' : rem <= 7.5 ? 'w-sm' : rem <= 10.5 ? 'w-md' : 'w-lg';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* What in one page (or examples-local.css) re-implements the kit: rules on a
|
|
228
|
+
* kit-owned selector and single-declaration inline styles a kit class covers.
|
|
229
|
+
* Returns [{ found, advice }]. Pure — `kitClassNames` from loadKitClassNames().
|
|
230
|
+
*/
|
|
231
|
+
export function getKitDuplicates(pageText, kitClassNames, { isStylesheet = false } = {}) {
|
|
232
|
+
const duplicates = [];
|
|
233
|
+
const styleText = isStylesheet ? pageText : [...pageText.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]).join('\n');
|
|
234
|
+
for (const selector of getUniqueSorted(getRuleSelectors(styleText))) {
|
|
235
|
+
if (KIT_REPLACED_SELECTORS[selector]) duplicates.push({ found: `${selector} { … }`, advice: KIT_REPLACED_SELECTORS[selector] });
|
|
236
|
+
else if (/^\.[a-z][a-z0-9-]*$/.test(selector) && kitClassNames.has(selector.slice(1))) {
|
|
237
|
+
duplicates.push({ found: `${selector} { … }`, advice: `restyles the kit's ${selector} for this page — scope it (e.g. .my-demo ${selector}) or drop it` });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (isStylesheet) return duplicates;
|
|
241
|
+
for (const match of pageText.matchAll(/<(\w+)\b[^>]*\sstyle="\s*([^";]+?)\s*;?\s*"/g)) {
|
|
242
|
+
const [, tagName, declaration] = match;
|
|
243
|
+
const width = declaration.match(/^width\s*:\s*(\d+(?:\.\d+)?)(rem|px)$/i);
|
|
244
|
+
if (width && /^(input|select)$/i.test(tagName)) duplicates.push({ found: `<${tagName} style="${declaration}">`, advice: `class="${getWidthClass(Number(width[1]), width[2])}"` });
|
|
245
|
+
else if (/^margin-(left|inline-start)\s*:\s*auto$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="push-end"' });
|
|
246
|
+
else if (/^margin-top\s*:\s*1rem$/i.test(declaration)) duplicates.push({ found: `style="${declaration}"`, advice: 'class="mt-1"' });
|
|
247
|
+
}
|
|
248
|
+
return duplicates;
|
|
249
|
+
}
|
package/src/cli/sync-plan.js
CHANGED
|
@@ -69,14 +69,14 @@ const hasAnyFile = (root, fileNames) => fileNames.some((fileName) => existsSync(
|
|
|
69
69
|
export function getStandardScripts(config) {
|
|
70
70
|
const scripts = {
|
|
71
71
|
dev: 'vite',
|
|
72
|
-
build: 'vite build',
|
|
72
|
+
...(config.library.shouldBuild ? { build: 'vite build' } : {}),
|
|
73
73
|
'build:examples': 'vite build --mode examples',
|
|
74
74
|
'preview:examples': 'vite preview --mode examples',
|
|
75
75
|
'clean:dist': generateRemoveCommand(['dist']),
|
|
76
76
|
clean: generateRemoveCommand(['dist', config.examples.outDir]),
|
|
77
77
|
doctor: 'kmwc doctor',
|
|
78
78
|
};
|
|
79
|
-
if (existsSync(join(config.root, config.cssVariables.manifest))) scripts['check:vars'] = 'kmwc check-vars';
|
|
79
|
+
if (config.cssVariables.isEnabled && existsSync(join(config.root, config.cssVariables.manifest))) scripts['check:vars'] = 'kmwc check-vars';
|
|
80
80
|
if (existsSync(join(config.root, CEM_CONFIG_FILE))) scripts.analyze = 'cem analyze';
|
|
81
81
|
if (hasAnyFile(config.root, PLAYWRIGHT_CONFIG_FILES)) {
|
|
82
82
|
Object.assign(scripts, {
|
|
@@ -84,6 +84,7 @@ export function getStandardScripts(config) {
|
|
|
84
84
|
'test:e2e:ui': 'playwright test --ui',
|
|
85
85
|
'test:e2e:headed': 'playwright test --headed',
|
|
86
86
|
'test:e2e:install': 'playwright install chromium',
|
|
87
|
+
'check:pages': 'kmwc check-pages',
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
return scripts;
|
package/src/config.d.ts
CHANGED
|
@@ -10,7 +10,12 @@ export interface KmwcPackageConfig {
|
|
|
10
10
|
tag?: string;
|
|
11
11
|
/** Reserved CSS prefix (base-variables.md "Existing prefixes"), e.g. `ms`. */
|
|
12
12
|
prefix?: string;
|
|
13
|
-
library
|
|
13
|
+
/** Registry kind; defaults to the components.json entry's kind (else `component`). A `library` runs only the doctor checks that apply to it. */
|
|
14
|
+
kind?: 'component' | 'library' | 'showcase' | 'docs' | 'test';
|
|
15
|
+
/** The library build (`vite build`). `false` = the repo builds its package itself or ships none: plain `vite build` refuses to run, only `--mode examples` builds. */
|
|
16
|
+
library?: false | {
|
|
17
|
+
/** `false` = same as `library: false`, keeping `entry` for the example pages. */
|
|
18
|
+
shouldBuild?: boolean;
|
|
14
19
|
entry?: string;
|
|
15
20
|
/** UMD global name. */
|
|
16
21
|
globalName?: string;
|
|
@@ -51,14 +56,15 @@ export interface KmwcPackageConfig {
|
|
|
51
56
|
packageJsonPaths?: string[];
|
|
52
57
|
installCommand?: string;
|
|
53
58
|
};
|
|
54
|
-
|
|
59
|
+
/** `false` (or `checkMode: 'off'`): no variables manifest check — sync adds no `check:vars`, `kmwc check-vars` skips. */
|
|
60
|
+
cssVariables?: false | {
|
|
55
61
|
manifest?: string;
|
|
56
62
|
/** Directories (stylesheets scanned) or explicit files (any type, e.g. a built bundle). */
|
|
57
63
|
sources?: string[];
|
|
58
64
|
/** Prefixed names (without `--`) that are runtime internals, not theming knobs. */
|
|
59
65
|
internalVariables?: string[];
|
|
60
66
|
/** `declared`: manifest ⇔ `--x:` declarations; `consumed`: manifest ⇔ `var(--x)` reads. */
|
|
61
|
-
checkMode?: 'declared' | 'consumed';
|
|
67
|
+
checkMode?: 'declared' | 'consumed' | 'off';
|
|
62
68
|
};
|
|
63
69
|
rulebook?: {
|
|
64
70
|
/** Path to BlissFramework `guidelines/web-components` (env KMWC_RULEBOOK_PATH wins). */
|
|
@@ -75,6 +81,7 @@ export interface KmwcConfig {
|
|
|
75
81
|
packageJson: Record<string, any>;
|
|
76
82
|
id: string;
|
|
77
83
|
packageName: string;
|
|
84
|
+
kind: string;
|
|
78
85
|
version: string;
|
|
79
86
|
title: string;
|
|
80
87
|
tagline: string;
|
|
@@ -85,6 +92,7 @@ export interface KmwcConfig {
|
|
|
85
92
|
author: string;
|
|
86
93
|
license: string;
|
|
87
94
|
library: {
|
|
95
|
+
shouldBuild: boolean;
|
|
88
96
|
entry: string;
|
|
89
97
|
globalName: string;
|
|
90
98
|
fileName: string;
|
|
@@ -118,7 +126,8 @@ export interface KmwcConfig {
|
|
|
118
126
|
manifest: string;
|
|
119
127
|
sources: string[];
|
|
120
128
|
internalVariables: string[];
|
|
121
|
-
checkMode: 'declared' | 'consumed';
|
|
129
|
+
checkMode: 'declared' | 'consumed' | 'off';
|
|
130
|
+
isEnabled: boolean;
|
|
122
131
|
};
|
|
123
132
|
rulebook: { path: string; skippedChecks: string[] };
|
|
124
133
|
sync: { skippedFiles: string[] };
|
|
@@ -137,6 +146,7 @@ export const PORT_RANGES: Array<{ kinds: string[]; firstBlock: number; lastBlock
|
|
|
137
146
|
export const PREVIEW_PORT_OFFSET: number;
|
|
138
147
|
export const IMAGE_PORT_OFFSET: number;
|
|
139
148
|
export function getPortsForBlock(portBlock: number): { dev: number; preview: number; image: number };
|
|
149
|
+
export function getRegistryKind(id: string, registry?: { components: Array<{ id: string; kind: string }> }): string;
|
|
140
150
|
export function getPortRange(kind: string): { kinds: string[]; firstBlock: number; lastBlock: number };
|
|
141
151
|
export function getNextPortBlock(registry?: { components: Array<Record<string, any>> }, kind?: string): number;
|
|
142
152
|
export function getPeerModule(root: string, moduleId: string): any;
|
package/src/config.js
CHANGED
|
@@ -139,6 +139,11 @@ export function getRegistry() {
|
|
|
139
139
|
return getJsonFile(join(DEVKIT_ROOT, 'components.json'));
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/** The registry kind of `id` (`component`, `library` …); `component` when it isn't registered. */
|
|
143
|
+
export function getRegistryKind(id, registry = getRegistry()) {
|
|
144
|
+
return registry.components.find((entry) => entry.id === id)?.kind || 'component';
|
|
145
|
+
}
|
|
146
|
+
|
|
142
147
|
/** Strip the npm scope: `@keenmate/web-multiselect` → `web-multiselect`. */
|
|
143
148
|
export function getUnscopedName(packageName) {
|
|
144
149
|
return String(packageName || '').replace(/^@[^/]+\//, '');
|
|
@@ -182,13 +187,16 @@ export function getKmwcConfig(root = process.cwd()) {
|
|
|
182
187
|
export function mapKmwcConfig(packageJson, root = process.cwd()) {
|
|
183
188
|
const packageConfig = packageJson.kmwc || {};
|
|
184
189
|
const id = packageConfig.id || getUnscopedName(packageJson.name) || 'component';
|
|
185
|
-
|
|
190
|
+
// `library: false` — the repo builds its package itself (tsc …) or ships none;
|
|
191
|
+
// Vite serves and builds only the examples site.
|
|
192
|
+
const library = packageConfig.library === false ? { shouldBuild: false } : packageConfig.library || {};
|
|
186
193
|
const examples = packageConfig.examples || {};
|
|
187
194
|
const ports = packageConfig.ports || {};
|
|
188
195
|
const image = packageConfig.image || {};
|
|
189
196
|
const theme = packageConfig.theme || {};
|
|
190
197
|
const analytics = packageConfig.analytics || {};
|
|
191
|
-
|
|
198
|
+
// `cssVariables: false` — no variables manifest to check (no check:vars script).
|
|
199
|
+
const cssVariables = packageConfig.cssVariables === false ? { checkMode: 'off' } : packageConfig.cssVariables || {};
|
|
192
200
|
const rulebook = packageConfig.rulebook || {};
|
|
193
201
|
const devPort = ports.dev ?? 5173;
|
|
194
202
|
|
|
@@ -198,6 +206,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
|
|
|
198
206
|
packageJson,
|
|
199
207
|
id,
|
|
200
208
|
packageName: packageJson.name || id,
|
|
209
|
+
kind: packageConfig.kind || getRegistryKind(id),
|
|
201
210
|
version: packageJson.version || '0.1.0',
|
|
202
211
|
title: packageConfig.title || mapToTitleCase(id),
|
|
203
212
|
tagline: packageConfig.tagline || packageJson.description || '',
|
|
@@ -208,6 +217,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
|
|
|
208
217
|
author: typeof packageJson.author === 'string' ? packageJson.author : packageJson.author?.name || 'Keenmate s.r.o.',
|
|
209
218
|
license: packageJson.license || 'MIT',
|
|
210
219
|
library: {
|
|
220
|
+
shouldBuild: library.shouldBuild ?? true,
|
|
211
221
|
entry: library.entry || 'src/index.ts',
|
|
212
222
|
globalName: library.globalName || mapToPascalCase(id),
|
|
213
223
|
fileName: library.fileName || id,
|
|
@@ -254,6 +264,7 @@ export function mapKmwcConfig(packageJson, root = process.cwd()) {
|
|
|
254
264
|
sources: cssVariables.sources || ['src/css'],
|
|
255
265
|
internalVariables: cssVariables.internalVariables || [],
|
|
256
266
|
checkMode: cssVariables.checkMode || 'declared',
|
|
267
|
+
isEnabled: cssVariables.checkMode !== 'off',
|
|
257
268
|
},
|
|
258
269
|
rulebook: {
|
|
259
270
|
path:
|
|
@@ -161,6 +161,15 @@
|
|
|
161
161
|
margin-bottom: 0.75rem;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/* A heading naming an API (`EV05 · getMonthDataCallback & beforeMonthChangeCallback`)
|
|
165
|
+
has no break opportunity — let it break rather than widen the page on a phone. */
|
|
166
|
+
h1,
|
|
167
|
+
h2,
|
|
168
|
+
h3,
|
|
169
|
+
h4 {
|
|
170
|
+
overflow-wrap: anywhere;
|
|
171
|
+
}
|
|
172
|
+
|
|
164
173
|
ul {
|
|
165
174
|
line-height: 1.8;
|
|
166
175
|
}
|
|
@@ -207,6 +216,25 @@
|
|
|
207
216
|
}
|
|
208
217
|
}
|
|
209
218
|
|
|
219
|
+
/* A small phone (320–400px): a component with a fixed minimum width — an inline
|
|
220
|
+
calendar is ~250px — still fits inside a card. */
|
|
221
|
+
@media (max-width: 400px) {
|
|
222
|
+
body {
|
|
223
|
+
padding: 0.75rem;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.card,
|
|
227
|
+
.example-section {
|
|
228
|
+
padding: 1rem;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Boxes nested in a card (a demo card holding a demo area) add up. */
|
|
232
|
+
.demo-card,
|
|
233
|
+
.demo-area {
|
|
234
|
+
padding: 0.75rem;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
210
238
|
@media (prefers-reduced-motion: reduce) {
|
|
211
239
|
html {
|
|
212
240
|
scroll-behavior: auto;
|
|
@@ -120,6 +120,9 @@
|
|
|
120
120
|
font-size: 0.9em;
|
|
121
121
|
color: var(--kmwc-accent-color);
|
|
122
122
|
font-family: var(--kmwc-mono-font-family);
|
|
123
|
+
/* A long identifier has no break opportunity — break it anywhere
|
|
124
|
+
rather than widen the page on a phone. */
|
|
125
|
+
overflow-wrap: anywhere;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
pre code,
|
|
@@ -130,6 +133,35 @@
|
|
|
130
133
|
border-radius: 0;
|
|
131
134
|
color: inherit;
|
|
132
135
|
font-size: inherit;
|
|
136
|
+
overflow-wrap: inherit;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* A key in a keyboard map — the spotlight's key-hint look. */
|
|
140
|
+
kbd {
|
|
141
|
+
display: inline-block;
|
|
142
|
+
padding: 0.1rem 0.35rem;
|
|
143
|
+
background-color: var(--kmwc-elevated-bg);
|
|
144
|
+
color: var(--kmwc-text-color-1);
|
|
145
|
+
border: 1px solid var(--kmwc-border-color);
|
|
146
|
+
border-bottom-width: 2px;
|
|
147
|
+
border-radius: var(--kmwc-radius-sm);
|
|
148
|
+
font-family: var(--kmwc-font-family);
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
font-size: 0.8em;
|
|
151
|
+
line-height: 1.2;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* A search match — the spotlight's match highlight. */
|
|
156
|
+
mark {
|
|
157
|
+
background-color: var(--kmwc-accent-light-bg-hover);
|
|
158
|
+
color: var(--kmwc-accent-color);
|
|
159
|
+
font-weight: 600;
|
|
160
|
+
/* No padding: a match inside a word ("Town|send") must not split it. */
|
|
161
|
+
padding: 0;
|
|
162
|
+
border-radius: var(--kmwc-radius-sm);
|
|
163
|
+
box-decoration-break: clone;
|
|
164
|
+
-webkit-box-decoration-break: clone;
|
|
133
165
|
}
|
|
134
166
|
|
|
135
167
|
/* ==========================================================================
|
|
@@ -228,6 +260,18 @@
|
|
|
228
260
|
margin-top: 1rem;
|
|
229
261
|
}
|
|
230
262
|
|
|
263
|
+
/* Push an item to the end of a flex row (`.controls`, `.btn-group` …). */
|
|
264
|
+
.push-end {
|
|
265
|
+
margin-inline-start: auto;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* Field widths: a native field is ~20 characters wide; these size a short
|
|
269
|
+
one (a page size, a caption) without an inline style. */
|
|
270
|
+
.w-xs { width: 4rem; max-width: 100%; }
|
|
271
|
+
.w-sm { width: 6rem; max-width: 100%; }
|
|
272
|
+
.w-md { width: 9rem; max-width: 100%; }
|
|
273
|
+
.w-lg { width: 12rem; max-width: 100%; }
|
|
274
|
+
|
|
231
275
|
@media (max-width: 768px) {
|
|
232
276
|
.grid,
|
|
233
277
|
.grid-2,
|
|
@@ -74,7 +74,11 @@
|
|
|
74
74
|
cursor: not-allowed;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/* A number is short: 7rem holds six digits and the spin buttons (a
|
|
78
|
+
.form-group still stretches it to the row; .w-* sizes it). */
|
|
77
79
|
:where(input[type="number"]):not(:where(.kmwc-native, .kmwc-native *)) {
|
|
80
|
+
width: 7rem;
|
|
81
|
+
max-width: 100%;
|
|
78
82
|
padding-inline-end: 0.25rem; /* room for the spin buttons */
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
--kmwc-log-info-color: var(--kmwc-info-color);
|
|
30
30
|
--kmwc-log-warn-color: var(--kmwc-warning-color);
|
|
31
31
|
--kmwc-log-err-color: var(--kmwc-danger-color);
|
|
32
|
-
|
|
32
|
+
/* text-color-3 is 2.8:1 on the light panel — under WCAG AA's 4.5. */
|
|
33
|
+
--kmwc-log-muted-color: var(--kmwc-text-color-2);
|
|
33
34
|
--kmwc-log-name-color: var(--kmwc-accent-color);
|
|
34
35
|
--kmwc-log-rule-color: var(--kmwc-border-color);
|
|
35
36
|
font-family: var(--kmwc-mono-font-family);
|
|
@@ -94,7 +95,9 @@
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/* Timeline: a line with cells is a row; the cells keep their width and the
|
|
97
|
-
detail after them wraps in its own column.
|
|
98
|
+
detail after them wraps in its own column. When the cells leave the detail
|
|
99
|
+
less than ~14rem (a phone, a long source name) it drops under them at full
|
|
100
|
+
width instead of being squeezed to a letter per line. */
|
|
98
101
|
.log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)),
|
|
99
102
|
.event-entry:has(> :is(.event-time, .event-name)) {
|
|
100
103
|
display: flex;
|
|
@@ -103,8 +106,12 @@
|
|
|
103
106
|
border-bottom: 1px solid var(--kmwc-log-rule-color);
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
.log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)) {
|
|
110
|
+
flex-wrap: wrap;
|
|
111
|
+
}
|
|
112
|
+
|
|
106
113
|
.log-line:has(> :is(.log-line__seq, .log-line__time, .log-line__source, .log-line__name)) > :last-child:not(.log-line__name) {
|
|
107
|
-
flex: 1 1
|
|
114
|
+
flex: 1 1 14rem;
|
|
108
115
|
min-width: 0;
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -115,6 +122,7 @@
|
|
|
115
122
|
.event-time,
|
|
116
123
|
.event-name {
|
|
117
124
|
flex: none;
|
|
125
|
+
max-width: 100%;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
.log-line__seq,
|
|
@@ -179,7 +187,7 @@
|
|
|
179
187
|
display: flex;
|
|
180
188
|
flex-wrap: wrap;
|
|
181
189
|
gap: 0.4rem;
|
|
182
|
-
margin: 0.
|
|
190
|
+
margin: 0.75rem 0 1rem; /* it follows the demo it measures */
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
.css-readout code { font-size: 0.78rem; }
|
package/src/vite/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { readdirSync, existsSync, cpSync } from 'node:fs';
|
|
|
20
20
|
import { join, resolve } from 'node:path';
|
|
21
21
|
import { getKmwcConfig, getDevkitPackageJson, generateBuildDefines, loadPeerModule, DEVKIT_ROOT, NON_PAGE_HTML_FILES } from '../config.js';
|
|
22
22
|
import { renderThemeBootstrapScript } from '../examples/theme-switch.js';
|
|
23
|
-
import { composePage } from './page-composer.js';
|
|
23
|
+
import { composePage, insertIntoHead, insertBeforeBodyEnd } from './page-composer.js';
|
|
24
24
|
import { generateChapterIndex } from './chapter-index.js';
|
|
25
25
|
|
|
26
26
|
const AUTO_ENTRY_PATH = join(DEVKIT_ROOT, 'src', 'examples', 'auto.js');
|
|
@@ -162,11 +162,7 @@ export function createExamplesPlugin(pluginConfig = {}) {
|
|
|
162
162
|
const kitImports = isBuild ? `import '${KIT_STYLESHEET_PATH}'; import '${VIRTUAL_AUTO_ID}';` : `import '${VIRTUAL_AUTO_ID}';`;
|
|
163
163
|
const bodyHtml = `<script type="module">${kitImports}</script>\n`;
|
|
164
164
|
|
|
165
|
-
|
|
166
|
-
? html.replace(/<head[^>]*>/i, (headTag) => `${headTag}\n${headHtml}`)
|
|
167
|
-
: headHtml + html;
|
|
168
|
-
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, `${bodyHtml}</body>`) : html + bodyHtml;
|
|
169
|
-
return html;
|
|
165
|
+
return insertBeforeBodyEnd(insertIntoHead(html, headHtml), bodyHtml);
|
|
170
166
|
},
|
|
171
167
|
},
|
|
172
168
|
|
|
@@ -199,9 +195,16 @@ export function defineComponentConfig(presetConfig = {}) {
|
|
|
199
195
|
const config = getKmwcConfig(presetConfig.root);
|
|
200
196
|
// The component's own vite — the one running this config (see loadPeerModule).
|
|
201
197
|
const { mergeConfig, searchForWorkspaceRoot } = await loadPeerModule(config.root, 'vite');
|
|
202
|
-
const library = { ...config.library, ...(presetConfig.library || {}) };
|
|
198
|
+
const library = { ...config.library, ...(presetConfig.library === false ? { shouldBuild: false } : presetConfig.library || {}) };
|
|
203
199
|
const isExamplesMode = env.mode === EXAMPLES_MODE;
|
|
204
200
|
const isLibraryBuild = env.command === 'build' && !isExamplesMode;
|
|
201
|
+
if (isLibraryBuild && !library.shouldBuild) {
|
|
202
|
+
// The library build empties dist/ — it would wipe a package the repo builds itself.
|
|
203
|
+
throw new Error(
|
|
204
|
+
`${config.id}: kmwc.library is false — plain \`vite build\` has nothing to build. ` +
|
|
205
|
+
'Build the package with its own script (npm run build), the examples site with `vite build --mode examples`.'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
205
208
|
|
|
206
209
|
const plugins = [createExamplesPlugin({ root: config.root }), ...(presetConfig.plugins || [])];
|
|
207
210
|
|
|
@@ -64,7 +64,36 @@ export function mapOutsideComments(html, mapper, skippedPattern = HTML_COMMENT_P
|
|
|
64
64
|
return mappedHtml + mapper(html.slice(lastIndex));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// The real tag only — `<head[^>]*>` also matches `<header>`.
|
|
68
|
+
const HEAD_TAG_PATTERN = /<head(?=[\s>])[^>]*>/i;
|
|
69
|
+
const BODY_END_TAG = '</body>';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Insert `headHtml` right after the page's <head> tag (prepend when there is none).
|
|
73
|
+
* The first <head> outside comments is the real one: the text "<head>" in a tooltip
|
|
74
|
+
* or a script comment comes later, and a snippet landing there breaks the page
|
|
75
|
+
* (an HTML comment inside a module script throws).
|
|
76
|
+
*/
|
|
77
|
+
export function insertIntoHead(html, headHtml) {
|
|
78
|
+
let isInserted = false;
|
|
79
|
+
const insertedHtml = mapOutsideComments(html, (text) => {
|
|
80
|
+
if (isInserted || !HEAD_TAG_PATTERN.test(text)) return text;
|
|
81
|
+
isInserted = true;
|
|
82
|
+
return text.replace(HEAD_TAG_PATTERN, (headTag) => `${headTag}\n${headHtml}`);
|
|
83
|
+
});
|
|
84
|
+
return isInserted ? insertedHtml : headHtml + html;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Insert `bodyHtml` before the LAST </body> (append when there is none): an
|
|
89
|
+
* earlier one is text — e.g. a comment inside an inline script.
|
|
90
|
+
*/
|
|
91
|
+
export function insertBeforeBodyEnd(html, bodyHtml) {
|
|
92
|
+
const bodyEndIndex = html.toLowerCase().lastIndexOf(BODY_END_TAG);
|
|
93
|
+
return bodyEndIndex < 0 ? html + bodyHtml : html.slice(0, bodyEndIndex) + bodyHtml + html.slice(bodyEndIndex);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const mapToCamelCase =(kebabName) => kebabName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
68
97
|
|
|
69
98
|
const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
70
99
|
const mapToEscapedHtml = (text) => String(text).replace(/[&<>"]/g, (character) => HTML_ESCAPES[character]);
|