@monoharada/wcf-mcp 0.2.0 → 0.5.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.
Files changed (4) hide show
  1. package/README.md +51 -1
  2. package/core.mjs +332 -41
  3. package/package.json +1 -1
  4. package/validator.mjs +149 -7
package/README.md CHANGED
@@ -100,7 +100,24 @@ claude mcp add wcf -- npx @monoharada/wcf-mcp
100
100
 
101
101
  | ツール | 説明 |
102
102
  |--------|------|
103
- | `validate_markup` | HTML スニペットを検証し、未知要素・不正enum値・不正スロット名・必須属性欠落(error)、未知属性・トークン誤用・`aria-live`/`role="alert"` 誤用・親子関係違反・空インタラクティブ要素(warning)を検出し、`suggestion` を返す |
103
+ | `validate_markup` | HTML スニペットを検証し、セマンティック検証(下表)で `suggestion` 付き診断を返す |
104
+
105
+ #### validate_markup 検出コード一覧
106
+
107
+ | Code | Severity | 説明 | 例 |
108
+ |------|----------|------|----|
109
+ | `unknownElement` | error | CEM に未登録のカスタム要素。prefix 補完提案あり | `<input-text>` → `dads-input-text` |
110
+ | `unknownAttribute` | warning | CEM に未登録の属性 | `<dads-button foo="x">` |
111
+ | `invalidEnumValue` | error | enum 属性に不正な値 | `type="banana"` |
112
+ | `invalidSlotName` | error | CEM に未登録のスロット名 | `slot="nonexistent"` |
113
+ | `missingRequiredAttribute` | error | フォーム要素の必須属性欠落 | `<dads-input-text>` without `label` |
114
+ | `orphanedChildComponent` | warning | 親要素なしの子コンポーネント | `<dads-breadcrumb-item>` without `<dads-breadcrumb>` |
115
+ | `emptyInteractiveElement` | warning | accessible name が空の操作要素 | `<dads-button></dads-button>` |
116
+ | `canonicalLowercaseRecommendation` | warning | 大文字を含む属性名(lowercase が canonical) | `Variant="solid"` → `variant` |
117
+ | `tokenMisuse` | warning | inline style でのトークン誤用 | `color: #000` → `var(--color-*)` |
118
+ | `ariaLiveNotRecommended` | warning | `aria-live` の使用(DADS 非推奨) | `aria-live="polite"` |
119
+ | `roleAlertNotRecommended` | warning | `role="alert"` の使用(DADS 非推奨) | `role="alert"` |
120
+ | `forbiddenAttribute` | warning | 禁止属性 | `placeholder` |
104
121
 
105
122
  ### UI パターン
106
123
 
@@ -355,6 +372,39 @@ Claude Desktop 設定例:
355
372
  prefix: "myui" → dads-button → myui-button
356
373
  ```
357
374
 
375
+ ## v0.3.0 新機能 — ランタイムセットアップ情報
376
+
377
+ v0.3.0 では、AI エージェントが CDN 非対応の vendor-local 配信モデルを正しく理解できるよう、3つのツールにランタイムセットアップ情報を追加しました。
378
+
379
+ ### `get_design_system_overview` — setupInfo 新フィールド
380
+
381
+ | フィールド | 型 | 説明 |
382
+ |-----------|-----|------|
383
+ | `noCDN` | `true` | CDN 配信が利用不可であることを示すフラグ |
384
+ | `deliveryModel` | `"vendor-local"` | 配信モデルの種別(将来拡張可能) |
385
+ | `importMapHint` | `string` | import map のパターン説明 |
386
+ | `bootScript` | `string` | boot.js の役割説明 |
387
+ | `vendorSetup` | `object` | `init`/`add`/`workflow` の2段階セットアップガイド |
388
+ | `htmlSetup` | `string` | import map + boot.js を含む完全な HTML head テンプレート |
389
+
390
+ > 既存の `htmlBoilerplate` は変更なし(後方互換性)
391
+
392
+ ### `get_install_recipe` — 新フィールド
393
+
394
+ | フィールド | 型 | 説明 |
395
+ |-----------|-----|------|
396
+ | `usageContext` | `"body-only"` | `usageSnippet` が body 用 HTML であることを明示 |
397
+ | `vendorHint` | `object` | `install`(CLI コマンド)、`importMap`(テンプレート)、`boot`(ブートスクリプト説明)。`importmap`(小文字 m)は非推奨エイリアス — v1.0 で削除予定 |
398
+
399
+ ### `get_pattern_recipe` — 新フィールド
400
+
401
+ | フィールド | 型 | 説明 |
402
+ |-----------|-----|------|
403
+ | `entryHints` | `string[]` | パターンのエントリポイント(`["boot"]` など) |
404
+ | `scaffoldHint` | `object` | `doctype`、`importMap`、`bootScript`、`noscript`、`serveOverHttp` を含むページ雛形情報 |
405
+
406
+ > `scaffoldHint.serveOverHttp` は `file://` プロトコルでの実行を防止するガイダンスです。
407
+
358
408
  ## v0.2.0 マイグレーション
359
409
 
360
410
  ### `list_components` のデフォルトページネーション変更
package/core.mjs CHANGED
@@ -145,8 +145,97 @@ const SYNONYM_TABLE = new Map([
145
145
  ['skip-navigation', ['skip-link', 'landmark', 'skip nav']],
146
146
  ['heading', ['heading hierarchy', 'h1', 'heading level']],
147
147
  ['form', ['input', 'validation', 'required', 'label']],
148
+ ['layout', ['grid', 'flexbox', 'layout-shell', 'responsive', 'breakpoint']],
149
+ ['responsive', ['media query', 'breakpoint', 'viewport', 'mobile']],
150
+ ['error', ['validation', 'aria-invalid', 'aria-describedby', 'error text']],
151
+ ['focus', ['focus-visible', 'focus ring', 'outline', 'tabindex', 'keyboard']],
152
+ ['token', ['design token', 'css variable', 'custom property', 'spacing token']],
148
153
  ]);
149
154
 
155
+ // Interaction examples for form components (P-04 / #206)
156
+ const INTERACTION_EXAMPLES_MAP = Object.freeze({
157
+ 'dads-input-text': [
158
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "hello";' },
159
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
160
+ { scenario: 'Clear validation error', trigger: 'attribute', code: 'el.error = false; el.errorText = "";' },
161
+ { scenario: 'Listen to value change', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
162
+ ],
163
+ 'dads-textarea': [
164
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "long text...";' },
165
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "入力できる文字数を超えています";' },
166
+ { scenario: 'Listen to input event', trigger: 'event', code: "el.addEventListener('input', (e) => { console.log(e.target.value); });" },
167
+ ],
168
+ 'dads-select': [
169
+ { scenario: 'Set selected value', trigger: 'property', code: 'el.value = "option1";' },
170
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
171
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
172
+ ],
173
+ 'dads-checkbox': [
174
+ { scenario: 'Set checked state', trigger: 'property', code: 'el.checked = true;' },
175
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
176
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.checked); });" },
177
+ ],
178
+ 'dads-radio': [
179
+ { scenario: 'Set checked state', trigger: 'property', code: 'el.checked = true;' },
180
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
181
+ ],
182
+ 'dads-combobox': [
183
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "selected-option";' },
184
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
185
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
186
+ ],
187
+ 'dads-date-picker': [
188
+ { scenario: 'Set date value', trigger: 'property', code: 'el.value = "2024-01-15";' },
189
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
190
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
191
+ ],
192
+ 'dads-file-upload': [
193
+ { scenario: 'Listen to file selection', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.files); });" },
194
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
195
+ ],
196
+ });
197
+
198
+ // Layout behavior metadata for layout/display components (P-05 / #207)
199
+ const LAYOUT_BEHAVIOR_MAP = Object.freeze({
200
+ 'dads-layout-shell': {
201
+ responsive: {
202
+ breakpoints: { desktop: '80rem', tablet: '48rem' },
203
+ modes: ['auto', 'desktop', 'tablet', 'mobile'],
204
+ defaultMode: 'auto',
205
+ description: 'Automatically switches between desktop/tablet/mobile layouts based on viewport width when mode="auto".',
206
+ },
207
+ overflow: {
208
+ strategy: 'slot-driven',
209
+ description: 'Slots (header, sidebar, aside, footer) are auto-hidden when empty. Sidebar collapses to rail on tablet.',
210
+ },
211
+ constraints: {
212
+ patterns: ['website', 'app-shell', 'master-detail', 'left-header-pane', 'three-pane', 'three-pane-shell'],
213
+ defaultPattern: 'app-shell',
214
+ mobileSidebarOptions: ['hidden', 'top', 'bottom'],
215
+ description: 'Choose a pattern attribute to control layout structure. Pair with mode and mobile-sidebar for full control.',
216
+ },
217
+ },
218
+ 'dads-layout-sidebar': {
219
+ responsive: {
220
+ description: 'Designed to be placed inside dads-layout-shell sidebar slot. Width is controlled by the parent shell.',
221
+ },
222
+ constraints: {
223
+ description: 'Simple container for sidebar content. Use inside dads-layout-shell for responsive behavior.',
224
+ },
225
+ },
226
+ 'dads-device-mock': {
227
+ responsive: {
228
+ devices: ['desktop', 'tablet', 'mobile'],
229
+ defaultDevice: 'mobile',
230
+ description: 'Renders a device frame (desktop/tablet/mobile) around slotted content. Set device attribute to switch.',
231
+ },
232
+ constraints: {
233
+ visibleHeight: 'Use visible-height attribute to clip the mock to a specific height (e.g. "220px").',
234
+ description: 'Display-only component for previewing content in device frames. Not a layout container.',
235
+ },
236
+ },
237
+ });
238
+
150
239
  export function expandQueryWithSynonyms(query) {
151
240
  const q = String(query ?? '').toLowerCase().trim();
152
241
  if (!q) return [q];
@@ -752,10 +841,16 @@ export function levenshteinDistance(left, right) {
752
841
  return prev[b.length];
753
842
  }
754
843
 
755
- export function suggestUnknownElementTagName(tagName, cemIndex) {
844
+ export function suggestUnknownElementTagName(tagName, cemIndex, prefix) {
756
845
  const target = String(tagName ?? '').trim().toLowerCase();
757
846
  if (!target || !target.includes('-')) return undefined;
758
847
 
848
+ // Try prefix-prepend before Levenshtein (e.g. input-text → dads-input-text)
849
+ if (prefix && cemIndex instanceof Map) {
850
+ const prefixed = `${String(prefix).toLowerCase()}-${target}`;
851
+ if (cemIndex.has(prefixed)) return prefixed;
852
+ }
853
+
759
854
  let bestTag;
760
855
  let bestDistance = Number.POSITIVE_INFINITY;
761
856
  const candidateSource = cemIndex instanceof Map ? cemIndex.keys() : [];
@@ -775,15 +870,19 @@ export function suggestUnknownElementTagName(tagName, cemIndex) {
775
870
  return bestTag;
776
871
  }
777
872
 
778
- export function buildDiagnosticSuggestion({ diagnostic, cemIndex }) {
873
+ export function buildDiagnosticSuggestion({ diagnostic, cemIndex, prefix }) {
779
874
  const code = String(diagnostic?.code ?? '');
780
875
  if (!code) return undefined;
781
876
 
782
877
  if (code === 'unknownElement') {
783
- const tagName = suggestUnknownElementTagName(diagnostic?.tagName, cemIndex);
878
+ const tagName = suggestUnknownElementTagName(diagnostic?.tagName, cemIndex, prefix);
784
879
  return tagName ? `Did you mean "${tagName}"?` : undefined;
785
880
  }
786
881
 
882
+ if (code === 'canonicalLowercaseRecommendation') {
883
+ return diagnostic?.hint ?? undefined;
884
+ }
885
+
787
886
  if (code === 'forbiddenAttribute' && String(diagnostic?.attrName ?? '').toLowerCase() === 'placeholder') {
788
887
  return 'Use aria-label or aria-describedby support text instead of placeholder.';
789
888
  }
@@ -1621,6 +1720,9 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1621
1720
  detectMissingRequiredAttributes = () => [],
1622
1721
  detectOrphanedChildComponents = () => [],
1623
1722
  detectEmptyInteractiveElement = () => [],
1723
+ detectNonLowercaseAttributes = () => [],
1724
+ detectCdnReferences = () => [],
1725
+ detectMissingRuntimeScaffold = () => [],
1624
1726
  } = await loadValidator();
1625
1727
  const canonicalCemIndex = collectCemCustomElements(manifest);
1626
1728
  const canonicalEnumMap = buildEnumAttributeMap(manifest);
@@ -1653,9 +1755,12 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1653
1755
 
1654
1756
  const tokenSuggestionMap = buildTokenSuggestionMap(designTokensData);
1655
1757
 
1758
+ const VENDOR_DIR = 'vendor-runtime';
1759
+ const PREFIX_STRIP_RE = /^[^-]+-/;
1760
+
1656
1761
  const server = new McpServer({
1657
1762
  name: 'web-components-factory-design-system',
1658
- version: '0.2.0',
1763
+ version: '0.5.0',
1659
1764
  });
1660
1765
 
1661
1766
  server.registerPrompt(
@@ -1805,7 +1910,7 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1805
1910
 
1806
1911
  const overview = {
1807
1912
  name: 'DADS Web Components (wcf)',
1808
- version: '0.2.0',
1913
+ version: '0.5.0',
1809
1914
  prefix: CANONICAL_PREFIX,
1810
1915
  totalComponents: indexes.decls.length,
1811
1916
  componentsByCategory: categoryCount,
@@ -1814,9 +1919,37 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1814
1919
  setupInfo: {
1815
1920
  npmPackage: 'web-components-factory',
1816
1921
  installCommand: 'npm install web-components-factory',
1817
- vendorRuntimePath: 'vendor-runtime/',
1922
+ vendorRuntimePath: `${VENDOR_DIR}/`,
1818
1923
  htmlBoilerplate: '<script type="module" src="vendor-runtime/src/autoload.js"></script>',
1819
1924
  noscriptGuidance: 'WCF components require JavaScript. Provide <noscript> fallback with static HTML equivalents for critical content.',
1925
+ noCDN: true,
1926
+ deliveryModel: 'vendor-local',
1927
+ distribution: {
1928
+ selfHosted: true,
1929
+ cdn: false,
1930
+ strategy: 'vendor-importmap',
1931
+ quickStart: 'npx web-components-factory init --prefix <prefix> --dir <dir>',
1932
+ description:
1933
+ 'Components are installed locally via the wcf CLI. No CDN is available. All assets are served from the project directory using import maps and a boot script.',
1934
+ },
1935
+ importMapHint: 'WCF uses <script type="importmap"> for module resolution. Each component tag name maps to a local JS file: { "<prefix>-<component>": "./<dir>/components/<component>.js" }. The wcf CLI generates importmap.snippet.json automatically via `wcf init`.',
1936
+ bootScript: '<dir>/boot.js — sets the component prefix via setConfig(), then loads wc-autoloader.js which scans the DOM for custom element tags and dynamically imports them via the import map.',
1937
+ vendorSetup: {
1938
+ init: 'wcf init --prefix <prefix> --dir <dir>',
1939
+ add: 'wcf add <componentId> --prefix <prefix> --out <dir>',
1940
+ workflow: '1. wcf init で初期化(boot.js, importmap.snippet.json, autoloader を生成) → 2. wcf add で各コンポーネントを追加 → import map と boot.js が自動生成される',
1941
+ },
1942
+ htmlSetup: [
1943
+ '<script type="importmap">',
1944
+ '{',
1945
+ ' "imports": {',
1946
+ ' "<prefix>-button": "./<dir>/components/button.js",',
1947
+ ' "<prefix>-card": "./<dir>/components/card.js"',
1948
+ ' }',
1949
+ '}',
1950
+ '</script>',
1951
+ '<script type="module" src="./<dir>/boot.js"></script>',
1952
+ ].join('\n'),
1820
1953
  },
1821
1954
  ideSetupTemplates: IDE_SETUP_TEMPLATES,
1822
1955
  availablePrompts: [
@@ -2015,6 +2148,14 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2015
2148
  if (accessibilityChecklist) {
2016
2149
  api.accessibilityChecklist = accessibilityChecklist;
2017
2150
  }
2151
+ const interactionExamples = canonicalTag ? INTERACTION_EXAMPLES_MAP[canonicalTag] : undefined;
2152
+ if (interactionExamples) {
2153
+ api.interactionExamples = interactionExamples;
2154
+ }
2155
+ const layoutBehavior = canonicalTag ? LAYOUT_BEHAVIOR_MAP[canonicalTag] : undefined;
2156
+ if (layoutBehavior) {
2157
+ api.layoutBehavior = layoutBehavior;
2158
+ }
2018
2159
 
2019
2160
  return buildJsonToolResponse(api);
2020
2161
  },
@@ -2133,7 +2274,19 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2133
2274
  defineHint,
2134
2275
  source: install.source,
2135
2276
  usageSnippet,
2277
+ usageContext: 'body-only',
2136
2278
  installHint: componentId ? `wcf add ${componentId}` : undefined,
2279
+ vendorHint: (() => {
2280
+ const im = tagNames.length > 0
2281
+ ? JSON.stringify({ imports: Object.fromEntries(tagNames.map((t) => [t, `./<dir>/components/${t.replace(/^[^-]+-/, '')}.js`])) })
2282
+ : undefined;
2283
+ return {
2284
+ install: componentId ? `wcf add ${componentId} --prefix <prefix> --out <dir>` : undefined,
2285
+ importMap: im,
2286
+ importmap: im, // @deprecated — use importMap; will be removed in v1.0
2287
+ boot: '<dir>/boot.js -- loads autoloader that registers components via import map',
2288
+ };
2289
+ })(),
2137
2290
  },
2138
2291
  null,
2139
2292
  2,
@@ -2226,8 +2379,40 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2226
2379
  severity: 'warning',
2227
2380
  });
2228
2381
 
2229
- const diagnostics = [...cemDiagnostics, ...enumDiagnostics, ...slotDiagnostics, ...requiredAttrDiagnostics, ...orphanDiagnostics, ...emptyInteractiveDiagnostics, ...tokenMisuseDiagnostics, ...accessibilityDiagnostics].map((d) => {
2230
- const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex });
2382
+ const lowercaseDiagnostics = detectNonLowercaseAttributes({
2383
+ filePath: '<markup>',
2384
+ text: html,
2385
+ cem: cemIndex,
2386
+ severity: 'warning',
2387
+ });
2388
+
2389
+ const cdnDiagnostics = detectCdnReferences({
2390
+ filePath: '<markup>',
2391
+ text: html,
2392
+ severity: 'warning',
2393
+ });
2394
+
2395
+ const scaffoldDiagnostics = detectMissingRuntimeScaffold({
2396
+ filePath: '<markup>',
2397
+ text: html,
2398
+ severity: 'warning',
2399
+ });
2400
+
2401
+ const allRawDiagnostics = [
2402
+ ...cemDiagnostics,
2403
+ ...enumDiagnostics,
2404
+ ...slotDiagnostics,
2405
+ ...requiredAttrDiagnostics,
2406
+ ...orphanDiagnostics,
2407
+ ...emptyInteractiveDiagnostics,
2408
+ ...lowercaseDiagnostics,
2409
+ ...tokenMisuseDiagnostics,
2410
+ ...accessibilityDiagnostics,
2411
+ ...cdnDiagnostics,
2412
+ ...scaffoldDiagnostics,
2413
+ ];
2414
+ const diagnostics = allRawDiagnostics.map((d) => {
2415
+ const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex, prefix: p });
2231
2416
  return {
2232
2417
  file: d.file,
2233
2418
  range: d.range,
@@ -2271,6 +2456,70 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2271
2456
  },
2272
2457
  );
2273
2458
 
2459
+ // -----------------------------------------------------------------------
2460
+ // Helper: buildFullPageHtml
2461
+ // -----------------------------------------------------------------------
2462
+ function escapeHtmlTitle(s) {
2463
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2464
+ }
2465
+
2466
+ /**
2467
+ * Build import map entries from a component closure.
2468
+ * @param {string[]} closure - Component IDs
2469
+ * @param {Object} components - Component metadata (from install registry)
2470
+ * @param {string} prefix - Tag name prefix
2471
+ * @param {string} dir - Directory placeholder or concrete path
2472
+ * @returns {Object} Import map entries { prefixedTag: path }
2473
+ */
2474
+ function buildImportMapEntries(closure, components, prefix, dir) {
2475
+ return Object.fromEntries(
2476
+ closure.flatMap((cid) => {
2477
+ const meta = components[cid];
2478
+ const tags = Array.isArray(meta?.tags) ? meta.tags : [cid];
2479
+ return tags.map((t) => {
2480
+ const lower = String(t).toLowerCase();
2481
+ const suffix = lower.replace(PREFIX_STRIP_RE, '');
2482
+ return [withPrefix(lower, prefix), `./${dir}/components/${suffix}.js`];
2483
+ });
2484
+ }),
2485
+ );
2486
+ }
2487
+
2488
+ /**
2489
+ * Build a complete HTML5 page from pattern data, resolving scaffoldHint
2490
+ * placeholders into concrete paths.
2491
+ * @param {Object} opts
2492
+ * @param {string} opts.html - Pattern HTML body
2493
+ * @param {string} opts.title - Page title
2494
+ * @param {Object} opts.importMapEntries - Import map entries { tag: path }
2495
+ * @param {string} [opts.dir] - Directory for JS assets
2496
+ * @param {string} [opts.lang='ja'] - HTML lang attribute
2497
+ * @returns {string} Complete HTML5 document
2498
+ */
2499
+ function buildFullPageHtml({ html, title, importMapEntries, dir = VENDOR_DIR, lang = 'ja' }) {
2500
+ const importMapJson = JSON.stringify({ imports: importMapEntries }, null, 2);
2501
+ return [
2502
+ '<!DOCTYPE html>',
2503
+ `<html lang="${lang}">`,
2504
+ '<head>',
2505
+ ' <meta charset="UTF-8">',
2506
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
2507
+ ` <title>${escapeHtmlTitle(title)}</title>`,
2508
+ ` <!-- distribution: selfHosted=true, strategy=vendor-importmap -->`,
2509
+ ` <!-- Do NOT replace these local paths with CDN URLs. This design system is self-hosted. -->`,
2510
+ ` <script type="importmap">`,
2511
+ `${importMapJson}`,
2512
+ ` </script>`,
2513
+ ` <script type="module" src="./${dir}/boot.js"></script>`,
2514
+ '</head>',
2515
+ '<body>',
2516
+ ` <noscript>このページの機能にはJavaScriptが必要です。</noscript>`,
2517
+ ` ${html}`,
2518
+ '</body>',
2519
+ '</html>',
2520
+ ].join('\n');
2521
+ }
2522
+
2274
2523
  // -----------------------------------------------------------------------
2275
2524
  // Tool: get_pattern_recipe
2276
2525
  // -----------------------------------------------------------------------
@@ -2278,13 +2527,14 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2278
2527
  'get_pattern_recipe',
2279
2528
  {
2280
2529
  description:
2281
- 'Get a complete pattern recipe with component dependencies and HTML. When: building a page layout from a pattern. Returns: dependency tree, install commands, and resolved HTML. After: use validate_markup to verify the generated HTML.',
2530
+ 'Get a complete pattern recipe with component dependencies and HTML. When: building a page layout from a pattern. Returns: dependency tree, install commands, and resolved HTML. After: use validate_markup to verify the generated HTML. Use include: ["fullPage"] to get a complete HTML5 page ready for browser rendering.',
2282
2531
  inputSchema: {
2283
2532
  patternId: z.string(),
2284
2533
  prefix: z.string().optional(),
2534
+ include: z.array(z.enum(['fullPage'])).optional(),
2285
2535
  },
2286
2536
  },
2287
- async ({ patternId, prefix }) => {
2537
+ async ({ patternId, prefix, include }) => {
2288
2538
  const id = String(patternId ?? '').trim();
2289
2539
  const p = normalizePrefix(prefix);
2290
2540
  const pat = patterns[id];
@@ -2315,28 +2565,59 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2315
2565
  const canonicalHtml = String(pat.html ?? '');
2316
2566
  const html = applyPrefixToHtml(canonicalHtml, p);
2317
2567
 
2568
+ const entryHints = Array.isArray(pat.entryHints) ? [...pat.entryHints] : ['boot'];
2569
+
2570
+ const importMapEntries = buildImportMapEntries(closure, components, p, '<dir>');
2571
+
2572
+ const scaffoldHint = {
2573
+ doctype: '<!DOCTYPE html>',
2574
+ importMap: `<script type="importmap">\n${JSON.stringify({ imports: importMapEntries }, null, 2)}\n</script>`,
2575
+ bootScript: '<script type="module" src="./<dir>/boot.js"></script>',
2576
+ noscript: '<noscript>このページの機能にはJavaScriptが必要です。</noscript>',
2577
+ serveOverHttp: 'Import maps require HTTP/HTTPS. Use a local dev server (e.g. npx serve .) instead of opening the HTML file directly via file:// protocol.',
2578
+ };
2579
+
2580
+ // Build fullPageHtml when requested via include: ['fullPage']
2581
+ const includeArr = Array.isArray(include) ? include : [];
2582
+ let fullPageHtml;
2583
+ if (includeArr.includes('fullPage')) {
2584
+ const resolvedImportMap = buildImportMapEntries(closure, components, p, VENDOR_DIR);
2585
+ fullPageHtml = buildFullPageHtml({
2586
+ html,
2587
+ title: pat.title ?? pat.id,
2588
+ importMapEntries: resolvedImportMap,
2589
+ });
2590
+ }
2591
+
2592
+ const result = {
2593
+ pattern: {
2594
+ id: pat.id,
2595
+ title: pat.title,
2596
+ description: pat.description,
2597
+ },
2598
+ prefix: p,
2599
+ requires,
2600
+ components: closure,
2601
+ install,
2602
+ html,
2603
+ canonicalHtml,
2604
+ installHint: closure.length > 0 ? `wcf add ${closure.join(' ')}` : undefined,
2605
+ entryHints,
2606
+ scaffoldHint,
2607
+ };
2608
+
2609
+ if (fullPageHtml !== undefined) {
2610
+ result.fullPageHtml = fullPageHtml;
2611
+ result.vendorSetup = {
2612
+ command: `npx web-components-factory init --prefix ${p} --dir ${VENDOR_DIR} && npx web-components-factory add ${closure.join(' ')} --prefix ${p} --out ${VENDOR_DIR}`,
2613
+ };
2614
+ }
2615
+
2318
2616
  return {
2319
2617
  content: [
2320
2618
  {
2321
2619
  type: 'text',
2322
- text: JSON.stringify(
2323
- {
2324
- pattern: {
2325
- id: pat.id,
2326
- title: pat.title,
2327
- description: pat.description,
2328
- },
2329
- prefix: p,
2330
- requires,
2331
- components: closure,
2332
- install,
2333
- html,
2334
- canonicalHtml,
2335
- installHint: closure.length > 0 ? `wcf add ${closure.join(' ')}` : undefined,
2336
- },
2337
- null,
2338
- 2,
2339
- ),
2620
+ text: JSON.stringify(result, null, 2),
2340
2621
  },
2341
2622
  ],
2342
2623
  };
@@ -2562,25 +2843,35 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2562
2843
  // Snippet match: weight 1
2563
2844
  if (snippet.includes(q)) score += 1;
2564
2845
 
2565
- // Body text match: weight 1
2566
- if (body && body.includes(q)) score += 1;
2846
+ // Body text match: weight 1, plus boost for multiple occurrences
2847
+ if (body && body.includes(q)) {
2848
+ score += 1;
2849
+ // Count additional occurrences in body for boost (cap at +2)
2850
+ let idx = body.indexOf(q);
2851
+ let occurrences = 0;
2852
+ while (idx !== -1 && occurrences < 3) {
2853
+ occurrences++;
2854
+ idx = body.indexOf(q, idx + q.length);
2855
+ }
2856
+ if (occurrences > 1) score += Math.min(occurrences - 1, 2);
2857
+ }
2567
2858
 
2568
- // Synonym expansion match: weight 1 (only for expanded terms, not the original)
2569
- if (score === 0 && expandedTerms.length > 1) {
2570
- for (let i = 1; i < expandedTerms.length; i++) {
2859
+ // Synonym expansion match: check all expanded terms, cap total synonym contribution at +2
2860
+ if (expandedTerms.length > 1) {
2861
+ let synScore = 0;
2862
+ const lowerKeywords = keywords.map((kw) => String(kw).toLowerCase());
2863
+ for (let i = 1; i < expandedTerms.length && synScore < 2; i++) {
2571
2864
  const syn = expandedTerms[i];
2572
- if (heading.includes(syn) || snippet.includes(syn) || body.includes(syn)) {
2573
- score += 1;
2574
- break;
2575
- }
2576
- for (const kw of keywords) {
2577
- if (String(kw).toLowerCase().includes(syn)) {
2578
- score += 1;
2865
+ if (heading.includes(syn)) { synScore += 1; continue; }
2866
+ if (snippet.includes(syn) || body.includes(syn)) { synScore += 1; continue; }
2867
+ for (const kw of lowerKeywords) {
2868
+ if (kw.includes(syn)) {
2869
+ synScore += 1;
2579
2870
  break;
2580
2871
  }
2581
2872
  }
2582
- if (score > 0) break;
2583
2873
  }
2874
+ score += synScore;
2584
2875
  }
2585
2876
 
2586
2877
  if (score > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoharada/wcf-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server for the web-components-factory design system. Provides component discovery, validation, and pattern-based UI composition without cloning the repository.",
5
5
  "type": "module",
6
6
  "bin": {
package/validator.mjs CHANGED
@@ -797,15 +797,17 @@ export function detectEmptyInteractiveElement({
797
797
  }
798
798
 
799
799
  /**
800
- * Hardcoded map of required attributes per form component (DIG-08).
801
- * Only `label` for form inputs.
800
+ * Required attributes per form component (DIG-08).
801
+ * Both `label` and `name` are required for components that declare them in CEM.
802
+ * Note: dads-date-picker and dads-file-upload use slots for labels, not attributes.
802
803
  */
803
804
  const REQUIRED_ATTRIBUTES = new Map([
804
- ['dads-input-text', ['label']],
805
- ['dads-textarea', ['label']],
806
- ['dads-select', ['label']],
807
- ['dads-checkbox', ['label']],
808
- ['dads-radio', ['label']],
805
+ ['dads-input-text', ['label', 'name']],
806
+ ['dads-textarea', ['label', 'name']],
807
+ ['dads-select', ['label', 'name']],
808
+ ['dads-checkbox', ['label', 'name']],
809
+ ['dads-radio', ['label', 'name']],
810
+ ['dads-combobox', ['label', 'name']],
809
811
  ]);
810
812
 
811
813
  /**
@@ -866,3 +868,143 @@ export function detectMissingRequiredAttributes({
866
868
 
867
869
  return diagnostics;
868
870
  }
871
+
872
+ /**
873
+ * Detect attributes written in non-lowercase on known custom elements.
874
+ * HTML attributes are case-insensitive, but WCF uses lowercase canonically.
875
+ * @param {{
876
+ * filePath?: string;
877
+ * text: string;
878
+ * cem: Map<string, { attributes: Set<string> }>;
879
+ * severity?: string;
880
+ * }} params
881
+ */
882
+ export function detectNonLowercaseAttributes({
883
+ filePath = '<input>',
884
+ text,
885
+ cem,
886
+ severity = 'warning',
887
+ }) {
888
+ const diagnostics = [];
889
+ if (!(cem instanceof Map) || cem.size === 0) return diagnostics;
890
+
891
+ const lineStarts = computeLineIndex(text);
892
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
893
+ let m;
894
+
895
+ while ((m = tagRe.exec(text))) {
896
+ const tag = String(m[1] ?? '').toLowerCase();
897
+ if (!tag.includes('-')) continue;
898
+
899
+ const meta = cem.get(tag);
900
+ if (!meta) continue;
901
+
902
+ const attrChunk = String(m[2] ?? '');
903
+ const rawAttrsStart = m.index + 1 + tag.length;
904
+ const attrs = parseAttributes(attrChunk);
905
+
906
+ for (const { name, offset } of attrs) {
907
+ // Check if attribute has non-lowercase characters BEFORE normalizing
908
+ if (name === name.toLowerCase()) continue;
909
+
910
+ const lower = name.toLowerCase();
911
+
912
+ // Skip global attributes and event handlers
913
+ if (shouldSkipAttr(lower)) continue;
914
+
915
+ // Only flag if the lowercase form is a known CEM attribute
916
+ if (!meta.attributes.has(lower)) continue;
917
+
918
+ const startIndex = rawAttrsStart + offset;
919
+ const endIndex = startIndex + name.length;
920
+ const range = makeRange(lineStarts, startIndex, endIndex);
921
+ diagnostics.push({
922
+ file: filePath,
923
+ range,
924
+ severity,
925
+ code: 'canonicalLowercaseRecommendation',
926
+ message: `Attribute "${name}" should be lowercase: "${lower}".`,
927
+ tagName: tag,
928
+ attrName: name,
929
+ hint: `Use "${lower}" instead of "${name}".`,
930
+ });
931
+ }
932
+ }
933
+
934
+ return diagnostics;
935
+ }
936
+
937
+ /**
938
+ * Detect CDN URLs in markup that should use local vendor paths instead.
939
+ */
940
+ export function detectCdnReferences({
941
+ filePath = '<input>',
942
+ text,
943
+ severity = 'warning',
944
+ }) {
945
+ const diagnostics = [];
946
+ const lineStarts = computeLineIndex(text);
947
+ const cdnRe = /https?:\/\/(?:cdn\.jsdelivr\.net|unpkg\.com|cdnjs\.cloudflare\.com|esm\.sh)/g;
948
+ let m;
949
+ while ((m = cdnRe.exec(text))) {
950
+ const range = makeRange(lineStarts, m.index, m.index + m[0].length);
951
+ diagnostics.push({
952
+ file: filePath,
953
+ range,
954
+ severity,
955
+ code: 'cdnReference',
956
+ message: `CDN URL detected: "${m[0]}". This design system is self-hosted. Use local vendor paths instead.`,
957
+ tagName: '',
958
+ hint: 'Replace CDN URLs with local paths (e.g., ./vendor-runtime/components/...). Run `wcf init` to set up local assets.',
959
+ });
960
+ }
961
+
962
+ return diagnostics;
963
+ }
964
+
965
+ /**
966
+ * Detect missing importmap or boot.js in a full HTML page.
967
+ * Only triggers when the markup contains a full page structure (<!DOCTYPE html>).
968
+ */
969
+ export function detectMissingRuntimeScaffold({
970
+ filePath = '<input>',
971
+ text,
972
+ severity = 'warning',
973
+ }) {
974
+ const diagnostics = [];
975
+
976
+ // Only check full HTML pages
977
+ if (!text.includes('<!DOCTYPE html>') && !text.includes('<!doctype html>')) {
978
+ return diagnostics;
979
+ }
980
+
981
+ const lineStarts = computeLineIndex(text);
982
+
983
+ if (!/<script\b[^>]*\btype\s*=\s*['"]importmap['"][^>]*>/i.test(text)) {
984
+ const range = makeRange(lineStarts, 0, Math.min(text.length, 15));
985
+ diagnostics.push({
986
+ file: filePath,
987
+ range,
988
+ severity,
989
+ code: 'missingImportmap',
990
+ message: 'Full HTML page is missing <script type="importmap">. WCF components require an import map for module resolution.',
991
+ tagName: '',
992
+ hint: 'Add <script type="importmap">{"imports":{...}}</script> in <head>. Run `wcf init` to generate one.',
993
+ });
994
+ }
995
+
996
+ if (!text.includes('boot.js')) {
997
+ const range = makeRange(lineStarts, 0, Math.min(text.length, 15));
998
+ diagnostics.push({
999
+ file: filePath,
1000
+ range,
1001
+ severity,
1002
+ code: 'missingBootScript',
1003
+ message: 'Full HTML page is missing boot.js script. WCF components require the boot script to initialize.',
1004
+ tagName: '',
1005
+ hint: 'Add <script type="module" src="./vendor-runtime/boot.js"></script> in <head>.',
1006
+ });
1007
+ }
1008
+
1009
+ return diagnostics;
1010
+ }