@bytechain.cn/colamd-themes 0.3.2 → 0.5.1

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 (99) hide show
  1. package/.claude/skills/colamd-themes-preview.skill.md +333 -0
  2. package/.claude/skills/colamd-themes.skill.md +10 -0
  3. package/.dsh/skills/colamd-themes/SKILL.md +14 -0
  4. package/.github/supported-topics.yml +17 -0
  5. package/.hermes/skills/tools/colamd-themes/SKILL.md +14 -0
  6. package/.openclaw/skills/colamd-themes/SKILL.md +1 -37
  7. package/.opencode/skills/colamd-themes/SKILL.md +17 -0
  8. package/.trae/rules/colamd-themes.md +0 -36
  9. package/CHANGELOG.md +331 -241
  10. package/DSH_SUPPORT_SUMMARY.md +87 -0
  11. package/README.md +414 -384
  12. package/README_CN.md +412 -384
  13. package/RELEASENOTE.md +541 -389
  14. package/dist/cli.js +36 -55
  15. package/dist/cli.js.map +1 -1
  16. package/dist/color-utils.d.ts +1 -0
  17. package/dist/color-utils.js +25 -6
  18. package/dist/color-utils.js.map +1 -1
  19. package/dist/extractors/pdf-extractor.js +6 -1
  20. package/dist/extractors/pdf-extractor.js.map +1 -1
  21. package/dist/extractors/url-extractor.d.ts +0 -4
  22. package/dist/extractors/url-extractor.js +13 -126
  23. package/dist/extractors/url-extractor.js.map +1 -1
  24. package/dist/generator.d.ts +1 -1
  25. package/dist/generator.js +32 -11
  26. package/dist/generator.js.map +1 -1
  27. package/dist/models.d.ts +5 -4
  28. package/dist/models.js +2 -3
  29. package/dist/models.js.map +1 -1
  30. package/dist/renderer.d.ts +6 -3
  31. package/dist/renderer.js +54 -93
  32. package/dist/renderer.js.map +1 -1
  33. package/dist/templates/template.css +220 -498
  34. package/dist/templates/theme-paradigm.md +530 -658
  35. package/dist/templates/theme-template.hbs +256 -370
  36. package/dist/theme-store.js +19 -3
  37. package/dist/theme-store.js.map +1 -1
  38. package/dist/utils/document-paradigm.d.ts +12 -0
  39. package/dist/utils/document-paradigm.js +109 -0
  40. package/dist/utils/document-paradigm.js.map +1 -0
  41. package/dist/validators.d.ts +4 -1
  42. package/dist/validators.js +115 -1
  43. package/dist/validators.js.map +1 -1
  44. package/mcp-server/dist/index.d.ts +13 -0
  45. package/mcp-server/dist/index.d.ts.map +1 -0
  46. package/mcp-server/dist/index.js +0 -0
  47. package/mcp-server/dist/index.js.map +1 -0
  48. package/mcp-server/package-lock.json +1759 -0
  49. package/mcp-server/package.json +24 -0
  50. package/mcp-server/src/index.ts +0 -0
  51. package/mcp-server/tsconfig.json +19 -0
  52. package/package.json +54 -36
  53. package/skills/build-skills.sh +30 -5
  54. package/skills/colamd-themes/SKILL.md +180 -178
  55. package/src/cli.ts +474 -487
  56. package/src/color-systems.ts +289 -289
  57. package/src/color-utils.ts +137 -120
  58. package/src/contrast.ts +66 -66
  59. package/src/docx-style-paradigm.ts +811 -811
  60. package/src/export-cli.ts +356 -356
  61. package/src/extractors/base.ts +8 -8
  62. package/src/extractors/docx-extractor.ts +417 -417
  63. package/src/extractors/pdf-extractor.ts +533 -527
  64. package/src/extractors/url-extractor.ts +846 -981
  65. package/src/generator.ts +197 -169
  66. package/src/index.ts +29 -29
  67. package/src/models.ts +212 -212
  68. package/src/renderer.ts +310 -359
  69. package/src/templates/template.css +220 -498
  70. package/src/templates/theme-paradigm.md +530 -658
  71. package/src/templates/theme-template.hbs +256 -370
  72. package/src/theme-store.ts +185 -170
  73. package/src/types/pdf-parse.d.ts +12 -12
  74. package/src/utils/cli-utils.ts +84 -84
  75. package/src/utils/document-paradigm.ts +118 -0
  76. package/src/validators.ts +298 -176
  77. package/themes/academic-paper-pt.css +353 -1805
  78. package/themes/academic-paper.css +350 -1099
  79. package/themes/academic-style.css +342 -2430
  80. package/themes/colly.css +256 -370
  81. package/themes/elegant.css +288 -671
  82. package/themes/forest-ink.css +240 -664
  83. package/themes/guizang.css +250 -732
  84. package/themes/hermes-security.css +279 -0
  85. package/themes/meeting-minutes.css +256 -370
  86. package/themes/pixso-design.css +278 -1261
  87. package/themes/salvator-blog.css +256 -370
  88. package/themes/shimo-theme.css +256 -0
  89. package/themes/swiss-design.css +285 -746
  90. package/themes/template.css +220 -498
  91. package/themes/trae-blue.css +280 -737
  92. package/themes/word-contract.css +249 -0
  93. package/themes-preview/demo-content.md +187 -187
  94. package/tsconfig.json +18 -18
  95. package/src/mermaid-presets.ts +0 -142
  96. package/themes/swiss-design copy.css +0 -746
  97. package/themes-preview/colly-preview.html +0 -623
  98. package/themes-preview/meeting-minutes-preview.html +0 -625
  99. package/themes-preview/salvator-blog-preview.html +0 -623
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared document paradigm utilities used by all extractors.
3
+ * Extracted from url-extractor, docx-extractor, and pdf-extractor
4
+ * to eliminate code duplication.
5
+ */
6
+
7
+ import type { ThemeStyle } from "../models.js";
8
+ import type { DocumentStyleParadigm } from "../docx-style-paradigm.js";
9
+ import {
10
+ ACADEMIC_PAPER_DEFAULTS,
11
+ validateDocumentStyle,
12
+ } from "../docx-style-paradigm.js";
13
+
14
+ export function themeToParadigm(theme: ThemeStyle): DocumentStyleParadigm {
15
+ const baseFontSize = parseInt(theme.typography.baseFontSize) || 16;
16
+
17
+ return {
18
+ pageBackground: theme.palette.bgColor,
19
+ headings: {
20
+ h1: {
21
+ fontFamily: theme.seedTypography.fontHeading,
22
+ fontSize: 16 * theme.seedTypography.fontScaleH1,
23
+ fontWeight: theme.typography.headingWeight,
24
+ color: theme.palette.headingColor,
25
+ lineHeight: theme.seedTypography.lineHeightHeading,
26
+ },
27
+ h2: {
28
+ fontFamily: theme.seedTypography.fontHeading,
29
+ fontSize: 16 * theme.seedTypography.fontScaleH2,
30
+ fontWeight: Math.max(600, theme.typography.headingWeight - 100),
31
+ color: theme.palette.headingColor,
32
+ lineHeight: theme.seedTypography.lineHeightHeading,
33
+ },
34
+ h3: {
35
+ fontFamily: theme.seedTypography.fontHeading,
36
+ fontSize: 16 * theme.seedTypography.fontScaleH3,
37
+ fontWeight: 600,
38
+ color: theme.palette.headingColor,
39
+ lineHeight: theme.seedTypography.lineHeightHeading + 0.05,
40
+ },
41
+ h4: {
42
+ fontFamily: theme.seedTypography.fontHeading,
43
+ fontSize: 16 * theme.seedTypography.fontScaleH4,
44
+ fontWeight: 600,
45
+ color: theme.palette.textMuted,
46
+ lineHeight: theme.seedTypography.lineHeightHeading + 0.05,
47
+ },
48
+ h5: {
49
+ fontFamily: theme.seedTypography.fontHeading,
50
+ fontSize: 16 * theme.seedTypography.fontScaleH5,
51
+ fontWeight: 500,
52
+ color: theme.palette.textMuted,
53
+ lineHeight: theme.seedTypography.lineHeightBody - 0.25,
54
+ },
55
+ h6: {
56
+ fontFamily: theme.seedTypography.fontHeading,
57
+ fontSize: 16 * theme.seedTypography.fontScaleH6,
58
+ fontWeight: 500,
59
+ color: theme.palette.textMuted,
60
+ lineHeight: theme.seedTypography.lineHeightBody - 0.2,
61
+ },
62
+ },
63
+ body: {
64
+ fontFamily: theme.seedTypography.fontBody,
65
+ fontSize: baseFontSize,
66
+ fontWeight: 400,
67
+ color: theme.palette.textColor,
68
+ lineHeight: theme.typography.baseLineHeight,
69
+ },
70
+ blockquote: {
71
+ fontFamily: theme.seedTypography.fontBody,
72
+ fontSize: Math.max(13, baseFontSize - 2),
73
+ fontWeight: 400,
74
+ color: theme.palette.textMuted,
75
+ lineHeight: theme.typography.baseLineHeight - 0.15,
76
+ borderLeftColor: theme.palette.blockquoteBorder,
77
+ bgColor: theme.palette.blockquoteBg,
78
+ },
79
+ codeBlock: {
80
+ fontFamily: theme.typography.codeFontFamily,
81
+ fontSize: Math.max(12, baseFontSize - 3),
82
+ fontWeight: 400,
83
+ color: theme.palette.codeBlockText || theme.palette.textColor,
84
+ lineHeight: theme.seedTypography.lineHeightCode,
85
+ bgColor: theme.palette.codeBlockBg,
86
+ },
87
+ inlineCode: {
88
+ fontSize: Math.max(12, baseFontSize - 3),
89
+ color: theme.palette.accentColor,
90
+ bgColor: theme.palette.codeBg,
91
+ },
92
+ table: {
93
+ headerFontFamily: theme.seedTypography.fontHeading,
94
+ headerFontSize: Math.max(12, baseFontSize - 3),
95
+ headerFontWeight: 700,
96
+ headerColor: "#ffffff",
97
+ headerBgColor: theme.palette.tableHeaderBg,
98
+ headerLineHeight: 1.4,
99
+ cellFontFamily: theme.seedTypography.fontBody,
100
+ cellFontSize: Math.max(11, baseFontSize - 4),
101
+ cellColor: theme.palette.textColor,
102
+ cellBgColor: theme.palette.bgColor,
103
+ cellLineHeight: 1.5,
104
+ cellPadding: "8px 12px",
105
+ borderColor: theme.palette.borderColor,
106
+ borderWidth: "1px",
107
+ },
108
+ accentColor: theme.palette.accentColor,
109
+ mutedTextColor: theme.palette.textMuted,
110
+ selectionBg: theme.palette.selectionBg,
111
+ };
112
+ }
113
+
114
+ export function logExtractorWarnings(extractorName: string, issues: { element: string; message: string }[]): void {
115
+ for (const issue of issues) {
116
+ console.warn(`[${extractorName}] ${issue.element}: ${issue.message}`);
117
+ }
118
+ }
package/src/validators.ts CHANGED
@@ -1,176 +1,298 @@
1
- /**
2
- * Paradigm validation engine — checks SeedPalette against v3.0 design constraints.
3
- * Based on theme-paradigm.md Section 2.
4
- */
5
-
6
- import { contrastRatio, hslSaturation, hslLightness, hslHue, sameHueAxis, isLightMode } from "./contrast.js";
7
-
8
- export interface ValidationIssue {
9
- ruleId: string;
10
- level: "MUST" | "MUST_NOT" | "SHOULD" | "SHOULD_NOT";
11
- message: string;
12
- fix?: string;
13
- }
14
-
15
- export interface SeedPalette {
16
- accent: string;
17
- accentLight: string;
18
- accentDark: string;
19
- surface: string;
20
- panel: string;
21
- panelAlt: string;
22
- ink: string;
23
- inkMuted: string;
24
- inkDim: string;
25
- border: string;
26
- borderStrong: string;
27
- }
28
-
29
- export function validateSeedPalette(palette: SeedPalette): ValidationIssue[] {
30
- const issues: ValidationIssue[] = [];
31
- const light = isLightMode(palette.surface);
32
-
33
- // CR-01: ink vs surface ≥ 7:1
34
- const crInk = contrastRatio(palette.ink, palette.surface);
35
- if (crInk < 7.0) {
36
- issues.push({
37
- ruleId: "CR-01",
38
- level: "MUST",
39
- message: `正文-底色对比度 ${crInk.toFixed(1)}:1 < 7:1 (WCAG AAA)`,
40
- fix: `建议加深 ink 或调浅 surface`,
41
- });
42
- }
43
-
44
- // CR-02: inkMuted vs surface ≥ 4.5:1
45
- const crMuted = contrastRatio(palette.inkMuted, palette.surface);
46
- if (crMuted < 4.5) {
47
- issues.push({
48
- ruleId: "CR-02",
49
- level: "MUST",
50
- message: `次要文字-底色对比度 ${crMuted.toFixed(1)}:1 < 4.5:1 (WCAG AA)`,
51
- });
52
- }
53
-
54
- // CR-04: accent vs surface ≥ 4.5:1
55
- const crAccent = contrastRatio(palette.accent, palette.surface);
56
- if (crAccent < 4.5) {
57
- issues.push({
58
- ruleId: "CR-04",
59
- level: "MUST",
60
- message: `点缀色-底色对比度 ${crAccent.toFixed(1)}:1 < 4.5:1`,
61
- });
62
- }
63
-
64
- // SL-01: surface saturation ≤ 0.05
65
- const sSurface = hslSaturation(palette.surface);
66
- if (sSurface > 0.05) {
67
- issues.push({
68
- ruleId: "SL-01",
69
- level: "MUST",
70
- message: `底色饱和度 ${(sSurface * 100).toFixed(1)}% > 5%,页面会偏色`,
71
- });
72
- }
73
-
74
- // SL-02: panel/panelAlt saturation ≤ 0.10
75
- for (const [name, hex] of [["panel", palette.panel], ["panelAlt", palette.panelAlt]] as const) {
76
- const s = hslSaturation(hex);
77
- if (s > 0.10) {
78
- issues.push({
79
- ruleId: "SL-02",
80
- level: "MUST_NOT",
81
- message: `表面色 ${name} 饱和度 ${(s * 100).toFixed(1)}% > 10%`,
82
- });
83
- }
84
- }
85
-
86
- // SL-03: surface/panel/panelAlt same hue axis
87
- if (!sameHueAxis(palette.surface, palette.panel, 5) || !sameHueAxis(palette.surface, palette.panelAlt, 5)) {
88
- issues.push({
89
- ruleId: "SL-03",
90
- level: "SHOULD",
91
- message: "三层表面色应保持相同色相轴(H 偏差 ≤ 5°)",
92
- });
93
- }
94
-
95
- // SL-04: L(surface) > L(panel) > L(panelAlt) for light
96
- const lSurface = hslLightness(palette.surface);
97
- const lPanel = hslLightness(palette.panel);
98
- const lPanelAlt = hslLightness(palette.panelAlt);
99
- if (light) {
100
- if (!(lSurface > lPanel && lPanel > lPanelAlt)) {
101
- issues.push({
102
- ruleId: "SL-04",
103
- level: "MUST",
104
- message: `浅色主题要求 L(surface) > L(panel) > L(panelAlt)`,
105
- });
106
- }
107
- } else {
108
- if (!(lSurface < lPanel && lPanel < lPanelAlt)) {
109
- issues.push({
110
- ruleId: "SL-04",
111
- level: "MUST",
112
- message: `深色主题要求 L(surface) < L(panel) < L(panelAlt)`,
113
- });
114
- }
115
- }
116
-
117
- // NT-01: ink/inkMuted/inkDim same hue axis
118
- if (!sameHueAxis(palette.ink, palette.inkMuted, 3) || !sameHueAxis(palette.ink, palette.inkDim, 3)) {
119
- issues.push({
120
- ruleId: "NT-01",
121
- level: "MUST",
122
- message: "文字色必须位于同一色相轴(H 偏差 ≤ 3°)",
123
- });
124
- }
125
-
126
- // NT-02: border vs panel lightness diff ≥ 0.08
127
- const lBorder = hslLightness(palette.border);
128
- if (Math.abs(lBorder - lPanel) < 0.08) {
129
- issues.push({
130
- ruleId: "NT-02",
131
- level: "MUST",
132
- message: `边框色与 panel 明度差 ${Math.abs(lBorder - lPanel).toFixed(3)} < 0.08,边框不可见`,
133
- });
134
- }
135
-
136
- // NT-03: border should be neutral gray
137
- const sBorder = hslSaturation(palette.border);
138
- if (sBorder > 0.05) {
139
- issues.push({
140
- ruleId: "NT-03",
141
- level: "SHOULD",
142
- message: `边框色饱和度 ${(sBorder * 100).toFixed(1)}% > 5%,应使用中性灰`,
143
- });
144
- }
145
-
146
- // HR-01: accent saturation ≥ 0.30 for light themes
147
- const sAccent = hslSaturation(palette.accent);
148
- if (light && sAccent < 0.30) {
149
- issues.push({
150
- ruleId: "HR-01",
151
- level: "SHOULD",
152
- message: `点缀色饱和度 ${(sAccent * 100).toFixed(1)}% < 30%,建议选暖色或冷色`,
153
- });
154
- }
155
-
156
- return issues;
157
- }
158
-
159
- /** Summarize validation results. */
160
- export function summarizeIssues(issues: ValidationIssue[]): string {
161
- const errors = issues.filter((i) => i.level === "MUST" || i.level === "MUST_NOT");
162
- const warnings = issues.filter((i) => i.level === "SHOULD" || i.level === "SHOULD_NOT");
163
- const lines: string[] = [];
164
- if (errors.length > 0) {
165
- lines.push(`ERRORS (${errors.length}):`);
166
- for (const e of errors) lines.push(` [${e.ruleId}] ${e.message}`);
167
- }
168
- if (warnings.length > 0) {
169
- lines.push(`WARNINGS (${warnings.length}):`);
170
- for (const w of warnings) lines.push(` [${w.ruleId}] ${w.message}`);
171
- }
172
- if (errors.length === 0 && warnings.length === 0) {
173
- lines.push("All validations passed.");
174
- }
175
- return lines.join("\n");
176
- }
1
+ /**
2
+ * Paradigm validation engine — checks SeedPalette against v4.0 design constraints
3
+ * and scans generated CSS for the ColaMD 2.7.8-rc.1 theme contract.
4
+ * Based on theme-paradigm.md Section 2.
5
+ */
6
+
7
+ import { parse, walk, generate } from "css-tree";
8
+ import { contrastRatio, hslSaturation, hslLightness, hslHue, sameHueAxis, isLightMode } from "./contrast.js";
9
+
10
+ export interface ValidationIssue {
11
+ ruleId: string;
12
+ level: "MUST" | "MUST_NOT" | "SHOULD" | "SHOULD_NOT";
13
+ message: string;
14
+ fix?: string;
15
+ }
16
+
17
+ export interface SeedPalette {
18
+ accent: string;
19
+ accentLight: string;
20
+ accentDark: string;
21
+ surface: string;
22
+ panel: string;
23
+ panelAlt: string;
24
+ ink: string;
25
+ inkMuted: string;
26
+ inkDim: string;
27
+ border: string;
28
+ borderStrong: string;
29
+ }
30
+
31
+ export function validateSeedPalette(palette: SeedPalette): ValidationIssue[] {
32
+ const issues: ValidationIssue[] = [];
33
+ const light = isLightMode(palette.surface);
34
+
35
+ // CR-01: ink vs surface ≥ 7:1
36
+ const crInk = contrastRatio(palette.ink, palette.surface);
37
+ if (crInk < 7.0) {
38
+ issues.push({
39
+ ruleId: "CR-01",
40
+ level: "MUST",
41
+ message: `正文-底色对比度 ${crInk.toFixed(1)}:1 < 7:1 (WCAG AAA)`,
42
+ fix: `建议加深 ink 或调浅 surface`,
43
+ });
44
+ }
45
+
46
+ // CR-02: inkMuted vs surface ≥ 4.5:1
47
+ const crMuted = contrastRatio(palette.inkMuted, palette.surface);
48
+ if (crMuted < 4.5) {
49
+ issues.push({
50
+ ruleId: "CR-02",
51
+ level: "MUST",
52
+ message: `次要文字-底色对比度 ${crMuted.toFixed(1)}:1 < 4.5:1 (WCAG AA)`,
53
+ });
54
+ }
55
+
56
+ // CR-04: accent vs surface ≥ 4.5:1
57
+ const crAccent = contrastRatio(palette.accent, palette.surface);
58
+ if (crAccent < 4.5) {
59
+ issues.push({
60
+ ruleId: "CR-04",
61
+ level: "MUST",
62
+ message: `点缀色-底色对比度 ${crAccent.toFixed(1)}:1 < 4.5:1`,
63
+ });
64
+ }
65
+
66
+ // SL-01: surface saturation ≤ 0.05
67
+ const sSurface = hslSaturation(palette.surface);
68
+ if (sSurface > 0.05) {
69
+ issues.push({
70
+ ruleId: "SL-01",
71
+ level: "MUST",
72
+ message: `底色饱和度 ${(sSurface * 100).toFixed(1)}% > 5%,页面会偏色`,
73
+ });
74
+ }
75
+
76
+ // SL-02: panel/panelAlt saturation ≤ 0.10
77
+ for (const [name, hex] of [["panel", palette.panel], ["panelAlt", palette.panelAlt]] as const) {
78
+ const s = hslSaturation(hex);
79
+ if (s > 0.10) {
80
+ issues.push({
81
+ ruleId: "SL-02",
82
+ level: "MUST_NOT",
83
+ message: `表面色 ${name} 饱和度 ${(s * 100).toFixed(1)}% > 10%`,
84
+ });
85
+ }
86
+ }
87
+
88
+ // SL-03: surface/panel/panelAlt same hue axis
89
+ if (!sameHueAxis(palette.surface, palette.panel, 5) || !sameHueAxis(palette.surface, palette.panelAlt, 5)) {
90
+ issues.push({
91
+ ruleId: "SL-03",
92
+ level: "SHOULD",
93
+ message: "三层表面色应保持相同色相轴(H 偏差 ≤ 5°)",
94
+ });
95
+ }
96
+
97
+ // SL-04: L(surface) > L(panel) > L(panelAlt) for light
98
+ const lSurface = hslLightness(palette.surface);
99
+ const lPanel = hslLightness(palette.panel);
100
+ const lPanelAlt = hslLightness(palette.panelAlt);
101
+ if (light) {
102
+ if (!(lSurface > lPanel && lPanel > lPanelAlt)) {
103
+ issues.push({
104
+ ruleId: "SL-04",
105
+ level: "MUST",
106
+ message: `浅色主题要求 L(surface) > L(panel) > L(panelAlt)`,
107
+ });
108
+ }
109
+ } else {
110
+ if (!(lSurface < lPanel && lPanel < lPanelAlt)) {
111
+ issues.push({
112
+ ruleId: "SL-04",
113
+ level: "MUST",
114
+ message: `深色主题要求 L(surface) < L(panel) < L(panelAlt)`,
115
+ });
116
+ }
117
+ }
118
+
119
+ // NT-01: ink/inkMuted/inkDim same hue axis
120
+ if (!sameHueAxis(palette.ink, palette.inkMuted, 3) || !sameHueAxis(palette.ink, palette.inkDim, 3)) {
121
+ issues.push({
122
+ ruleId: "NT-01",
123
+ level: "MUST",
124
+ message: "文字色必须位于同一色相轴(H 偏差 ≤ 3°)",
125
+ });
126
+ }
127
+
128
+ // NT-02: border vs panel lightness diff ≥ 0.08
129
+ const lBorder = hslLightness(palette.border);
130
+ if (Math.abs(lBorder - lPanel) < 0.08) {
131
+ issues.push({
132
+ ruleId: "NT-02",
133
+ level: "MUST",
134
+ message: `边框色与 panel 明度差 ${Math.abs(lBorder - lPanel).toFixed(3)} < 0.08,边框不可见`,
135
+ });
136
+ }
137
+
138
+ // NT-03: border should be neutral gray
139
+ const sBorder = hslSaturation(palette.border);
140
+ if (sBorder > 0.05) {
141
+ issues.push({
142
+ ruleId: "NT-03",
143
+ level: "SHOULD",
144
+ message: `边框色饱和度 ${(sBorder * 100).toFixed(1)}% > 5%,应使用中性灰`,
145
+ });
146
+ }
147
+
148
+ // HR-01: accent saturation ≥ 0.30 for light themes
149
+ const sAccent = hslSaturation(palette.accent);
150
+ if (light && sAccent < 0.30) {
151
+ issues.push({
152
+ ruleId: "HR-01",
153
+ level: "SHOULD",
154
+ message: `点缀色饱和度 ${(sAccent * 100).toFixed(1)}% < 30%,建议选暖色或冷色`,
155
+ });
156
+ }
157
+
158
+ return issues;
159
+ }
160
+
161
+ // ═══════════════════════════════════════════════════════════
162
+ // CSS contract validation (ColaMD 2.7.8-rc.1)
163
+ // ═══════════════════════════════════════════════════════════
164
+
165
+ /** 2.7 编辑器不再消费的变量:留着只会让人照抄。 */
166
+ const DEAD_VARIABLES = new Set([
167
+ "--surface-color", "--surface-alt", "--text-dim", "--heading-color",
168
+ "--accent-color", "--accent-dark", "--accent-light", "--hr-color",
169
+ "--code-color", "--code-block-color", "--table-border",
170
+ "--content-width", "--font-size-root", "--font-heading", "--font-code",
171
+ "--font-mermaid", "--font-size-mermaid",
172
+ ]);
173
+
174
+ /** 前缀匹配的死变量族(Mermaid 20 变量、圆角、字阶、行高、滚动条)。 */
175
+ const DEAD_VARIABLE_PREFIXES = [
176
+ "--mermaid-", "--radius-", "--font-scale-", "--line-height-", "--scrollbar-thumb",
177
+ ];
178
+
179
+ /** 旧编辑器 DOM 的选择器:在 CodeMirror 6 里命中不到任何元素。 */
180
+ const DEAD_SELECTOR_PATTERNS: Array<{ re: RegExp; label: string; fix: string }> = [
181
+ { re: /(^|[\s>+~,])#write(?![-\w])/, label: "#write", fix: "改用 #editor .cm-content" },
182
+ { re: /\.ProseMirror\b/, label: ".ProseMirror", fix: "改用 #editor .cm-content .cm-md-*" },
183
+ { re: /\.theme-custom\b/, label: "body.theme-custom", fix: "自定义主题变量写在 :root" },
184
+ { re: /\.mermaid-(block|preview)\b/, label: ".mermaid-*", fix: "图表用 .cm-md-mermaid 与 --pele-*" },
185
+ ];
186
+
187
+ /** 引用块/代码块在 CM6 里一行一个 div,纵向 padding 会按行累加。 */
188
+ function hasVerticalPadding(rule: { block: { children: { forEach: (cb: (node: any) => void) => void } } }): boolean {
189
+ let found = false;
190
+ rule.block.children.forEach((node: any) => {
191
+ if (node.type !== "Declaration" || node.property.startsWith("--")) return;
192
+ if (node.property === "padding") {
193
+ const values = generate(node.value).trim().split(/\s+/);
194
+ // 1~2 个值:第一个是纵向;3 个值:第二个;4 个值:第一个与第三个。
195
+ const vertical = values.length === 1 ? values[0]
196
+ : values.length === 2 ? values[0]
197
+ : values.length === 3 ? values[1] : `${values[0]} ${values[2]}`;
198
+ if (vertical.split(/\s+/).some((v) => !/^0(px|em|rem|%)?$/.test(v))) found = true;
199
+ } else if (node.property === "padding-top" || node.property === "padding-bottom") {
200
+ const value = generate(node.value).trim();
201
+ if (!/^0(px|em|rem|%)?$/.test(value)) found = true;
202
+ }
203
+ });
204
+ return found;
205
+ }
206
+
207
+ /** 校验生成的 CSS 是否符合 ColaMD 2.7.8-rc.1 契约。 */
208
+ export function validateThemeCss(css: string): ValidationIssue[] {
209
+ const issues: ValidationIssue[] = [];
210
+ const ast = parse(css, { positions: false });
211
+ let hasRootBlock = false;
212
+
213
+ walk(ast, (node) => {
214
+ if (node.type === "Declaration" && node.property.startsWith("--")) {
215
+ const name = node.property;
216
+ const dead = DEAD_VARIABLES.has(name)
217
+ || DEAD_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix));
218
+ if (dead) {
219
+ issues.push({
220
+ ruleId: "CSS-01",
221
+ level: "MUST_NOT",
222
+ message: `变量 ${name} 在 ColaMD 2.7.8-rc.1 契约中已不再消费`,
223
+ fix: "改用活变量清单(--bg-color / --text-secondary / --pele-* 等)",
224
+ });
225
+ }
226
+ }
227
+
228
+ if (node.type === "Rule" && node.prelude.type === "SelectorList") {
229
+ const selector = generate(node.prelude);
230
+ if (/(^|,)\s*:root\b/.test(selector)) hasRootBlock = true;
231
+
232
+ for (const dead of DEAD_SELECTOR_PATTERNS) {
233
+ if (dead.re.test(selector)) {
234
+ issues.push({
235
+ ruleId: "CSS-02",
236
+ level: "MUST_NOT",
237
+ message: `选择器 ${dead.label} 在 CodeMirror 6 中命中不到元素(${selector})`,
238
+ fix: dead.fix,
239
+ });
240
+ }
241
+ }
242
+
243
+ if (/\.cm-md-(blockquote|codeblock)\b/.test(selector) && hasVerticalPadding(node)) {
244
+ issues.push({
245
+ ruleId: "CSS-03",
246
+ level: "MUST_NOT",
247
+ message: `引用/代码块不能设置纵向 padding(每行一个 div,会按行累加):${selector}`,
248
+ fix: "只设 padding-left / padding-right",
249
+ });
250
+ }
251
+
252
+ walk(node.block, (child) => {
253
+ if (child.type === "Declaration" && child.value.type === "Value") {
254
+ walk(child.value, (valueNode) => {
255
+ if (valueNode.type === "Dimension" && valueNode.unit === "pt") {
256
+ issues.push({
257
+ ruleId: "CSS-04",
258
+ level: "MUST_NOT",
259
+ message: `使用了 pt 单位:${child.property}: ${generate(child.value)}`,
260
+ fix: "统一换算为 px(1pt ≈ 1.333px)",
261
+ });
262
+ }
263
+ });
264
+ }
265
+ });
266
+ }
267
+ });
268
+
269
+ if (!hasRootBlock) {
270
+ issues.push({
271
+ ruleId: "CSS-05",
272
+ level: "MUST",
273
+ message: "缺少 :root 根块,主题变量无法被继承",
274
+ fix: "把变量块写到 :root 上",
275
+ });
276
+ }
277
+
278
+ return issues;
279
+ }
280
+
281
+ /** Summarize validation results. */
282
+ export function summarizeIssues(issues: ValidationIssue[]): string {
283
+ const errors = issues.filter((i) => i.level === "MUST" || i.level === "MUST_NOT");
284
+ const warnings = issues.filter((i) => i.level === "SHOULD" || i.level === "SHOULD_NOT");
285
+ const lines: string[] = [];
286
+ if (errors.length > 0) {
287
+ lines.push(`ERRORS (${errors.length}):`);
288
+ for (const e of errors) lines.push(` [${e.ruleId}] ${e.message}`);
289
+ }
290
+ if (warnings.length > 0) {
291
+ lines.push(`WARNINGS (${warnings.length}):`);
292
+ for (const w of warnings) lines.push(` [${w.ruleId}] ${w.message}`);
293
+ }
294
+ if (errors.length === 0 && warnings.length === 0) {
295
+ lines.push("All validations passed.");
296
+ }
297
+ return lines.join("\n");
298
+ }