@q1travel/design-system 0.9.0 → 0.10.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/bin/q1-design.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // read-only cache under the consumer repo; `verify` proves that cache was
5
5
  // never hand-edited and still matches the installed package version.
6
6
 
7
+ import { execFileSync } from 'node:child_process';
7
8
  import { createRequire } from 'node:module';
8
9
  import {
9
10
  cpSync,
@@ -19,7 +20,7 @@ import {
19
20
  } from 'node:fs';
20
21
  import { createHash } from 'node:crypto';
21
22
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
- import { fileURLToPath } from 'node:url';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
24
 
24
25
  const PACKAGE_NAME = '@q1travel/design-system';
25
26
  const SKILL_RELATIVE_DIR = '.claude/skills/product-design';
@@ -30,8 +31,160 @@ const CONTRACTS_SOURCE_SUBPATH = 'contracts';
30
31
  const LINT_EXTENSIONS = new Set(['.css', '.js', '.jsx', '.ts', '.tsx', '.mjs']);
31
32
  const DASH_DS_PATTERN = /--ds-/;
32
33
  const TRANSITION_ALL_PATTERN = /transition(?:-property)?\s*:\s*all\b/;
33
- const HEX_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
34
- const COLOR_FUNCTION_PATTERN = /\b(?:rgb|rgba|hsl)\s*\(/i;
34
+ // Q1-DS-501 的匹配面(issue #130)。此前是两条无语法感知的裸正则,两个方向都错:
35
+ // 假阳——`url(#fade)`、CSS 注释里的 `#142` 这类非颜色的 # 引用被当成 hex 色值;
36
+ // 假阴——`hsla()` / `oklch()` / CSS 命名色完全不在匹配面内。
37
+ // 但收紧必须避开一个陷阱:`oklch(from var(--q1-color-status-success) …)` 与
38
+ // `color-mix(in srgb, var(--x) …)` 是**从 token 派生**,不是裸字面量,本就不该报。
39
+ // 因此判定不能只看函数名,还要看实参里有没有 var()。
40
+ const HEX_COLOR_PATTERN = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/;
41
+ const COLOR_FUNCTION_NAMES = 'rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch';
42
+ const COLOR_FUNCTION_PATTERN = new RegExp(`\\b(?:${COLOR_FUNCTION_NAMES})\\s*\\(`, 'i');
43
+ const URL_CALL_PATTERN = /\burl\s*\([^)]*\)/gi;
44
+ const LINE_COMMENT_PATTERN = /\/\/.*$/;
45
+ // W3C CSS 命名色全表。只在值位置(冒号之后)匹配,避免命中 `.tomato-card` 这类标识符。
46
+ const NAMED_COLORS = new Set(
47
+ ('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood ' +
48
+ 'cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod ' +
49
+ 'darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon ' +
50
+ 'darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray ' +
51
+ 'dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green ' +
52
+ 'greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon ' +
53
+ 'lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon ' +
54
+ 'lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen ' +
55
+ 'magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue ' +
56
+ 'mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy ' +
57
+ 'oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip ' +
58
+ 'peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown ' +
59
+ 'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal ' +
60
+ 'thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '),
61
+ );
62
+
63
+ // 逐行扫描时维护块注释状态:注释里的 `#142`、`#deadbeef` 不是颜色。
64
+ export function stripCssNoise(lineContent, commentState) {
65
+ let out = '';
66
+ let index = 0;
67
+ while (index < lineContent.length) {
68
+ if (commentState.inBlockComment) {
69
+ const close = lineContent.indexOf('*/', index);
70
+ if (close === -1) return out;
71
+ commentState.inBlockComment = false;
72
+ index = close + 2;
73
+ continue;
74
+ }
75
+ const open = lineContent.indexOf('/*', index);
76
+ if (open === -1) {
77
+ out += lineContent.slice(index);
78
+ break;
79
+ }
80
+ out += lineContent.slice(index, open);
81
+ commentState.inBlockComment = true;
82
+ index = open + 2;
83
+ }
84
+ // url(...) 里的 # 是片段引用,不是颜色;行注释同理。
85
+ return out.replace(LINE_COMMENT_PATTERN, '').replace(URL_CALL_PATTERN, 'url()');
86
+ }
87
+
88
+ // 实参里出现 var() 即视为从 token 派生,不是裸字面量。
89
+ function colorFunctionIsRaw(lineContent) {
90
+ const match = COLOR_FUNCTION_PATTERN.exec(lineContent);
91
+ if (!match) return false;
92
+ const argsStart = lineContent.indexOf('(', match.index);
93
+ let depth = 0;
94
+ for (let index = argsStart; index < lineContent.length; index += 1) {
95
+ if (lineContent[index] === '(') depth += 1;
96
+ else if (lineContent[index] === ')') {
97
+ depth -= 1;
98
+ if (depth === 0) {
99
+ return !/\bvar\s*\(/.test(lineContent.slice(argsStart, index + 1));
100
+ }
101
+ }
102
+ }
103
+ // 括号未闭合(跨行声明):保守地按未派生处理,宁可报出来让人看一眼。
104
+ return !/\bvar\s*\(/.test(lineContent.slice(argsStart));
105
+ }
106
+
107
+ function hasNamedColorValue(lineContent) {
108
+ const colon = lineContent.indexOf(':');
109
+ if (colon === -1) return false;
110
+ const value = lineContent.slice(colon + 1);
111
+ if (/\bvar\s*\(/.test(value)) return false;
112
+ return (value.match(/[a-zA-Z]+/g) ?? []).some((word) => NAMED_COLORS.has(word.toLowerCase()));
113
+ }
114
+
115
+ export { createSuppressionTracker, parsePragma };
116
+
117
+ export function findsRawColorLiteral(lineContent, commentState) {
118
+ const scannable = stripCssNoise(lineContent, commentState);
119
+ if (!scannable.trim()) return false;
120
+ if (HEX_COLOR_PATTERN.test(scannable)) return true;
121
+ if (colorFunctionIsRaw(scannable)) return true;
122
+ return hasNamedColorValue(scannable);
123
+ }
124
+
125
+ // Q1-DS-404:运动值必须来自 token。匹配面只认 transition / animation 简写与
126
+ // transition-duration / transition-timing-function / animation-duration /
127
+ // animation-timing-function 这几个**值位置**,不扫全文——`width: 0.15s` 不存在,
128
+ // 但 `grid-template-columns: 200ms` 这种误报要靠限定属性名挡住。
129
+ //
130
+ // 判定为裸值的两种情况:
131
+ // 时长——值里出现 <number>s / <number>ms 且该数值不是来自 var();
132
+ // 缓动——出现 cubic-bezier(),或出现 CSS 关键字缓动(ease / ease-in / …)。
133
+ // `linear` 与 `steps()` 不算裸缓动:它们是离散/恒速的功能性选择,没有可调的house 曲线形状,
134
+ // 逼它们走 token 只会造出恒等别名,或造出一条无处可迁的规则。
135
+ const MOTION_PROPERTY_PATTERN =
136
+ /(?:^|[;{\s])(transition|animation)(?:-(?:duration|timing-function))?\s*:/i;
137
+ const BARE_DURATION_PATTERN = /(?:^|[^\w.-])\d*\.?\d+m?s(?![\w-])/;
138
+ const BARE_EASING_PATTERN = /\bcubic-bezier\s*\(|\bease(?:-in-out|-in|-out)?\b/;
139
+ const VAR_CALL_PATTERN = /\bvar\s*\([^()]*(?:\([^()]*\)[^()]*)*\)/g;
140
+
141
+ // 顶层逗号切段:`cubic-bezier(0.23, 1, 0.32, 1)` 内部也有逗号,按深度切才不会把一条缓动切碎。
142
+ function splitMotionSegments(declaration) {
143
+ const segments = [];
144
+ let depth = 0;
145
+ let start = 0;
146
+ for (let index = 0; index < declaration.length; index += 1) {
147
+ const character = declaration[index];
148
+ if (character === '(') depth += 1;
149
+ else if (character === ')') depth -= 1;
150
+ else if (character === ',' && depth === 0) {
151
+ segments.push(declaration.slice(start, index));
152
+ start = index + 1;
153
+ }
154
+ }
155
+ segments.push(declaration.slice(start));
156
+ return segments;
157
+ }
158
+
159
+ export function findsRawMotionLiteral(lineContent, commentState) {
160
+ const scannable = stripCssNoise(lineContent, commentState);
161
+
162
+ // 逐行扫描,但判定按**整条声明**做,理由有二:
163
+ // `animation:\n spinner-fade 1.2s\n linear infinite;` 的 infinite 落在后续行,
164
+ // 按行判会把中间那行误报;
165
+ // 反过来 `animation: pulse 2s ease-in-out infinite, fade 0.3s ease;` 里 infinite 只属于
166
+ // 前一支,按行判会连带放过后一支。攒满一条再判,两个方向都对,且一条声明只报一次。
167
+ if (commentState.motionValue == null) {
168
+ const propertyMatch = MOTION_PROPERTY_PATTERN.exec(scannable);
169
+ if (!propertyMatch) return false;
170
+ commentState.motionValue = scannable.slice(scannable.indexOf(':', propertyMatch.index) + 1);
171
+ } else {
172
+ commentState.motionValue += ` ${scannable}`;
173
+ }
174
+ if (!/[;}]/.test(commentState.motionValue)) return false;
175
+
176
+ const declaration = commentState.motionValue;
177
+ commentState.motionValue = null;
178
+ return splitMotionSegments(declaration).some((segment) => {
179
+ // infinite 循环动画不判,且只豁免它自己那一支。时长 token 是**响应预算**——衡量
180
+ // 「用户动作到界面回应完毕」有多久;无限循环按定义不是对输入的响应,它的周期是该组件
181
+ // 自己的节奏(spinner 1.2s、skeleton 1.6s),塞进 120–300ms 只会压坏它,或逼出假 token。
182
+ if (/\binfinite\b/.test(segment)) return false;
183
+ // 值来自 var() 时不判——`transition: var(--q1-navigation-chevron-transition)` 是合法组件 token。
184
+ const withoutVars = segment.replace(VAR_CALL_PATTERN, '');
185
+ return BARE_DURATION_PATTERN.test(withoutVars) || BARE_EASING_PATTERN.test(withoutVars);
186
+ });
187
+ }
35
188
 
36
189
  function resolveInstalledPackageRoot(consumerRootDir) {
37
190
  const require = createRequire(join(consumerRootDir, 'package.json'));
@@ -401,8 +554,55 @@ function isTokensPath(absolutePath, baseDir) {
401
554
  return toPosixPath(relative(baseDir, absolutePath)).includes('tokens');
402
555
  }
403
556
 
404
- function hasDisablePragma(fileContent, ruleId) {
405
- return new RegExp(`q1-lint-disable\\s+${ruleId}\\b`).test(fileContent);
557
+ // 抑制 pragma(issue #130 清理时重做粒度)。原实现只有文件级:整份文件里任意位置出现
558
+ // `q1-lint-disable <RULE>` 就关掉该文件的规则。对真实代码库过粗——一处刻意例外会让整个文件失明,
559
+ // 而 components.css 这类大文件正是刻意例外最集中的地方。
560
+ //
561
+ // 三种形态:
562
+ // q1-lint-disable-file <RULE> <理由> 整份文件
563
+ // q1-lint-disable-next-line <RULE> <理由> 仅下一行
564
+ // q1-lint-disable <RULE> <理由> … q1-lint-enable <RULE> 区间
565
+ //
566
+ // 硬规则:**没写理由的抑制不生效**。抑制本身若无解释,就只是把债务换了个地方藏。
567
+ const PRAGMA_PATTERN =
568
+ /q1-lint-(disable-file|disable-next-line|disable|enable)\s+([A-Z0-9-]+)\b[ \t]*([^*\n]*)/;
569
+
570
+ function parsePragma(lineContent, ruleId) {
571
+ const match = PRAGMA_PATTERN.exec(lineContent);
572
+ if (!match) return null;
573
+ const [, kind, matchedRuleId, rawReason] = match;
574
+ if (matchedRuleId !== ruleId) return null;
575
+ const reason = rawReason.replace(/\*\/\s*$/, '').trim();
576
+ if (kind !== 'enable' && !reason) return { kind: 'invalid', reason: '' };
577
+ return { kind, reason };
578
+ }
579
+
580
+ function hasFileDisablePragma(fileContent, ruleId) {
581
+ return fileContent
582
+ .split('\n')
583
+ .some((lineContent) => parsePragma(lineContent, ruleId)?.kind === 'disable-file');
584
+ }
585
+
586
+ // 返回一个「该行是否被抑制」的判定器;调用方必须逐行喂,以维护区间与 next-line 状态。
587
+ function createSuppressionTracker(ruleId) {
588
+ let inDisabledRange = false;
589
+ let skipNextLine = false;
590
+
591
+ return (lineContent) => {
592
+ const suppressedByPreviousLine = skipNextLine;
593
+ skipNextLine = false;
594
+
595
+ const pragma = parsePragma(lineContent, ruleId);
596
+ if (pragma) {
597
+ if (pragma.kind === 'disable-next-line') skipNextLine = true;
598
+ else if (pragma.kind === 'disable') inDisabledRange = true;
599
+ else if (pragma.kind === 'enable') inDisabledRange = false;
600
+ // pragma 行本身不是代码行,不参与判定。
601
+ return true;
602
+ }
603
+
604
+ return suppressedByPreviousLine || inDisabledRange;
605
+ };
406
606
  }
407
607
 
408
608
  function truncateLine(lineContent, maxLength = 100) {
@@ -412,6 +612,35 @@ function truncateLine(lineContent, maxLength = 100) {
412
612
  : trimmed;
413
613
  }
414
614
 
615
+ // 三条规则统一走同一套「是否适用 + 是否被抑制」流程。此前只有 Q1-DS-501 认 pragma,
616
+ // Q1-DS-001 / Q1-DS-401 无论写什么注释都拦不住——包括规则命中讲述该规则的文档这种自指误报。
617
+ const LINT_RULES = [
618
+ {
619
+ ruleId: 'Q1-DS-001',
620
+ appliesToFile: () => true,
621
+ matches: (lineContent) => DASH_DS_PATTERN.test(lineContent),
622
+ },
623
+ {
624
+ ruleId: 'Q1-DS-401',
625
+ appliesToFile: () => true,
626
+ matches: (lineContent) => TRANSITION_ALL_PATTERN.test(lineContent),
627
+ },
628
+ {
629
+ ruleId: 'Q1-DS-404',
630
+ // 与 Q1-DS-501 同款豁免:tokens.css 是这些字面量的**定义处**,规则不能咬住自己的源头。
631
+ appliesToFile: ({ isCssFile, isTokens }) => isCssFile && !isTokens,
632
+ createState: () => ({ inBlockComment: false, motionValue: null }),
633
+ matches: (lineContent, state) => findsRawMotionLiteral(lineContent, state),
634
+ },
635
+ {
636
+ ruleId: 'Q1-DS-501',
637
+ appliesToFile: ({ isCssFile, isTokens }) => isCssFile && !isTokens,
638
+ // 该规则需要跨行的块注释状态,故用 createState 携带。
639
+ createState: () => ({ inBlockComment: false }),
640
+ matches: (lineContent, state) => findsRawColorLiteral(lineContent, state),
641
+ },
642
+ ];
643
+
415
644
  function collectLintHits(inputPaths, baseDir) {
416
645
  const targetFiles = collectLintTargetFiles(inputPaths, baseDir);
417
646
  const hits = [];
@@ -419,41 +648,27 @@ function collectLintHits(inputPaths, baseDir) {
419
648
  for (const absoluteFilePath of targetFiles) {
420
649
  const fileContent = readFileSync(absoluteFilePath, 'utf8');
421
650
  const relativeFilePath = toPosixPath(relative(baseDir, absoluteFilePath));
422
- const isCssFile = extname(absoluteFilePath) === '.css';
423
- const q1ds501Applicable =
424
- isCssFile &&
425
- !isTokensPath(absoluteFilePath, baseDir) &&
426
- !hasDisablePragma(fileContent, 'Q1-DS-501');
651
+ const fileFacts = {
652
+ isCssFile: extname(absoluteFilePath) === '.css',
653
+ isTokens: isTokensPath(absoluteFilePath, baseDir),
654
+ };
655
+
656
+ const activeRules = LINT_RULES.map((rule) => ({
657
+ rule,
658
+ applicable: rule.appliesToFile(fileFacts) && !hasFileDisablePragma(fileContent, rule.ruleId),
659
+ state: rule.createState?.(),
660
+ isSuppressed: createSuppressionTracker(rule.ruleId),
661
+ }));
427
662
 
428
663
  fileContent.split('\n').forEach((lineContent, index) => {
429
664
  const lineNumber = index + 1;
430
- if (DASH_DS_PATTERN.test(lineContent)) {
431
- hits.push({
432
- ruleId: 'Q1-DS-001',
433
- filePath: relativeFilePath,
434
- lineNumber,
435
- lineContent,
436
- });
437
- }
438
- if (TRANSITION_ALL_PATTERN.test(lineContent)) {
439
- hits.push({
440
- ruleId: 'Q1-DS-401',
441
- filePath: relativeFilePath,
442
- lineNumber,
443
- lineContent,
444
- });
445
- }
446
- if (
447
- q1ds501Applicable &&
448
- (HEX_COLOR_PATTERN.test(lineContent) ||
449
- COLOR_FUNCTION_PATTERN.test(lineContent))
450
- ) {
451
- hits.push({
452
- ruleId: 'Q1-DS-501',
453
- filePath: relativeFilePath,
454
- lineNumber,
455
- lineContent,
456
- });
665
+ for (const { rule, applicable, state, isSuppressed } of activeRules) {
666
+ // 匹配与抑制状态都必须逐行推进,因此无论该规则是否适用本文件都要各过一遍,
667
+ // 否则块注释与区间抑制的状态会在跳过的行上错位。
668
+ const matched = rule.matches(lineContent, state);
669
+ const suppressed = isSuppressed(lineContent);
670
+ if (!applicable || !matched || suppressed) continue;
671
+ hits.push({ ruleId: rule.ruleId, filePath: relativeFilePath, lineNumber, lineContent });
457
672
  }
458
673
  });
459
674
  }
@@ -461,8 +676,39 @@ function collectLintHits(inputPaths, baseDir) {
461
676
  return hits;
462
677
  }
463
678
 
464
- function runLint(inputPaths, consumerRootDir) {
465
- const hits = collectLintHits(inputPaths, consumerRootDir);
679
+ // --diff <base>:只扫相对 base 有改动的文件(issue #130 缺口 1)。
680
+ //
681
+ // 存量包袱重的仓库无法把 lint 当门禁——q1travel-web 有上万处 legacy --ds-*,全量扫描一开就淹没。
682
+ // 但止血不需要先还完存量:只拦新增就够,而「新增」用 git diff 现算即可。
683
+ //
684
+ // 刻意不做票面写的 baseline 快照:快照要生成、要更新、会和现实分叉,凭空多出一份需要维护的状态;
685
+ // 而 CI 本来就知道 base ref。--diff 用远小的成本覆盖同一个用途。
686
+ export function selectChangedFiles(inputPaths, baseRef, runGit) {
687
+ const output = runGit(['diff', '--no-renames', '--name-only', `${baseRef}...HEAD`]);
688
+ const changed = output.split('\n').map((line) => line.trim()).filter(Boolean);
689
+ if (changed.length === 0) return [];
690
+ // 与全量模式同源:改动集要和 inputPaths 求交,否则 --diff 反而会把扫描面扩大到范围之外。
691
+ const scopes = inputPaths.filter(Boolean);
692
+ if (scopes.length === 0) return changed;
693
+ return changed.filter((changedPath) =>
694
+ scopes.some((scope) => changedPath === scope || changedPath.startsWith(`${scope}/`)),
695
+ );
696
+ }
697
+
698
+ function runLint(inputPaths, consumerRootDir, diffBaseRef) {
699
+ let scanPaths = inputPaths;
700
+ if (diffBaseRef) {
701
+ const changed = selectChangedFiles(inputPaths, diffBaseRef, (args) =>
702
+ execFileSync('git', args, { cwd: consumerRootDir, encoding: 'utf8' }),
703
+ ).filter((path) => LINT_EXTENSIONS.has(extname(path)) && existsSync(join(consumerRootDir, path)));
704
+ if (changed.length === 0) {
705
+ console.log(`[q1-design] lint ✓(相对 ${diffBaseRef} 无可扫描的改动文件)`);
706
+ return;
707
+ }
708
+ scanPaths = changed;
709
+ }
710
+
711
+ const hits = collectLintHits(scanPaths, consumerRootDir);
466
712
 
467
713
  if (hits.length === 0) {
468
714
  console.log('[q1-design] lint ✓');
@@ -531,27 +777,172 @@ function runEval() {
531
777
  }
532
778
  }
533
779
 
780
+ // —— ls / doc ——
781
+ // 组件清单与 API 不另建一份数据文件:它们**现推自随包发布的 dist-pkg/design-system.d.ts**。
782
+ // 另建数据文件意味着多一处会与真实导出面分叉的东西,而分叉的那天没人会发现——
783
+ // .d.ts 由 tsc 从源码生成,它不可能对不上。
784
+ //
785
+ // 存在的理由是「查阅便捷」:消费方(人或 agent)想知道有哪些组件、某个组件收什么 props,
786
+ // 不该被迫开浏览器翻文档站,也不该去 node_modules 里翻六万行 .d.ts。
787
+ const TYPES_RELATIVE_PATH = 'dist-pkg/design-system.d.ts';
788
+
789
+ function resolveTypesPath(consumerRootDir) {
790
+ const packageRootDir = resolveInstalledPackageRoot(consumerRootDir);
791
+ const candidates = [
792
+ packageRootDir && join(packageRootDir, TYPES_RELATIVE_PATH),
793
+ // 本仓直跑:没有「安装的自己」,退回到自己的构建产物。
794
+ join(consumerRootDir, TYPES_RELATIVE_PATH),
795
+ ].filter(Boolean);
796
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
797
+ }
798
+
799
+ // 逐条切出顶层声明。按大括号深度收尾,因为 props 类型体经常跨几十行;
800
+ // 单行声明(type 别名、函数签名)以 `;` 收尾。
801
+ export function parseTypeDeclarations(typesSource) {
802
+ const declarations = [];
803
+ const lines = typesSource.split('\n');
804
+ for (let index = 0; index < lines.length; index += 1) {
805
+ // 未导出的 `declare type` 也收:Button 的 props 形状就住在未导出的 ButtonProps 里,
806
+ // 而 `doc Button` 不给出 props 形状就没有存在价值。exported 标记保留,供调用方区分。
807
+ const match = /^(export )?declare (?:function|type|const|class) ([A-Za-z0-9_]+)/.exec(lines[index]);
808
+ if (!match) continue;
809
+ const body = [lines[index]];
810
+ let depth = (lines[index].match(/\{/g) ?? []).length - (lines[index].match(/\}/g) ?? []).length;
811
+ while (depth > 0 && index + 1 < lines.length) {
812
+ index += 1;
813
+ body.push(lines[index]);
814
+ depth += (lines[index].match(/\{/g) ?? []).length - (lines[index].match(/\}/g) ?? []).length;
815
+ }
816
+ const words = match[0].split(' ');
817
+ declarations.push({
818
+ name: match[2],
819
+ kind: words[match[1] ? 2 : 1],
820
+ exported: Boolean(match[1]),
821
+ text: body.join('\n'),
822
+ });
823
+ }
824
+ return declarations;
825
+ }
826
+
827
+ // 组件 = 首字母大写的导出函数。小写导出(addDays、useToast 之类)是工具与 hook,
828
+ // 单列一档而不是混进组件清单——把它们混在一起,清单就不再回答「有哪些组件」这个问题了。
829
+ const isComponentName = (name) => /^[A-Z]/.test(name);
830
+
831
+ function loadDeclarations(consumerRootDir) {
832
+ const typesPath = resolveTypesPath(consumerRootDir);
833
+ if (!typesPath) {
834
+ console.error(`[q1-design] 找不到 ${TYPES_RELATIVE_PATH}。请先安装 ${PACKAGE_NAME},或在本仓先跑 npm run build。`);
835
+ process.exit(1);
836
+ }
837
+ return parseTypeDeclarations(readFileSync(typesPath, 'utf8'));
838
+ }
839
+
840
+ function runList(consumerRootDir) {
841
+ const declarations = loadDeclarations(consumerRootDir);
842
+ const components = declarations
843
+ .filter(({ kind, name, exported }) => exported && kind === 'function' && isComponentName(name))
844
+ .map(({ name }) => name)
845
+ .sort();
846
+ const hooks = declarations
847
+ .filter(({ kind, name, exported }) => exported && kind === 'function' && !isComponentName(name))
848
+ .map(({ name }) => name)
849
+ .sort();
850
+
851
+ console.log(`组件 (${components.length})`);
852
+ for (const name of components) console.log(` ${name}`);
853
+ if (hooks.length > 0) {
854
+ console.log(`\n工具与 hook (${hooks.length})`);
855
+ console.log(` ${hooks.join(' ')}`);
856
+ }
857
+ console.log(`\n查看某个组件的 API:q1-design doc <名称>`);
858
+ }
859
+
860
+ function runDoc(consumerRootDir, requestedName) {
861
+ if (!requestedName) {
862
+ console.error('Usage: q1-design doc <组件名>');
863
+ process.exit(1);
864
+ }
865
+ const declarations = loadDeclarations(consumerRootDir);
866
+ // 连带打印同前缀的**类型**(ButtonProps / ButtonVariant / ButtonSize…):
867
+ // 只给函数签名等于把 props 的形状藏起来,读者还得自己再翻一次。
868
+ // 但只带类型,不带同前缀的另一个组件——`doc Button` 不该顺手塞来 ButtonLink,
869
+ // 那是另一个组件,有它自己的一页。
870
+ // 同前缀的类型归属于「最长的那个同前缀组件」:ButtonLinkProps 属于 ButtonLink,
871
+ // 不该在 `doc Button` 里露面。按最长前缀判归属,不必维护一张后缀白名单。
872
+ const componentNames = declarations
873
+ .filter(({ kind, name, exported }) => exported && kind === 'function' && isComponentName(name))
874
+ .map(({ name }) => name);
875
+ const belongsElsewhere = (typeName) =>
876
+ componentNames.some(
877
+ (candidate) => candidate.length > requestedName.length && typeName.startsWith(candidate),
878
+ );
879
+ const related = declarations.filter(
880
+ ({ name, kind }) =>
881
+ name === requestedName ||
882
+ (kind === 'type' && name.startsWith(requestedName) && !belongsElsewhere(name)),
883
+ );
884
+ if (related.length === 0) {
885
+ const suggestions = declarations
886
+ .filter(({ name }) => name.toLowerCase().includes(requestedName.toLowerCase()))
887
+ .map(({ name }) => name);
888
+ console.error(`[q1-design] 没有导出名为 ${requestedName} 的成员。`);
889
+ if (suggestions.length > 0) console.error(` 是否想找:${suggestions.join(', ')}`);
890
+ else console.error(' 用 q1-design ls 看完整清单。');
891
+ process.exit(1);
892
+ }
893
+ for (const declaration of related) {
894
+ console.log(declaration.text);
895
+ console.log('');
896
+ }
897
+ }
898
+
534
899
  function printUsage() {
535
- console.error('Usage: q1-design <sync|verify|lint|eval>');
900
+ console.error('Usage: q1-design <ls|doc|sync|verify|lint|eval>');
901
+ console.error(' ls 列出随包发布的组件与 hook');
902
+ console.error(' doc <组件名> 打印该组件的公共 API(函数签名 + 同名前缀的类型)');
536
903
  console.error(
537
904
  ' lint [路径...] 扫描 .css/.js/.jsx/.ts/.tsx/.mjs,默认 src,目录递归',
538
905
  );
906
+ console.error(
907
+ ' lint --diff <base-ref> [路径...] 只扫相对 base-ref 有改动的文件(存量重的仓库用它当增量门禁)',
908
+ );
539
909
  console.error(' eval 对 tooling/scripts/evals 的 fixtures 跑 before/after 断言');
540
910
  }
541
911
 
542
- const subcommand = process.argv[2];
543
- const consumerRootDir = process.cwd();
544
-
545
- if (subcommand === 'sync') {
546
- runSync(consumerRootDir);
547
- } else if (subcommand === 'verify') {
548
- runVerify(consumerRootDir);
549
- } else if (subcommand === 'lint') {
550
- const inputPaths = process.argv.slice(3);
551
- runLint(inputPaths.length > 0 ? inputPaths : ['src'], consumerRootDir);
552
- } else if (subcommand === 'eval') {
553
- runEval();
554
- } else {
555
- printUsage();
556
- process.exit(1);
912
+ // 直跑守卫(与 scripts/ci-path-filter.mjs 同款):被 import 时只暴露纯函数,不执行 CLI,
913
+ // 这样 Q1-DS-501 的匹配逻辑可以被单测直接覆盖,而不必靠拉起子进程去反推。
914
+ const isDirectRun =
915
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
916
+
917
+ if (isDirectRun) {
918
+ const subcommand = process.argv[2];
919
+ const consumerRootDir = process.cwd();
920
+
921
+ if (subcommand === 'ls') {
922
+ runList(consumerRootDir);
923
+ } else if (subcommand === 'doc') {
924
+ runDoc(consumerRootDir, process.argv[3]);
925
+ } else if (subcommand === 'sync') {
926
+ runSync(consumerRootDir);
927
+ } else if (subcommand === 'verify') {
928
+ runVerify(consumerRootDir);
929
+ } else if (subcommand === 'lint') {
930
+ const args = process.argv.slice(3);
931
+ const diffIndex = args.indexOf('--diff');
932
+ let diffBaseRef;
933
+ if (diffIndex !== -1) {
934
+ diffBaseRef = args[diffIndex + 1];
935
+ if (!diffBaseRef || diffBaseRef.startsWith('--')) {
936
+ console.error('Usage: q1-design lint --diff <base-ref> [路径...]');
937
+ process.exit(1);
938
+ }
939
+ args.splice(diffIndex, 2);
940
+ }
941
+ runLint(args.length > 0 ? args : ['src'], consumerRootDir, diffBaseRef);
942
+ } else if (subcommand === 'eval') {
943
+ runEval();
944
+ } else {
945
+ printUsage();
946
+ process.exit(1);
947
+ }
557
948
  }
@@ -27,3 +27,12 @@ README owns the repository inventory and generated-file boundary. This file owns
27
27
  - Token references are the design source for production code. Current theme resolved values are clipboard output only.
28
28
  - The same public navigation groups remain in the product surface: `基础`, `品牌`, and `组件`.
29
29
  - If a capability is missing from a shared component, add the capability to that component before adding another page-local pattern.
30
+
31
+ ## Documentation Section Ownership
32
+
33
+ Two component-page sections are q1travel supersets with no external counterpart, so their scope is defined here rather than inferred page by page.
34
+
35
+ - **设计 Token** — every Tier B native-semantic control page (Button, Input, Textarea, Select, Checkbox, Radio, Switch) MUST carry this section. Those controls take their whole surface from semantic tokens, so the role/token/usage table is the only place a consumer can see which tokens to override. Tier A and Tier C pages MUST NOT add it by default: their appearance comes mostly from structure, and a token table there restates the global palette without telling the reader anything component-specific. **Tabs is the one recorded exception** — a Tier A component whose surface is likewise entirely token-driven; it keeps its table, and it does not license other Tier A pages to add one.
36
+ - **API 与行为矩阵** — reserved for composite host components that the consumer assembles while the host owns the state (ContextSwitcher, Navigation, Sidebar). The table exists to state who owns what; a component that owns its own state does not need it.
37
+
38
+ Rows in these tables reference semantic tokens (`--q1-color-*`, `--q1-shadow-*`, `--q1-radius-*`), not component-private custom properties. Component-private properties driven by props (for example an avatar diameter) are implementation detail and MUST NOT be documented as a consumer-facing token contract.