@monoharada/wcf-mcp 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core.mjs CHANGED
@@ -112,6 +112,8 @@ const BUILTIN_TOOL_NAMES = Object.freeze(new Set([
112
112
  'get_design_token_detail',
113
113
  'get_accessibility_docs',
114
114
  'search_guidelines',
115
+ 'generate_full_page_html',
116
+ 'get_component_selector_guide',
115
117
  ]));
116
118
  const A11Y_CATEGORY_LEVEL_MAP = Object.freeze({
117
119
  semantics: 'A',
@@ -141,12 +143,125 @@ const SYNONYM_TABLE = new Map([
141
143
  ['aria-live', ['role=alert', 'aria-describedby', 'live region', 'error text']],
142
144
  ['keyboard', ['focus', 'tab', 'tabindex', 'key event', 'focus trap']],
143
145
  ['contrast', ['color', 'wcag', 'color contrast']],
144
- ['spacing', ['margin', 'padding', 'gap', 'spacing token']],
146
+ ['spacing', ['margin', 'padding', 'gap', 'spacing token', '--spacing']],
145
147
  ['skip-navigation', ['skip-link', 'landmark', 'skip nav']],
146
148
  ['heading', ['heading hierarchy', 'h1', 'heading level']],
147
149
  ['form', ['input', 'validation', 'required', 'label']],
150
+ ['part', ['::part', 'css part', 'shadow dom styling']],
151
+ ['layout', ['grid', 'flexbox', 'layout-shell', 'responsive', 'breakpoint']],
152
+ ['responsive', ['media query', 'breakpoint', 'viewport', 'mobile']],
153
+ ['error', ['validation', 'aria-invalid', 'aria-describedby', 'error text']],
154
+ ['focus', ['focus-visible', 'focus ring', 'outline', 'tabindex', 'keyboard']],
155
+ ['token', ['design token', 'css variable', 'custom property', 'spacing token']],
156
+ ['div-soup', ['wrapper', 'unnecessary div', 'minimal dom']],
148
157
  ]);
149
158
 
159
+ // Icon alias table: common alias → canonical icon names (DD-18)
160
+ // Maps user-friendly search terms to actual icon names in the CEM catalog.
161
+ const ICON_ALIAS_TABLE = new Map([
162
+ ['x', ['close', 'cancel']],
163
+ ['trash', ['delete']],
164
+ ['pencil', ['edit']],
165
+ ['magnifying', ['search']],
166
+ ['gear', ['settings']],
167
+ ['plus', ['add']],
168
+ ['minus', ['subtract']],
169
+ ['tick', ['check', 'checkmark']],
170
+ ['alert', ['warning', 'attention']],
171
+ ['info', ['information', 'help']],
172
+ ['hamburger', ['menu']],
173
+ ['back', ['arrowBack', 'arrowLeft']],
174
+ ['forward', ['arrowForward', 'arrowRight']],
175
+ ['eye', ['visibility']],
176
+ ['user', ['person']],
177
+ ['file', ['document']],
178
+ ['bell', ['notification']],
179
+ ]);
180
+
181
+ // Interaction examples for form components (P-04 / #206)
182
+ const INTERACTION_EXAMPLES_MAP = Object.freeze({
183
+ 'dads-input-text': [
184
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "hello";' },
185
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
186
+ { scenario: 'Clear validation error', trigger: 'attribute', code: 'el.error = false; el.errorText = "";' },
187
+ { scenario: 'Listen to value change', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
188
+ ],
189
+ 'dads-textarea': [
190
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "long text...";' },
191
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "入力できる文字数を超えています";' },
192
+ { scenario: 'Listen to input event', trigger: 'event', code: "el.addEventListener('input', (e) => { console.log(e.target.value); });" },
193
+ ],
194
+ 'dads-select': [
195
+ { scenario: 'Set selected value', trigger: 'property', code: 'el.value = "option1";' },
196
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
197
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
198
+ ],
199
+ 'dads-checkbox': [
200
+ { scenario: 'Set checked state', trigger: 'property', code: 'el.checked = true;' },
201
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
202
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.checked); });" },
203
+ ],
204
+ 'dads-radio': [
205
+ { scenario: 'Set checked state', trigger: 'property', code: 'el.checked = true;' },
206
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
207
+ ],
208
+ 'dads-combobox': [
209
+ { scenario: 'Set value programmatically', trigger: 'property', code: 'el.value = "selected-option";' },
210
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
211
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
212
+ ],
213
+ 'dads-date-picker': [
214
+ { scenario: 'Set date value', trigger: 'property', code: 'el.value = "2024-01-15";' },
215
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
216
+ { scenario: 'Listen to change event', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.value); });" },
217
+ ],
218
+ 'dads-file-upload': [
219
+ { scenario: 'Listen to file selection', trigger: 'event', code: "el.addEventListener('change', (e) => { console.log(e.target.files); });" },
220
+ { scenario: 'Show validation error', trigger: 'attribute', code: 'el.error = true; el.errorText = "この項目は入力が必須です";' },
221
+ ],
222
+ });
223
+
224
+ // Layout behavior metadata for layout/display components (P-05 / #207)
225
+ const LAYOUT_BEHAVIOR_MAP = Object.freeze({
226
+ 'dads-layout-shell': {
227
+ responsive: {
228
+ breakpoints: { desktop: '80rem', tablet: '48rem' },
229
+ modes: ['auto', 'desktop', 'tablet', 'mobile'],
230
+ defaultMode: 'auto',
231
+ description: 'Automatically switches between desktop/tablet/mobile layouts based on viewport width when mode="auto".',
232
+ },
233
+ overflow: {
234
+ strategy: 'slot-driven',
235
+ description: 'Slots (header, sidebar, aside, footer) are auto-hidden when empty. Sidebar collapses to rail on tablet.',
236
+ },
237
+ constraints: {
238
+ patterns: ['website', 'app-shell', 'master-detail', 'left-header-pane', 'three-pane', 'three-pane-shell'],
239
+ defaultPattern: 'app-shell',
240
+ mobileSidebarOptions: ['hidden', 'top', 'bottom'],
241
+ description: 'Choose a pattern attribute to control layout structure. Pair with mode and mobile-sidebar for full control.',
242
+ },
243
+ },
244
+ 'dads-layout-sidebar': {
245
+ responsive: {
246
+ description: 'Designed to be placed inside dads-layout-shell sidebar slot. Width is controlled by the parent shell.',
247
+ },
248
+ constraints: {
249
+ description: 'Simple container for sidebar content. Use inside dads-layout-shell for responsive behavior.',
250
+ },
251
+ },
252
+ 'dads-device-mock': {
253
+ responsive: {
254
+ devices: ['desktop', 'tablet', 'mobile'],
255
+ defaultDevice: 'mobile',
256
+ description: 'Renders a device frame (desktop/tablet/mobile) around slotted content. Set device attribute to switch.',
257
+ },
258
+ constraints: {
259
+ visibleHeight: 'Use visible-height attribute to clip the mock to a specific height (e.g. "220px").',
260
+ description: 'Display-only component for previewing content in device frames. Not a layout container.',
261
+ },
262
+ },
263
+ });
264
+
150
265
  export function expandQueryWithSynonyms(query) {
151
266
  const q = String(query ?? '').toLowerCase().trim();
152
267
  if (!q) return [q];
@@ -377,6 +492,42 @@ export function buildTokenRelationshipIndex(designTokensData) {
377
492
  return { byToken };
378
493
  }
379
494
 
495
+ /**
496
+ * Extract which components reference which design tokens via var() in CEM cssProperties.
497
+ * Returns Map<tokenName, Set<componentTagName>>.
498
+ * DD-25: var(--token, fallback) fallback values are not extracted (known limitation).
499
+ */
500
+ export function buildComponentTokenReferencedBy(manifest) {
501
+ const result = new Map();
502
+ const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
503
+ const varRe = /var\((--[\w-]+)/g;
504
+ for (const mod of modules) {
505
+ const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
506
+ for (const decl of declarations) {
507
+ const tag = decl?.tagName;
508
+ if (typeof tag !== 'string') continue;
509
+ const cssProps = Array.isArray(decl?.cssProperties) ? decl.cssProperties : [];
510
+ for (const prop of cssProps) {
511
+ const defaultVal = typeof prop?.default === 'string' ? prop.default : '';
512
+ let m;
513
+ while ((m = varRe.exec(defaultVal)) !== null) {
514
+ const tokenName = normalizeTokenIdentifier(m[1]);
515
+ if (!tokenName) continue;
516
+ if (!result.has(tokenName)) result.set(tokenName, new Set());
517
+ result.get(tokenName).add(tag);
518
+ }
519
+ // Also index the css property name itself as a token → component mapping
520
+ const propName = normalizeTokenIdentifier(prop?.name);
521
+ if (propName) {
522
+ if (!result.has(propName)) result.set(propName, new Set());
523
+ result.get(propName).add(tag);
524
+ }
525
+ }
526
+ }
527
+ }
528
+ return result;
529
+ }
530
+
380
531
  function toTokenSummary(token) {
381
532
  return {
382
533
  name: String(token?.name ?? ''),
@@ -752,10 +903,16 @@ export function levenshteinDistance(left, right) {
752
903
  return prev[b.length];
753
904
  }
754
905
 
755
- export function suggestUnknownElementTagName(tagName, cemIndex) {
906
+ export function suggestUnknownElementTagName(tagName, cemIndex, prefix) {
756
907
  const target = String(tagName ?? '').trim().toLowerCase();
757
908
  if (!target || !target.includes('-')) return undefined;
758
909
 
910
+ // Try prefix-prepend before Levenshtein (e.g. input-text → dads-input-text)
911
+ if (prefix && cemIndex instanceof Map) {
912
+ const prefixed = `${String(prefix).toLowerCase()}-${target}`;
913
+ if (cemIndex.has(prefixed)) return prefixed;
914
+ }
915
+
759
916
  let bestTag;
760
917
  let bestDistance = Number.POSITIVE_INFINITY;
761
918
  const candidateSource = cemIndex instanceof Map ? cemIndex.keys() : [];
@@ -775,15 +932,19 @@ export function suggestUnknownElementTagName(tagName, cemIndex) {
775
932
  return bestTag;
776
933
  }
777
934
 
778
- export function buildDiagnosticSuggestion({ diagnostic, cemIndex }) {
935
+ export function buildDiagnosticSuggestion({ diagnostic, cemIndex, prefix }) {
779
936
  const code = String(diagnostic?.code ?? '');
780
937
  if (!code) return undefined;
781
938
 
782
939
  if (code === 'unknownElement') {
783
- const tagName = suggestUnknownElementTagName(diagnostic?.tagName, cemIndex);
940
+ const tagName = suggestUnknownElementTagName(diagnostic?.tagName, cemIndex, prefix);
784
941
  return tagName ? `Did you mean "${tagName}"?` : undefined;
785
942
  }
786
943
 
944
+ if (code === 'canonicalLowercaseRecommendation') {
945
+ return diagnostic?.hint ?? undefined;
946
+ }
947
+
787
948
  if (code === 'forbiddenAttribute' && String(diagnostic?.attrName ?? '').toLowerCase() === 'placeholder') {
788
949
  return 'Use aria-label or aria-describedby support text instead of placeholder.';
789
950
  }
@@ -796,6 +957,14 @@ export function buildDiagnosticSuggestion({ diagnostic, cemIndex }) {
796
957
  return 'Use role="alert" only for urgent live updates; otherwise use static text associated via aria-describedby.';
797
958
  }
798
959
 
960
+ if (code === 'emptyLabel') {
961
+ return diagnostic?.hint ?? 'Provide a meaningful label value for accessibility.';
962
+ }
963
+
964
+ if (code === 'emptyAriaLabel') {
965
+ return diagnostic?.hint ?? 'Provide a meaningful aria-label value or use a visible <label> element.';
966
+ }
967
+
799
968
  return undefined;
800
969
  }
801
970
 
@@ -835,6 +1004,74 @@ export function buildIndexes(manifest) {
835
1004
  return { byTag, byClass, modulePathByTag, decls };
836
1005
  }
837
1006
 
1007
+ /**
1008
+ * Extracts the primary component prefix from CEM indexes.
1009
+ * Returns the most common prefix among all tagNames (e.g. 'dads' from 'dads-button').
1010
+ * Falls back to CANONICAL_PREFIX if no tagNames are found.
1011
+ */
1012
+ export function extractPrefixFromIndexes(indexes) {
1013
+ const counts = new Map();
1014
+ for (const { tagName } of indexes.decls) {
1015
+ const i = tagName.indexOf('-');
1016
+ if (i > 0) {
1017
+ const p = tagName.slice(0, i);
1018
+ counts.set(p, (counts.get(p) ?? 0) + 1);
1019
+ }
1020
+ }
1021
+ let best = CANONICAL_PREFIX;
1022
+ let bestCount = 0;
1023
+ for (const [p, c] of counts) {
1024
+ if (c > bestCount) { best = p; bestCount = c; }
1025
+ }
1026
+ return best;
1027
+ }
1028
+
1029
+ /**
1030
+ * Build a full HTML page from a fragment.
1031
+ * @param {{ html: string; prefix: string; cemIndex: Map }} params
1032
+ */
1033
+ export function buildFullPageHtml({ html, prefix, cemIndex }) {
1034
+ // Extract custom element tags from the HTML fragment
1035
+ const tagRe = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/gi;
1036
+ const tags = new Set();
1037
+ let m;
1038
+ while ((m = tagRe.exec(html))) {
1039
+ tags.add(String(m[1]).toLowerCase());
1040
+ }
1041
+
1042
+ // Build import map entries for recognized components
1043
+ const importEntries = {};
1044
+ for (const tag of tags) {
1045
+ if (cemIndex.has(tag)) {
1046
+ const suffix = tag.replace(/^[^-]+-/, '');
1047
+ importEntries[tag] = `./<dir>/components/${suffix}.js`;
1048
+ }
1049
+ }
1050
+
1051
+ const importMapJson = JSON.stringify({ imports: importEntries }, null, 2);
1052
+
1053
+ const lines = [
1054
+ '<!DOCTYPE html>',
1055
+ `<html lang="ja">`,
1056
+ '<head>',
1057
+ ' <meta charset="utf-8">',
1058
+ ' <meta name="viewport" content="width=device-width, initial-scale=1">',
1059
+ ` <title>WCF Preview</title>`,
1060
+ ` <link rel="stylesheet" href="./<dir>/styles/tokens.css">`,
1061
+ ` <script type="importmap">`,
1062
+ importMapJson,
1063
+ ` </script>`,
1064
+ '</head>',
1065
+ '<body>',
1066
+ html,
1067
+ ` <script type="module" src="./<dir>/boot.js"></script>`,
1068
+ '</body>',
1069
+ '</html>',
1070
+ ];
1071
+
1072
+ return { fullHtml: lines.join('\n'), importEntries };
1073
+ }
1074
+
838
1075
  export function pickDecl({ byTag, byClass }, { tagName, className, prefix }) {
839
1076
  if (typeof tagName === 'string' && tagName.trim() !== '') {
840
1077
  const canonical = toCanonicalTagName(tagName, prefix);
@@ -867,6 +1104,7 @@ export function serializeApi(decl, modulePath, prefix) {
867
1104
  attributes: attributes.map((a) => ({
868
1105
  name: a?.name,
869
1106
  type: a?.type?.text,
1107
+ default: a?.default ?? null,
870
1108
  description: a?.description,
871
1109
  inheritedFrom: a?.inheritedFrom,
872
1110
  deprecated: a?.deprecated,
@@ -933,8 +1171,12 @@ export function generateSnippet(api, prefix) {
933
1171
  if (!a) continue;
934
1172
  const t = String(a.type ?? '').toLowerCase();
935
1173
  const isBoolean = t.includes('boolean');
936
- if (isBoolean) lines.push(` ${name}`);
937
- else lines.push(` ${name}=""`);
1174
+ if (isBoolean) {
1175
+ lines.push(` ${name}`);
1176
+ } else {
1177
+ const defaultVal = typeof a.default === 'string' ? a.default.replace(/^['"]|['"]$/g, '') : '';
1178
+ lines.push(` ${name}="${defaultVal}"`);
1179
+ }
938
1180
  if (lines.length >= 4) break;
939
1181
  }
940
1182
 
@@ -1133,7 +1375,18 @@ export function searchIconCatalog(indexes, { query, limit, offset, prefix } = {}
1133
1375
 
1134
1376
  let icons = buildIconCatalog(indexes, prefix);
1135
1377
  if (q) {
1136
- icons = icons.filter((icon) => icon.name.toLowerCase().includes(q));
1378
+ // Expand query with icon aliases (DD-18)
1379
+ const searchTerms = [q];
1380
+ const aliases = ICON_ALIAS_TABLE.get(q);
1381
+ if (aliases) {
1382
+ for (const alias of aliases) {
1383
+ if (!searchTerms.includes(alias)) searchTerms.push(alias);
1384
+ }
1385
+ }
1386
+ icons = icons.filter((icon) => {
1387
+ const name = icon.name.toLowerCase();
1388
+ return searchTerms.some((term) => name.includes(term));
1389
+ });
1137
1390
  }
1138
1391
 
1139
1392
  const total = icons.length;
@@ -1609,6 +1862,7 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1609
1862
 
1610
1863
  const manifest = await loadJson('custom-elements.json');
1611
1864
  const indexes = buildIndexes(manifest);
1865
+ const detectedPrefix = extractPrefixFromIndexes(indexes);
1612
1866
  const {
1613
1867
  collectCemCustomElements,
1614
1868
  validateTextAgainstCem,
@@ -1621,6 +1875,9 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1621
1875
  detectMissingRequiredAttributes = () => [],
1622
1876
  detectOrphanedChildComponents = () => [],
1623
1877
  detectEmptyInteractiveElement = () => [],
1878
+ detectNonLowercaseAttributes = () => [],
1879
+ detectCdnReferences = () => [],
1880
+ detectMissingRuntimeScaffold = () => [],
1624
1881
  } = await loadValidator();
1625
1882
  const canonicalCemIndex = collectCemCustomElements(manifest);
1626
1883
  const canonicalEnumMap = buildEnumAttributeMap(manifest);
@@ -1652,10 +1909,14 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1652
1909
  }
1653
1910
 
1654
1911
  const tokenSuggestionMap = buildTokenSuggestionMap(designTokensData);
1912
+ const componentTokenRefMap = buildComponentTokenReferencedBy(manifest);
1913
+
1914
+ const VENDOR_DIR = 'vendor-runtime';
1915
+ const PREFIX_STRIP_RE = /^[^-]+-/;
1655
1916
 
1656
1917
  const server = new McpServer({
1657
1918
  name: 'web-components-factory-design-system',
1658
- version: '0.2.0',
1919
+ version: '0.6.0',
1659
1920
  });
1660
1921
 
1661
1922
  server.registerPrompt(
@@ -1805,8 +2066,8 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1805
2066
 
1806
2067
  const overview = {
1807
2068
  name: 'DADS Web Components (wcf)',
1808
- version: '0.2.0',
1809
- prefix: CANONICAL_PREFIX,
2069
+ version: '0.6.0',
2070
+ prefix: detectedPrefix,
1810
2071
  totalComponents: indexes.decls.length,
1811
2072
  componentsByCategory: categoryCount,
1812
2073
  totalPatterns: patternList.length,
@@ -1814,9 +2075,43 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1814
2075
  setupInfo: {
1815
2076
  npmPackage: 'web-components-factory',
1816
2077
  installCommand: 'npm install web-components-factory',
1817
- vendorRuntimePath: 'vendor-runtime/',
1818
- htmlBoilerplate: '<script type="module" src="vendor-runtime/src/autoload.js"></script>',
2078
+ vendorRuntimePath: '<dir>/',
2079
+ htmlBoilerplate: [
2080
+ '<script type="importmap">',
2081
+ `{ "imports": { "${detectedPrefix}-button": "./<dir>/components/button.js" } }`,
2082
+ '</script>',
2083
+ '<script type="module" src="./<dir>/boot.js"></script>',
2084
+ ].join('\n'),
1819
2085
  noscriptGuidance: 'WCF components require JavaScript. Provide <noscript> fallback with static HTML equivalents for critical content.',
2086
+ noCDN: true,
2087
+ deliveryModel: 'vendor-local',
2088
+ distribution: {
2089
+ selfHosted: true,
2090
+ cdn: false,
2091
+ strategy: 'vendor-importmap',
2092
+ quickStart: 'npx web-components-factory init --prefix <prefix> --dir <dir>',
2093
+ description:
2094
+ '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.',
2095
+ },
2096
+ importMapHint: `WCF uses <script type="importmap"> for module resolution. Each component tag name maps to a local JS file: { "${detectedPrefix}-<component>": "./<dir>/components/<component>.js" }. The wcf CLI generates importmap.snippet.json automatically via \`wcf init\`.`,
2097
+ 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.',
2098
+ detectedPrefix,
2099
+ vendorSetup: {
2100
+ init: `wcf init --prefix ${detectedPrefix} --dir <dir>`,
2101
+ add: `wcf add <componentId> --prefix ${detectedPrefix} --out <dir>`,
2102
+ workflow: '1. wcf init で初期化(boot.js, importmap.snippet.json, autoloader を生成) → 2. wcf add で各コンポーネントを追加 → import map と boot.js が自動生成される',
2103
+ },
2104
+ htmlSetup: [
2105
+ '<script type="importmap">',
2106
+ '{',
2107
+ ' "imports": {',
2108
+ ` "${detectedPrefix}-button": "./<dir>/components/button.js",`,
2109
+ ` "${detectedPrefix}-card": "./<dir>/components/card.js"`,
2110
+ ' }',
2111
+ '}',
2112
+ '</script>',
2113
+ '<script type="module" src="./<dir>/boot.js"></script>',
2114
+ ].join('\n'),
1820
2115
  },
1821
2116
  ideSetupTemplates: IDE_SETUP_TEMPLATES,
1822
2117
  availablePrompts: [
@@ -1851,6 +2146,7 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1851
2146
  { name: 'generate_usage_snippet', purpose: 'Minimal HTML usage example' },
1852
2147
  { name: 'get_install_recipe', purpose: 'Installation instructions and dependency tree' },
1853
2148
  { name: 'validate_markup', purpose: 'Validate HTML against CEM schema' },
2149
+ { name: 'generate_full_page_html', purpose: 'Wrap HTML fragment into a complete page with importmap and boot script' },
1854
2150
  { name: 'list_patterns', purpose: 'Browse page-level UI composition patterns' },
1855
2151
  { name: 'get_pattern_recipe', purpose: 'Full pattern recipe with dependencies and HTML' },
1856
2152
  { name: 'generate_pattern_snippet', purpose: 'Pattern HTML snippet only' },
@@ -1858,6 +2154,7 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1858
2154
  { name: 'get_design_token_detail', purpose: 'Get details, relationships, and usage examples for one token' },
1859
2155
  { name: 'get_accessibility_docs', purpose: 'Search component-level accessibility checklist and WCAG-filtered guidance' },
1860
2156
  { name: 'search_guidelines', purpose: 'Search design system guidelines and best practices' },
2157
+ { name: 'get_component_selector_guide', purpose: 'Component selection guide by category and use case' },
1861
2158
  ],
1862
2159
  recommendedWorkflow: [
1863
2160
  '1. get_design_system_overview → understand components, patterns, tokens, and IDE setup templates',
@@ -1872,7 +2169,8 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1872
2169
  '10. get_component_api → check attributes, slots, events, CSS parts',
1873
2170
  '11. generate_usage_snippet or get_pattern_recipe → get code',
1874
2171
  '12. validate_markup → verify your HTML and use suggestions to self-correct',
1875
- '13. get_install_recipeget import/install instructions',
2172
+ '13. generate_full_page_htmlwrap fragment into a complete preview-ready page',
2173
+ '14. get_install_recipe → get import/install instructions',
1876
2174
  ],
1877
2175
  experimental: {
1878
2176
  plugins: {
@@ -1971,16 +2269,53 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
1971
2269
  'get_component_api',
1972
2270
  {
1973
2271
  description:
1974
- 'Get the full API surface of a single component (attributes, slots, events, CSS parts, CSS custom properties). When: you need detailed specs for a component. Returns: complete component specification. After: use generate_usage_snippet for a code example.',
2272
+ 'Get the full API surface of one or more components (attributes, slots, events, CSS parts, CSS custom properties). When: you need detailed specs for components. Returns: complete component specification (single object or array for batch). After: use generate_usage_snippet for a code example.',
1975
2273
  inputSchema: {
1976
2274
  tagName: z.string().optional().describe('Tag name (e.g., "dads-button")'),
1977
2275
  className: z.string().optional().describe('Class name (e.g., "DadsButton")'),
1978
2276
  component: z.string().optional().describe('Any identifier: tagName, className, or bare name (e.g., "button")'),
2277
+ components: z.array(z.string()).max(10).optional().describe('Batch: array of component identifiers (max 10). When provided, component/tagName/className are ignored.'),
1979
2278
  prefix: z.string().optional(),
1980
2279
  },
1981
2280
  },
1982
- async ({ tagName, className, component, prefix }) => {
2281
+ async ({ tagName, className, component, components, prefix }) => {
1983
2282
  const p = normalizePrefix(prefix);
2283
+
2284
+ // Batch mode: components array takes priority (DD-23)
2285
+ if (Array.isArray(components) && components.length > 0) {
2286
+ const results = [];
2287
+ for (const comp of components) {
2288
+ const resolved = resolveDeclByComponent(indexes, comp, p);
2289
+ if (!resolved?.decl) {
2290
+ results.push({ component: comp, error: `Component not found: ${comp}` });
2291
+ continue;
2292
+ }
2293
+ const { decl: d, modulePath: mp } = resolved;
2294
+ const cTag = typeof d.tagName === 'string' ? d.tagName.toLowerCase() : undefined;
2295
+ const mPath = mp ?? (cTag ? indexes.modulePathByTag.get(cTag) : undefined);
2296
+ const api = serializeApi(d, mPath, prefix);
2297
+ const related = getRelatedComponentsForTag({
2298
+ canonicalTagName: cTag,
2299
+ installRegistry,
2300
+ relatedMap: relatedComponentMap,
2301
+ prefix,
2302
+ });
2303
+ if (related.length > 0) api.relatedComponents = related;
2304
+ const a11y = extractAccessibilityChecklist(d, { prefix });
2305
+ if (a11y) api.accessibilityChecklist = a11y;
2306
+ results.push(api);
2307
+ }
2308
+ const resultJson = JSON.stringify(results, null, 2);
2309
+ if (measureToolResultBytes(resultJson) > MAX_TOOL_RESULT_BYTES) {
2310
+ return {
2311
+ content: [{ type: 'text', text: JSON.stringify({ error: 'Batch result exceeds size limit. Reduce the number of components.' }) }],
2312
+ isError: true,
2313
+ };
2314
+ }
2315
+ return buildJsonToolResponse(results);
2316
+ }
2317
+
2318
+ // Single mode (existing behavior)
1984
2319
  let decl;
1985
2320
  let modulePath;
1986
2321
 
@@ -2015,6 +2350,14 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2015
2350
  if (accessibilityChecklist) {
2016
2351
  api.accessibilityChecklist = accessibilityChecklist;
2017
2352
  }
2353
+ const interactionExamples = canonicalTag ? INTERACTION_EXAMPLES_MAP[canonicalTag] : undefined;
2354
+ if (interactionExamples) {
2355
+ api.interactionExamples = interactionExamples;
2356
+ }
2357
+ const layoutBehavior = canonicalTag ? LAYOUT_BEHAVIOR_MAP[canonicalTag] : undefined;
2358
+ if (layoutBehavior) {
2359
+ api.layoutBehavior = layoutBehavior;
2360
+ }
2018
2361
 
2019
2362
  return buildJsonToolResponse(api);
2020
2363
  },
@@ -2133,7 +2476,19 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2133
2476
  defineHint,
2134
2477
  source: install.source,
2135
2478
  usageSnippet,
2479
+ usageContext: 'body-only',
2136
2480
  installHint: componentId ? `wcf add ${componentId}` : undefined,
2481
+ vendorHint: (() => {
2482
+ const im = tagNames.length > 0
2483
+ ? JSON.stringify({ imports: Object.fromEntries(tagNames.map((t) => [t, `./<dir>/components/${t.replace(/^[^-]+-/, '')}.js`])) })
2484
+ : undefined;
2485
+ return {
2486
+ install: componentId ? `wcf add ${componentId} --prefix <prefix> --out <dir>` : undefined,
2487
+ importMap: im,
2488
+ importmap: im, // @deprecated — use importMap; will be removed in v1.0
2489
+ boot: '<dir>/boot.js -- loads autoloader that registers components via import map',
2490
+ };
2491
+ })(),
2137
2492
  },
2138
2493
  null,
2139
2494
  2,
@@ -2192,10 +2547,12 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2192
2547
  severity: 'warning',
2193
2548
  });
2194
2549
 
2550
+ const cemTagNames = new Set(cemIndex.keys());
2195
2551
  const accessibilityDiagnostics = detectAccessibilityMisuseInMarkup({
2196
2552
  filePath: '<markup>',
2197
2553
  text: html,
2198
- severity: 'warning',
2554
+ severity: 'error',
2555
+ cemTagNames,
2199
2556
  });
2200
2557
 
2201
2558
  const slotDiagnostics = detectInvalidSlotName({
@@ -2226,8 +2583,40 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2226
2583
  severity: 'warning',
2227
2584
  });
2228
2585
 
2229
- const diagnostics = [...cemDiagnostics, ...enumDiagnostics, ...slotDiagnostics, ...requiredAttrDiagnostics, ...orphanDiagnostics, ...emptyInteractiveDiagnostics, ...tokenMisuseDiagnostics, ...accessibilityDiagnostics].map((d) => {
2230
- const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex });
2586
+ const lowercaseDiagnostics = detectNonLowercaseAttributes({
2587
+ filePath: '<markup>',
2588
+ text: html,
2589
+ cem: cemIndex,
2590
+ severity: 'warning',
2591
+ });
2592
+
2593
+ const cdnDiagnostics = detectCdnReferences({
2594
+ filePath: '<markup>',
2595
+ text: html,
2596
+ severity: 'warning',
2597
+ });
2598
+
2599
+ const scaffoldDiagnostics = detectMissingRuntimeScaffold({
2600
+ filePath: '<markup>',
2601
+ text: html,
2602
+ severity: 'warning',
2603
+ });
2604
+
2605
+ const allRawDiagnostics = [
2606
+ ...cemDiagnostics,
2607
+ ...enumDiagnostics,
2608
+ ...slotDiagnostics,
2609
+ ...requiredAttrDiagnostics,
2610
+ ...orphanDiagnostics,
2611
+ ...emptyInteractiveDiagnostics,
2612
+ ...lowercaseDiagnostics,
2613
+ ...tokenMisuseDiagnostics,
2614
+ ...accessibilityDiagnostics,
2615
+ ...cdnDiagnostics,
2616
+ ...scaffoldDiagnostics,
2617
+ ];
2618
+ const diagnostics = allRawDiagnostics.map((d) => {
2619
+ const suggestion = buildDiagnosticSuggestion({ diagnostic: d, cemIndex, prefix: p });
2231
2620
  return {
2232
2621
  file: d.file,
2233
2622
  range: d.range,
@@ -2247,6 +2636,102 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2247
2636
  },
2248
2637
  );
2249
2638
 
2639
+ // -----------------------------------------------------------------------
2640
+ // Tool: generate_full_page_html
2641
+ // -----------------------------------------------------------------------
2642
+ server.registerTool(
2643
+ 'generate_full_page_html',
2644
+ {
2645
+ description:
2646
+ 'Generate a complete, self-contained HTML page from a component HTML fragment. When: you need a preview-ready full page with <!DOCTYPE html>, importmap, and boot script. Returns: { fullHtml, componentCount, importMapEntries }. After: save to a .html file and open via a local HTTP server.',
2647
+ inputSchema: {
2648
+ html: z.string().describe('HTML fragment containing WCF custom elements'),
2649
+ prefix: z.string().optional().describe('Component prefix (default: auto-detected)'),
2650
+ },
2651
+ },
2652
+ async ({ html, prefix }) => {
2653
+ const p = normalizePrefix(prefix);
2654
+ let ci = canonicalCemIndex;
2655
+ if (p !== CANONICAL_PREFIX) {
2656
+ ci = mergeWithPrefixed(canonicalCemIndex, p);
2657
+ }
2658
+
2659
+ const { fullHtml, importEntries } = buildFullPageHtml({ html, prefix: p, cemIndex: ci });
2660
+
2661
+ return {
2662
+ content: [{
2663
+ type: 'text',
2664
+ text: JSON.stringify({
2665
+ fullHtml,
2666
+ componentCount: Object.keys(importEntries).length,
2667
+ importMapEntries: importEntries,
2668
+ }, null, 2),
2669
+ }],
2670
+ };
2671
+ },
2672
+ );
2673
+
2674
+ // -----------------------------------------------------------------------
2675
+ // Tool: get_component_selector_guide
2676
+ // -----------------------------------------------------------------------
2677
+ let selectorGuideData = null;
2678
+ try {
2679
+ selectorGuideData = await loadJson('component-selector-guide.json');
2680
+ } catch {
2681
+ // component-selector-guide.json may not exist yet
2682
+ }
2683
+
2684
+ server.registerTool(
2685
+ 'get_component_selector_guide',
2686
+ {
2687
+ description:
2688
+ 'Get a component selection guide organized by UI category and use case. When: deciding which component to use for a UI requirement. Returns: categories with recommended components and use cases. After: use get_component_api for the selected component details.',
2689
+ inputSchema: {
2690
+ category: z.string().optional().describe('Filter by category key (e.g., "Form", "Navigation", "Layout")'),
2691
+ useCase: z.string().optional().describe('Search by use-case keyword (e.g., "date", "login", "upload")'),
2692
+ },
2693
+ },
2694
+ async ({ category, useCase }) => {
2695
+ if (!selectorGuideData || !Array.isArray(selectorGuideData.categories)) {
2696
+ return {
2697
+ content: [{ type: 'text', text: JSON.stringify({ error: 'Component selector guide not available.' }) }],
2698
+ isError: true,
2699
+ };
2700
+ }
2701
+
2702
+ let categories = selectorGuideData.categories;
2703
+
2704
+ // Filter by category
2705
+ if (typeof category === 'string' && category.trim()) {
2706
+ const cat = category.trim().toLowerCase();
2707
+ categories = categories.filter((c) => c.key.toLowerCase() === cat);
2708
+ }
2709
+
2710
+ // Filter by use case keyword
2711
+ if (typeof useCase === 'string' && useCase.trim()) {
2712
+ const kw = useCase.trim().toLowerCase();
2713
+ categories = categories.map((c) => ({
2714
+ ...c,
2715
+ components: c.components.filter((comp) =>
2716
+ comp.useCase.toLowerCase().includes(kw) ||
2717
+ comp.id.toLowerCase().includes(kw) ||
2718
+ comp.tagName.toLowerCase().includes(kw)
2719
+ ),
2720
+ })).filter((c) => c.components.length > 0);
2721
+ }
2722
+
2723
+ return buildJsonToolResponse({
2724
+ totalCategories: categories.length,
2725
+ categories: categories.map((c) => ({
2726
+ key: c.key,
2727
+ label: c.label,
2728
+ description: c.description,
2729
+ components: c.components,
2730
+ })),
2731
+ });
2732
+ },
2733
+ );
2734
+
2250
2735
  // -----------------------------------------------------------------------
2251
2736
  // Tool: list_patterns
2252
2737
  // -----------------------------------------------------------------------
@@ -2271,6 +2756,70 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2271
2756
  },
2272
2757
  );
2273
2758
 
2759
+ // -----------------------------------------------------------------------
2760
+ // Helper: buildFullPageHtmlFromImportMap
2761
+ // -----------------------------------------------------------------------
2762
+ function escapeHtmlTitle(s) {
2763
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2764
+ }
2765
+
2766
+ /**
2767
+ * Build import map entries from a component closure.
2768
+ * @param {string[]} closure - Component IDs
2769
+ * @param {Object} components - Component metadata (from install registry)
2770
+ * @param {string} prefix - Tag name prefix
2771
+ * @param {string} dir - Directory placeholder or concrete path
2772
+ * @returns {Object} Import map entries { prefixedTag: path }
2773
+ */
2774
+ function buildImportMapEntries(closure, components, prefix, dir) {
2775
+ return Object.fromEntries(
2776
+ closure.flatMap((cid) => {
2777
+ const meta = components[cid];
2778
+ const tags = Array.isArray(meta?.tags) ? meta.tags : [cid];
2779
+ return tags.map((t) => {
2780
+ const lower = String(t).toLowerCase();
2781
+ const suffix = lower.replace(PREFIX_STRIP_RE, '');
2782
+ return [withPrefix(lower, prefix), `./${dir}/components/${suffix}.js`];
2783
+ });
2784
+ }),
2785
+ );
2786
+ }
2787
+
2788
+ /**
2789
+ * Build a complete HTML5 page from pattern data, resolving scaffoldHint
2790
+ * placeholders into concrete paths.
2791
+ * @param {Object} opts
2792
+ * @param {string} opts.html - Pattern HTML body
2793
+ * @param {string} opts.title - Page title
2794
+ * @param {Object} opts.importMapEntries - Import map entries { tag: path }
2795
+ * @param {string} [opts.dir] - Directory for JS assets
2796
+ * @param {string} [opts.lang='ja'] - HTML lang attribute
2797
+ * @returns {string} Complete HTML5 document
2798
+ */
2799
+ function buildFullPageHtmlFromImportMap({ html, title, importMapEntries, dir = VENDOR_DIR, lang = 'ja' }) {
2800
+ const importMapJson = JSON.stringify({ imports: importMapEntries }, null, 2);
2801
+ return [
2802
+ '<!DOCTYPE html>',
2803
+ `<html lang="${lang}">`,
2804
+ '<head>',
2805
+ ' <meta charset="UTF-8">',
2806
+ ' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
2807
+ ` <title>${escapeHtmlTitle(title)}</title>`,
2808
+ ` <!-- distribution: selfHosted=true, strategy=vendor-importmap -->`,
2809
+ ` <!-- Do NOT replace these local paths with CDN URLs. This design system is self-hosted. -->`,
2810
+ ` <script type="importmap">`,
2811
+ `${importMapJson}`,
2812
+ ` </script>`,
2813
+ ` <script type="module" src="./${dir}/boot.js"></script>`,
2814
+ '</head>',
2815
+ '<body>',
2816
+ ` <noscript>このページの機能にはJavaScriptが必要です。</noscript>`,
2817
+ ` ${html}`,
2818
+ '</body>',
2819
+ '</html>',
2820
+ ].join('\n');
2821
+ }
2822
+
2274
2823
  // -----------------------------------------------------------------------
2275
2824
  // Tool: get_pattern_recipe
2276
2825
  // -----------------------------------------------------------------------
@@ -2278,13 +2827,14 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2278
2827
  'get_pattern_recipe',
2279
2828
  {
2280
2829
  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.',
2830
+ '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
2831
  inputSchema: {
2283
2832
  patternId: z.string(),
2284
2833
  prefix: z.string().optional(),
2834
+ include: z.array(z.enum(['fullPage'])).optional(),
2285
2835
  },
2286
2836
  },
2287
- async ({ patternId, prefix }) => {
2837
+ async ({ patternId, prefix, include }) => {
2288
2838
  const id = String(patternId ?? '').trim();
2289
2839
  const p = normalizePrefix(prefix);
2290
2840
  const pat = patterns[id];
@@ -2315,28 +2865,60 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2315
2865
  const canonicalHtml = String(pat.html ?? '');
2316
2866
  const html = applyPrefixToHtml(canonicalHtml, p);
2317
2867
 
2868
+ const entryHints = Array.isArray(pat.entryHints) ? [...pat.entryHints] : ['boot'];
2869
+
2870
+ const importMapEntries = buildImportMapEntries(closure, components, p, '<dir>');
2871
+
2872
+ const scaffoldHint = {
2873
+ doctype: '<!DOCTYPE html>',
2874
+ importMap: `<script type="importmap">\n${JSON.stringify({ imports: importMapEntries }, null, 2)}\n</script>`,
2875
+ bootScript: '<script type="module" src="./<dir>/boot.js"></script>',
2876
+ noscript: '<noscript>このページの機能にはJavaScriptが必要です。</noscript>',
2877
+ 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.',
2878
+ };
2879
+
2880
+ // Build fullPageHtml when requested via include: ['fullPage']
2881
+ const includeArr = Array.isArray(include) ? include : [];
2882
+ let fullPageHtml;
2883
+ if (includeArr.includes('fullPage')) {
2884
+ const resolvedImportMap = buildImportMapEntries(closure, components, p, VENDOR_DIR);
2885
+ fullPageHtml = buildFullPageHtmlFromImportMap({
2886
+ html,
2887
+ title: pat.title ?? pat.id,
2888
+ importMapEntries: resolvedImportMap,
2889
+ });
2890
+ }
2891
+
2892
+ const result = {
2893
+ pattern: {
2894
+ id: pat.id,
2895
+ title: pat.title,
2896
+ description: pat.description,
2897
+ },
2898
+ prefix: p,
2899
+ requires,
2900
+ components: closure,
2901
+ install,
2902
+ html,
2903
+ canonicalHtml,
2904
+ installHint: closure.length > 0 ? `wcf add ${closure.join(' ')}` : undefined,
2905
+ entryHints,
2906
+ scaffoldHint,
2907
+ behavior: typeof pat.behavior === 'string' ? pat.behavior : undefined,
2908
+ };
2909
+
2910
+ if (fullPageHtml !== undefined) {
2911
+ result.fullPageHtml = fullPageHtml;
2912
+ result.vendorSetup = {
2913
+ command: `npx web-components-factory init --prefix ${p} --dir ${VENDOR_DIR} && npx web-components-factory add ${closure.join(' ')} --prefix ${p} --out ${VENDOR_DIR}`,
2914
+ };
2915
+ }
2916
+
2318
2917
  return {
2319
2918
  content: [
2320
2919
  {
2321
2920
  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
- ),
2921
+ text: JSON.stringify(result, null, 2),
2340
2922
  },
2341
2923
  ],
2342
2924
  };
@@ -2433,6 +3015,12 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2433
3015
  isError: true,
2434
3016
  };
2435
3017
  }
3018
+ // Enrich referencedBy with component tagNames from CEM cssProperties
3019
+ const normalizedName = normalizeTokenIdentifier(name);
3020
+ const componentRefs = componentTokenRefMap.get(normalizedName);
3021
+ if (componentRefs && componentRefs.size > 0) {
3022
+ payload.componentReferencedBy = [...componentRefs].sort();
3023
+ }
2436
3024
  return buildJsonToolResponse(payload);
2437
3025
  },
2438
3026
  );
@@ -2562,25 +3150,35 @@ export async function createMcpServer(loadJsonData, loadValidator, options = {})
2562
3150
  // Snippet match: weight 1
2563
3151
  if (snippet.includes(q)) score += 1;
2564
3152
 
2565
- // Body text match: weight 1
2566
- if (body && body.includes(q)) score += 1;
3153
+ // Body text match: weight 1, plus boost for multiple occurrences
3154
+ if (body && body.includes(q)) {
3155
+ score += 1;
3156
+ // Count additional occurrences in body for boost (cap at +2)
3157
+ let idx = body.indexOf(q);
3158
+ let occurrences = 0;
3159
+ while (idx !== -1 && occurrences < 3) {
3160
+ occurrences++;
3161
+ idx = body.indexOf(q, idx + q.length);
3162
+ }
3163
+ if (occurrences > 1) score += Math.min(occurrences - 1, 2);
3164
+ }
2567
3165
 
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++) {
3166
+ // Synonym expansion match: check all expanded terms, cap total synonym contribution at +2
3167
+ if (expandedTerms.length > 1) {
3168
+ let synScore = 0;
3169
+ const lowerKeywords = keywords.map((kw) => String(kw).toLowerCase());
3170
+ for (let i = 1; i < expandedTerms.length && synScore < 2; i++) {
2571
3171
  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;
3172
+ if (heading.includes(syn)) { synScore += 1; continue; }
3173
+ if (snippet.includes(syn) || body.includes(syn)) { synScore += 1; continue; }
3174
+ for (const kw of lowerKeywords) {
3175
+ if (kw.includes(syn)) {
3176
+ synScore += 1;
2579
3177
  break;
2580
3178
  }
2581
3179
  }
2582
- if (score > 0) break;
2583
3180
  }
3181
+ score += synScore;
2584
3182
  }
2585
3183
 
2586
3184
  if (score > 0) {