css-comments-to-json 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # css-comments-to-json
2
+
3
+ Generate styleguide JSON data from CSS comments.
4
+
5
+ [English](#english) / [日本語](#日本語)
6
+
7
+ ## English
8
+
9
+ This package is inspired by Hologram and KSS. It parses documentation comments written close to your CSS components and turns them into structured styleguide data.
10
+
11
+ Unlike Hologram or many KSS-style tools, this package does not try to render a complete styleguide UI by itself. It focuses on generating JSON that can be consumed by Eleventy, Astro, VitePress, or any static site generator.
12
+
13
+ ## Why
14
+
15
+ Storybook is a powerful component workshop, and it is often the right tool for application components with many states, props, interactions, and visual tests.
16
+
17
+ But not every project needs that much machinery. For static sites, CSS-first component libraries, Eleventy/Nunjucks projects, corporate websites, landing pages, and CMS-oriented builds, a full component workshop can be more than the project needs.
18
+
19
+ `css-comments-to-json` is for projects where you want:
20
+
21
+ - CSS comments to be the source of truth for component usage.
22
+ - Class tables, descriptions, variants, and HTML examples to live near the CSS.
23
+ - Structured JSON that your existing static site generator can render.
24
+ - A lightweight alternative when Storybook is more than you need.
25
+ - Documentation that is easy for both humans and AI coding agents to read.
26
+
27
+ The basic flow is:
28
+
29
+ ```txt
30
+ CSS comments
31
+ -> styleguide JSON
32
+ -> your static site generator
33
+ ```
34
+
35
+ In short: this is a lightweight styleguide data generator for CSS-first projects, not a replacement for Storybook.
36
+
37
+ ## When To Use This
38
+
39
+ Use this when:
40
+
41
+ - You are building mostly static HTML/CSS sites.
42
+ - Your components are documented by class names and HTML examples.
43
+ - You already have an SSG such as Eleventy and want to render the styleguide there.
44
+ - You want Hologram/KSS-like CSS documentation without adopting a full UI workshop.
45
+ - You want AI tools to infer existing component usage from source-adjacent documentation.
46
+
47
+ ## Install
48
+
49
+ ```sh
50
+ npm install -D css-comments-to-json
51
+ ```
52
+
53
+ ## CLI
54
+
55
+ ```sh
56
+ npx css-comments-to-json \
57
+ --input "src/assets/css/**/*.css" \
58
+ --output "src/_data" \
59
+ --prefix styleguide
60
+ ```
61
+
62
+ This writes files like:
63
+
64
+ ```txt
65
+ src/_data/styleguidecomponent.json
66
+ src/_data/styleguidebase-component.json
67
+ ```
68
+
69
+ ## CSS Comment Format
70
+
71
+ ```css
72
+ /*
73
+ * @sg-category Component
74
+ * @sg-name CTA
75
+ * @sg-description CTA component.
76
+ * @sg-table
77
+ * | Class | Description |
78
+ * |-------|-------------|
79
+ * | c-cta | CTA container |
80
+ * @sg-example
81
+ <div class="c-cta">
82
+ CTA
83
+ </div>
84
+ */
85
+ ```
86
+
87
+ Supported tags:
88
+
89
+ - `@sg-category`
90
+ - `@sg-name`
91
+ - `@sg-sub-name`
92
+ - `@sg-description`
93
+ - `@sg-table`
94
+ - `@sg-example`
95
+ - `@sg-markup`
96
+ - `@sg-variant`
97
+
98
+ ## JavaScript API
99
+
100
+ ```js
101
+ import { generateStyleguideData } from 'css-comments-to-json';
102
+
103
+ await generateStyleguideData({
104
+ input: 'src/assets/css/**/*.css',
105
+ output: 'src/_data',
106
+ prefix: 'styleguide',
107
+ });
108
+ ```
109
+
110
+ For parsing a single CSS string:
111
+
112
+ ```js
113
+ import { parseStyleguideComments } from 'css-comments-to-json';
114
+
115
+ const data = parseStyleguideComments(css);
116
+ ```
117
+
118
+ ## Options
119
+
120
+ | Option | Default | Description |
121
+ |--------|---------|-------------|
122
+ | `input` | `src/assets/css/**/*.css` | CSS input glob |
123
+ | `output` | `src/_data` | Output directory |
124
+ | `prefix` | `styleguide` | Output filename prefix |
125
+ | `cwd` | `process.cwd()` | Working directory |
126
+ | `dryRun` | `false` | Return output files without writing |
127
+ | `includeSource` | `false` | Include source file and line |
128
+
129
+ ## Config File
130
+
131
+ You can keep options in a JavaScript config file:
132
+
133
+ ```js
134
+ // css-comments-to-json.config.mjs
135
+ export default {
136
+ input: 'src/assets/css/**/*.css',
137
+ output: 'src/_data',
138
+ prefix: 'styleguide',
139
+ includeSource: true,
140
+ };
141
+ ```
142
+
143
+ Run it with:
144
+
145
+ ```sh
146
+ npx css-comments-to-json --config css-comments-to-json.config.mjs
147
+ ```
148
+
149
+ CLI flags override config file values.
150
+
151
+ ## JSON Output
152
+
153
+ Each `@sg-category` becomes one JSON file. For example, `Component` with the
154
+ default `styleguide` prefix becomes `styleguidecomponent.json`.
155
+
156
+ ```json
157
+ [
158
+ {
159
+ "name": "CTA",
160
+ "id": "cta",
161
+ "description": "CTA component.",
162
+ "children": [],
163
+ "tables": [
164
+ "| Class | Description |\n|-------|-------------|\n| c-cta | CTA container |"
165
+ ],
166
+ "examples": [
167
+ {
168
+ "name": "default",
169
+ "code": "<div class=\"c-cta\">\n CTA\n</div>"
170
+ }
171
+ ]
172
+ }
173
+ ]
174
+ ```
175
+
176
+ When `includeSource` is enabled, each component also includes `source.file` and
177
+ `source.line`.
178
+
179
+ ## Warnings
180
+
181
+ The CLI prints warnings for incomplete styleguide comments, such as:
182
+
183
+ - Missing `@sg-category`
184
+ - Missing or empty `@sg-name`
185
+ - Empty `@sg-example`, `@sg-markup`, or `@sg-table` blocks
186
+ - `@sg-sub-name`, `@sg-example`, `@sg-markup`, or `@sg-table` before `@sg-name`
187
+
188
+ The JavaScript API returns the same warnings:
189
+
190
+ ```js
191
+ const result = await generateStyleguideData({
192
+ input: 'src/assets/css/**/*.css',
193
+ });
194
+
195
+ console.log(result.warnings);
196
+ ```
197
+
198
+ ## Eleventy
199
+
200
+ Use the CLI before Eleventy builds:
201
+
202
+ ```json
203
+ {
204
+ "scripts": {
205
+ "build:css-comments-json": "css-comments-to-json --input \"src/assets/css/**/*.css\" --output \"src/_data\" --prefix styleguide",
206
+ "build": "npm run build:css-comments-json && eleventy"
207
+ }
208
+ }
209
+ ```
210
+
211
+ ## 日本語
212
+
213
+ `css-comments-to-json` は、CSS 内の構造化コメントを読み取り、JSON に変換する小さな CLI/API です。
214
+
215
+ Hologram や KSS のような「CSS コメントを実装に近い場所へ置く」思想に影響を受けています。ただし、このパッケージ自体は完全なスタイルガイド UI を生成しません。CSS コメントから JSON を生成し、その JSON を Eleventy、Astro、VitePress など任意の静的サイトジェネレータで表示することに特化しています。
216
+
217
+ ### なぜ使うのか
218
+
219
+ Storybook は強力なコンポーネントワークショップです。状態、props、インタラクション、visual test が多いアプリケーションコンポーネントでは適した選択です。
220
+
221
+ 一方で、すべてのプロジェクトに Storybook ほどの仕組みが必要とは限りません。静的サイト、CSS 中心のコンポーネント、Eleventy/Nunjucks、コーポレートサイト、LP、CMS 実装寄りの案件では、もう少し軽い仕組みの方が合うことがあります。
222
+
223
+ `css-comments-to-json` は、次のような場合に向いています。
224
+
225
+ - CSS コメントをコンポーネント利用方法の一次情報にしたい。
226
+ - class 一覧、説明、variant、HTML 例を CSS の近くに置きたい。
227
+ - 既存の静的サイトジェネレータでスタイルガイドを表示したい。
228
+ - Storybook までは不要だが、軽量なスタイルガイドデータは欲しい。
229
+ - 人間にも AI コーディングエージェントにも読みやすいコンポーネントドキュメントを作りたい。
230
+
231
+ 基本の流れは次の通りです。
232
+
233
+ ```txt
234
+ CSS コメント
235
+ -> スタイルガイド用 JSON
236
+ -> 任意の静的サイトジェネレータ
237
+ ```
238
+
239
+ つまり、これは Storybook の代替ではなく、CSS-first なプロジェクト向けの軽量な JSON 生成ツールです。
240
+
241
+ ### 向いているケース
242
+
243
+ 向いているケース:
244
+
245
+ - 静的 HTML/CSS 中心のサイトを作っている。
246
+ - コンポーネントを class 名と HTML 例で管理している。
247
+ - Eleventy などの SSG が既にあり、その中でスタイルガイドを表示したい。
248
+ - Hologram/KSS 的な CSS ドキュメントは欲しいが、重いコンポーネントワークショップは不要。
249
+ - AI に既存コンポーネントの使い方を読み取らせたい。
250
+
251
+ ### インストール
252
+
253
+ ```sh
254
+ npm install -D css-comments-to-json
255
+ ```
256
+
257
+ ### CLI
258
+
259
+ ```sh
260
+ npx css-comments-to-json \
261
+ --input "src/assets/css/**/*.css" \
262
+ --output "src/_data" \
263
+ --prefix styleguide
264
+ ```
265
+
266
+ 次のような JSON を出力します。
267
+
268
+ ```txt
269
+ src/_data/styleguidecomponent.json
270
+ src/_data/styleguidebase-component.json
271
+ ```
272
+
273
+ ### CSS コメント形式
274
+
275
+ ```css
276
+ /*
277
+ * @sg-category Component
278
+ * @sg-name CTA
279
+ * @sg-description CTA コンポーネント。
280
+ * @sg-table
281
+ * | クラス名 | 説明 |
282
+ * |----------|------|
283
+ * | c-cta | CTA コンテナ |
284
+ * @sg-example
285
+ <div class="c-cta">
286
+ CTA
287
+ </div>
288
+ */
289
+ ```
290
+
291
+ 対応タグ:
292
+
293
+ - `@sg-category`
294
+ - `@sg-name`
295
+ - `@sg-sub-name`
296
+ - `@sg-description`
297
+ - `@sg-table`
298
+ - `@sg-example`
299
+ - `@sg-markup`
300
+ - `@sg-variant`
301
+
302
+ ### JavaScript API
303
+
304
+ ```js
305
+ import { generateStyleguideData } from 'css-comments-to-json';
306
+
307
+ await generateStyleguideData({
308
+ input: 'src/assets/css/**/*.css',
309
+ output: 'src/_data',
310
+ prefix: 'styleguide',
311
+ });
312
+ ```
313
+
314
+ CSS 文字列を直接パースする場合:
315
+
316
+ ```js
317
+ import { parseStyleguideComments } from 'css-comments-to-json';
318
+
319
+ const data = parseStyleguideComments(css);
320
+ ```
321
+
322
+ ### オプション
323
+
324
+ | Option | Default | Description |
325
+ |--------|---------|-------------|
326
+ | `input` | `src/assets/css/**/*.css` | CSS 入力 glob |
327
+ | `output` | `src/_data` | 出力ディレクトリ |
328
+ | `prefix` | `styleguide` | 出力ファイル名の prefix |
329
+ | `cwd` | `process.cwd()` | 作業ディレクトリ |
330
+ | `dryRun` | `false` | ファイルを書き込まず、出力予定だけ返す |
331
+ | `includeSource` | `false` | 出力に元ファイルと行番号を含める |
332
+
333
+ ### 設定ファイル
334
+
335
+ JavaScript の設定ファイルにオプションをまとめられます。
336
+
337
+ ```js
338
+ // css-comments-to-json.config.mjs
339
+ export default {
340
+ input: 'src/assets/css/**/*.css',
341
+ output: 'src/_data',
342
+ prefix: 'styleguide',
343
+ includeSource: true,
344
+ };
345
+ ```
346
+
347
+ 次のように実行します。
348
+
349
+ ```sh
350
+ npx css-comments-to-json --config css-comments-to-json.config.mjs
351
+ ```
352
+
353
+ CLI の指定は、設定ファイルの値を上書きします。
354
+
355
+ ### JSON 出力例
356
+
357
+ `@sg-category` ごとに 1 つの JSON ファイルを生成します。たとえば
358
+ `Component` は、標準の `styleguide` prefix では `styleguidecomponent.json`
359
+ になります。
360
+
361
+ ```json
362
+ [
363
+ {
364
+ "name": "CTA",
365
+ "id": "cta",
366
+ "description": "CTA component.",
367
+ "children": [],
368
+ "tables": [
369
+ "| Class | Description |\n|-------|-------------|\n| c-cta | CTA container |"
370
+ ],
371
+ "examples": [
372
+ {
373
+ "name": "default",
374
+ "code": "<div class=\"c-cta\">\n CTA\n</div>"
375
+ }
376
+ ]
377
+ }
378
+ ]
379
+ ```
380
+
381
+ `includeSource` を有効にすると、各コンポーネントに `source.file` と
382
+ `source.line` も含まれます。
383
+
384
+ ### Warning
385
+
386
+ CLI は、不完全なスタイルガイドコメントに warning を出します。
387
+
388
+ - `@sg-category` がない
389
+ - `@sg-name` がない、または空
390
+ - `@sg-example`、`@sg-markup`、`@sg-table` の中身が空
391
+ - `@sg-name` より前に `@sg-sub-name`、`@sg-example`、`@sg-markup`、
392
+ `@sg-table` が出ている
393
+
394
+ JavaScript API でも同じ warning を取得できます。
395
+
396
+ ```js
397
+ const result = await generateStyleguideData({
398
+ input: 'src/assets/css/**/*.css',
399
+ });
400
+
401
+ console.log(result.warnings);
402
+ ```
403
+
404
+ ### Eleventy で使う
405
+
406
+ Eleventy のビルド前に CLI を実行します。
407
+
408
+ ```json
409
+ {
410
+ "scripts": {
411
+ "build:css-comments-json": "css-comments-to-json --input \"src/assets/css/**/*.css\" --output \"src/_data\" --prefix styleguide",
412
+ "build": "npm run build:css-comments-json && eleventy"
413
+ }
414
+ }
415
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "css-comments-to-json",
3
+ "version": "0.1.0",
4
+ "description": "Generate styleguide JSON data from CSS comments.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "masizime",
8
+ "sideEffects": false,
9
+ "bin": {
10
+ "css-comments-to-json": "./src/cli.js"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.js"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test",
22
+ "check": "node --check src/cli.js && node --check src/index.js && npm test",
23
+ "prepublishOnly": "npm run check"
24
+ },
25
+ "keywords": [
26
+ "css",
27
+ "comments",
28
+ "json",
29
+ "cli",
30
+ "styleguide",
31
+ "documentation",
32
+ "hologram",
33
+ "kss",
34
+ "eleventy",
35
+ "nunjucks",
36
+ "ssg",
37
+ "static-site-generator"
38
+ ],
39
+ "dependencies": {
40
+ "glob": "^13.0.6"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/tamshow/css-comments-to-json.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/tamshow/css-comments-to-json/issues"
51
+ },
52
+ "homepage": "https://github.com/tamshow/css-comments-to-json#readme"
53
+ }
package/src/cli.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from 'node:process';
4
+ import path from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { generateStyleguideData } from './index.js';
7
+
8
+ function printHelp() {
9
+ console.log(`Usage:
10
+ css-comments-to-json [options]
11
+
12
+ Options:
13
+ --input <glob> CSS input glob (default: src/assets/css/**/*.css)
14
+ --output <dir> Output directory (default: src/_data)
15
+ --prefix <name> Output filename prefix (default: styleguide)
16
+ --cwd <dir> Working directory (default: process.cwd())
17
+ --config <file> Load options from a JS config file
18
+ --source Include source file and line in output
19
+ --dry-run Print files that would be written without writing
20
+ --help Show this help
21
+
22
+ Example:
23
+ css-comments-to-json --input "src/assets/css/**/*.css" --output "src/_data"`);
24
+ }
25
+
26
+ function parseArgs(argv) {
27
+ const options = {};
28
+
29
+ for (let index = 0; index < argv.length; index += 1) {
30
+ const arg = argv[index];
31
+
32
+ if (arg === '--help' || arg === '-h') {
33
+ options.help = true;
34
+ } else if (arg === '--dry-run') {
35
+ options.dryRun = true;
36
+ } else if (arg === '--source') {
37
+ options.includeSource = true;
38
+ } else if (
39
+ arg === '--input' ||
40
+ arg === '--output' ||
41
+ arg === '--prefix' ||
42
+ arg === '--cwd' ||
43
+ arg === '--config'
44
+ ) {
45
+ const value = argv[index + 1];
46
+ if (!value || value.startsWith('--')) {
47
+ throw new Error(`${arg} requires a value`);
48
+ }
49
+ options[arg.slice(2)] = value;
50
+ index += 1;
51
+ } else {
52
+ throw new Error(`Unknown option: ${arg}`);
53
+ }
54
+ }
55
+
56
+ return options;
57
+ }
58
+
59
+ async function loadConfig(configPath, cwd) {
60
+ if (!configPath) {
61
+ return {};
62
+ }
63
+
64
+ const absolutePath = path.resolve(cwd || process.cwd(), configPath);
65
+ const configModule = await import(pathToFileURL(absolutePath).href);
66
+ const config = configModule.default || configModule;
67
+
68
+ if (!config || typeof config !== 'object') {
69
+ throw new Error(`Config must export an object: ${configPath}`);
70
+ }
71
+
72
+ return config;
73
+ }
74
+
75
+ async function main() {
76
+ const cliOptions = parseArgs(process.argv.slice(2));
77
+
78
+ if (cliOptions.help) {
79
+ printHelp();
80
+ return;
81
+ }
82
+
83
+ const config = await loadConfig(cliOptions.config, cliOptions.cwd);
84
+ const optionsFromCli = { ...cliOptions };
85
+ delete optionsFromCli.config;
86
+ delete optionsFromCli.help;
87
+ const options = {
88
+ ...config,
89
+ ...optionsFromCli,
90
+ };
91
+ const result = await generateStyleguideData(options);
92
+
93
+ for (const warning of result.warnings) {
94
+ const source = warning.source
95
+ ? `${warning.source.file}${warning.source.line ? `:${warning.source.line}` : ''}`
96
+ : 'unknown source';
97
+ console.warn(`[warn] ${source} ${warning.message}`);
98
+ }
99
+
100
+ if (options.dryRun) {
101
+ for (const outputFile of result.outputFiles) {
102
+ console.log(`[dry-run] ${outputFile.filePath}`);
103
+ }
104
+ } else {
105
+ for (const outputFile of result.outputFiles) {
106
+ console.log(`write ${outputFile.filePath}`);
107
+ }
108
+ }
109
+
110
+ console.log(
111
+ `parsed ${result.files.length} files, generated ${result.outputFiles.length} files`,
112
+ );
113
+ }
114
+
115
+ main().catch((error) => {
116
+ console.error(error.message);
117
+ process.exit(1);
118
+ });
package/src/index.js ADDED
@@ -0,0 +1,441 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { globSync } from 'glob';
4
+
5
+ export const defaultOptions = {
6
+ input: 'src/assets/css/**/*.css',
7
+ output: 'src/_data',
8
+ prefix: 'styleguide',
9
+ cwd: process.cwd(),
10
+ dryRun: false,
11
+ includeSource: false,
12
+ warnings: undefined,
13
+ };
14
+
15
+ export function slugify(value) {
16
+ return String(value)
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, '-')
19
+ .replace(/^-|-$/g, '');
20
+ }
21
+
22
+ export function removeStyleAttributes(html) {
23
+ return html.replace(/ style="[^"]*"/g, '');
24
+ }
25
+
26
+ function formatSource(source) {
27
+ if (!source) return 'unknown source';
28
+ return `${source.file}${source.line ? `:${source.line}` : ''}`;
29
+ }
30
+
31
+ function pushWarning(warnings, message, source) {
32
+ if (!warnings) return;
33
+ warnings.push({
34
+ message,
35
+ source,
36
+ });
37
+ }
38
+
39
+ function getBlockName(value) {
40
+ return value.trim().split(/\s+/)[0] || 'default';
41
+ }
42
+
43
+ function pushExample(target, currentExample, warnings, source) {
44
+ if (!target || currentExample.length === 0) return;
45
+ const code = removeStyleAttributes(currentExample.slice(1).join('\n').trim());
46
+
47
+ if (!code) {
48
+ pushWarning(warnings, '@sg-example has no example body', source);
49
+ }
50
+
51
+ target.examples = target.examples || [];
52
+ target.examples.push({
53
+ name: getBlockName(currentExample[0]),
54
+ code,
55
+ });
56
+ }
57
+
58
+ function pushMarkup(target, currentMarkup, warnings, source) {
59
+ if (!target || currentMarkup.length === 0) return;
60
+ const code = removeStyleAttributes(currentMarkup.slice(1).join('\n').trim());
61
+
62
+ if (!code) {
63
+ pushWarning(warnings, '@sg-markup has no markup body', source);
64
+ }
65
+
66
+ target.markup = target.markup || [];
67
+ target.markup.push({
68
+ name: getBlockName(currentMarkup[0]),
69
+ code,
70
+ });
71
+ }
72
+
73
+ function pushTable(target, currentTable, warnings, source) {
74
+ if (!target || currentTable.length === 0) return;
75
+ const table = currentTable.join('\n').trim();
76
+
77
+ if (!table) {
78
+ pushWarning(warnings, '@sg-table has no table body', source);
79
+ }
80
+
81
+ target.tables = target.tables || [];
82
+ target.tables.push(table);
83
+ }
84
+
85
+ function getCommentStartLine(content, commentStartIndex) {
86
+ return content.slice(0, commentStartIndex).split('\n').length;
87
+ }
88
+
89
+ function normalizeCommentLines(comment) {
90
+ return comment
91
+ .split('\n')
92
+ .map((line) => line.trim().replace(/^\*\s?/, ''))
93
+ .filter((line) => line !== '*/' && line !== '/');
94
+ }
95
+
96
+ function getOrCreateComponent(categoryMap, category, name, source) {
97
+ const parentId = slugify(name);
98
+ if (!categoryMap[category]) categoryMap[category] = [];
99
+
100
+ let component = categoryMap[category].find((item) => item.name === name);
101
+
102
+ if (!component) {
103
+ component = {
104
+ name,
105
+ id: parentId,
106
+ children: [],
107
+ };
108
+
109
+ if (source) {
110
+ component.source = source;
111
+ }
112
+
113
+ categoryMap[category].push(component);
114
+ }
115
+
116
+ return component;
117
+ }
118
+
119
+ export function parseStyleguideComment(comment, categoryMap, options = {}) {
120
+ const { source, warningSource = source, warnings } = options;
121
+ const lines = normalizeCommentLines(comment);
122
+
123
+ let parentComponent = null;
124
+ let childComponent = null;
125
+ let currentExample = [];
126
+ let currentMarkup = [];
127
+ let currentTable = [];
128
+ let inTableBlock = false;
129
+ let category = 'default';
130
+ let hasCategory = false;
131
+ let hasName = false;
132
+
133
+ function flushBlocks() {
134
+ const target = childComponent || parentComponent;
135
+ pushExample(target, currentExample, warnings, warningSource);
136
+ pushMarkup(target, currentMarkup, warnings, warningSource);
137
+ pushTable(target, currentTable, warnings, warningSource);
138
+ currentExample = [];
139
+ currentMarkup = [];
140
+ currentTable = [];
141
+ inTableBlock = false;
142
+ }
143
+
144
+ for (const line of lines) {
145
+ if (line.startsWith('@sg-category')) {
146
+ flushBlocks();
147
+ category = line.substring('@sg-category'.length).trim() || 'default';
148
+ hasCategory = true;
149
+ } else if (line.startsWith('@sg-name')) {
150
+ flushBlocks();
151
+ const parentName = line.substring('@sg-name'.length).trim();
152
+
153
+ if (!parentName) {
154
+ pushWarning(warnings, '@sg-name is empty', warningSource);
155
+ inTableBlock = false;
156
+ continue;
157
+ }
158
+
159
+ hasName = true;
160
+ parentComponent = getOrCreateComponent(
161
+ categoryMap,
162
+ category,
163
+ parentName,
164
+ source,
165
+ );
166
+ childComponent = null;
167
+ inTableBlock = false;
168
+ } else if (line.startsWith('@sg-sub-name')) {
169
+ flushBlocks();
170
+ if (!parentComponent) {
171
+ pushWarning(
172
+ warnings,
173
+ '@sg-sub-name appears before @sg-name',
174
+ warningSource,
175
+ );
176
+ }
177
+ childComponent = {
178
+ subName: line.substring('@sg-sub-name'.length).trim(),
179
+ };
180
+ if (source) {
181
+ childComponent.source = source;
182
+ }
183
+ if (parentComponent) {
184
+ parentComponent.children.push(childComponent);
185
+ }
186
+ inTableBlock = false;
187
+ } else if (line.startsWith('@sg-description')) {
188
+ flushBlocks();
189
+ if (childComponent) {
190
+ childComponent.description = line
191
+ .substring('@sg-description'.length)
192
+ .trim();
193
+ } else if (parentComponent) {
194
+ parentComponent.description = line
195
+ .substring('@sg-description'.length)
196
+ .trim();
197
+ }
198
+ inTableBlock = false;
199
+ } else if (line.startsWith('@sg-example')) {
200
+ flushBlocks();
201
+ if (!childComponent && !parentComponent) {
202
+ pushWarning(
203
+ warnings,
204
+ '@sg-example appears before @sg-name',
205
+ warningSource,
206
+ );
207
+ }
208
+ currentExample.push(line.substring('@sg-example'.length).trim());
209
+ inTableBlock = false;
210
+ } else if (line.startsWith('@sg-markup')) {
211
+ flushBlocks();
212
+ if (!childComponent && !parentComponent) {
213
+ pushWarning(
214
+ warnings,
215
+ '@sg-markup appears before @sg-name',
216
+ warningSource,
217
+ );
218
+ }
219
+ currentMarkup.push(line.substring('@sg-markup'.length).trim());
220
+ inTableBlock = false;
221
+ } else if (line.startsWith('@sg-variant')) {
222
+ flushBlocks();
223
+ if (childComponent) {
224
+ childComponent.variants = childComponent.variants || [];
225
+ childComponent.variants.push(
226
+ line.substring('@sg-variant'.length).trim(),
227
+ );
228
+ } else if (parentComponent) {
229
+ parentComponent.variants = parentComponent.variants || [];
230
+ parentComponent.variants.push(
231
+ line.substring('@sg-variant'.length).trim(),
232
+ );
233
+ }
234
+ inTableBlock = false;
235
+ } else if (line.startsWith('@sg-table')) {
236
+ flushBlocks();
237
+ if (!childComponent && !parentComponent) {
238
+ pushWarning(
239
+ warnings,
240
+ '@sg-table appears before @sg-name',
241
+ warningSource,
242
+ );
243
+ }
244
+ inTableBlock = true;
245
+ } else if (inTableBlock) {
246
+ currentTable.push(line);
247
+ } else if (line.startsWith('@sg-')) {
248
+ flushBlocks();
249
+ } else {
250
+ if (currentExample.length > 0) {
251
+ currentExample.push(line);
252
+ } else if (currentMarkup.length > 0) {
253
+ currentMarkup.push(line);
254
+ }
255
+ inTableBlock = false;
256
+ }
257
+ }
258
+
259
+ if (!hasCategory) {
260
+ pushWarning(
261
+ warnings,
262
+ `@sg-category is missing; using "default" for ${formatSource(warningSource)}`,
263
+ warningSource,
264
+ );
265
+ }
266
+ if (!hasName) {
267
+ pushWarning(
268
+ warnings,
269
+ '@sg-name is missing; comment block was ignored',
270
+ warningSource,
271
+ );
272
+ }
273
+
274
+ flushBlocks();
275
+ }
276
+
277
+ export function parseStyleguideComments(content, options = {}) {
278
+ const mergedOptions = {
279
+ filePath: undefined,
280
+ includeSource: false,
281
+ warnings: undefined,
282
+ ...options,
283
+ };
284
+ const categoryMap = {};
285
+ const commentRegex = /\/\*[\s\S]*?\*\//g;
286
+ const matches = content.matchAll(commentRegex);
287
+
288
+ for (const match of matches) {
289
+ const comment = match[0];
290
+ if (!/@sg-/.test(comment)) continue;
291
+
292
+ const sourceBase = mergedOptions.filePath
293
+ ? {
294
+ file: mergedOptions.filePath,
295
+ line: getCommentStartLine(content, match.index || 0),
296
+ }
297
+ : undefined;
298
+ const source =
299
+ mergedOptions.includeSource && sourceBase
300
+ ? sourceBase
301
+ : undefined;
302
+ const warningSource =
303
+ sourceBase
304
+ ? {
305
+ ...sourceBase,
306
+ }
307
+ : undefined;
308
+
309
+ parseStyleguideComment(comment, categoryMap, {
310
+ source,
311
+ warningSource,
312
+ warnings: mergedOptions.warnings,
313
+ });
314
+ }
315
+
316
+ return categoryMap;
317
+ }
318
+
319
+ export function mergeCategoryMaps(target, source) {
320
+ for (const [category, components] of Object.entries(source)) {
321
+ if (!target[category]) target[category] = [];
322
+
323
+ for (const component of components) {
324
+ const existing = target[category].find(
325
+ (item) => item.name === component.name,
326
+ );
327
+
328
+ if (existing) {
329
+ existing.children.push(...(component.children || []));
330
+ if (component.description && !existing.description) {
331
+ existing.description = component.description;
332
+ }
333
+ if (component.tables) {
334
+ existing.tables = [...(existing.tables || []), ...component.tables];
335
+ }
336
+ if (component.examples) {
337
+ existing.examples = [
338
+ ...(existing.examples || []),
339
+ ...component.examples,
340
+ ];
341
+ }
342
+ if (component.markup) {
343
+ existing.markup = [...(existing.markup || []), ...component.markup];
344
+ }
345
+ if (component.variants) {
346
+ existing.variants = [
347
+ ...(existing.variants || []),
348
+ ...component.variants,
349
+ ];
350
+ }
351
+ } else {
352
+ target[category].push(component);
353
+ }
354
+ }
355
+ }
356
+
357
+ return target;
358
+ }
359
+
360
+ export async function collectStyleguideData(options = {}) {
361
+ const mergedOptions = {
362
+ ...defaultOptions,
363
+ ...options,
364
+ };
365
+ const files = globSync(mergedOptions.input, {
366
+ cwd: mergedOptions.cwd,
367
+ nodir: true,
368
+ }).sort();
369
+ const categoryMap = {};
370
+ const warnings = mergedOptions.warnings || [];
371
+
372
+ for (const file of files) {
373
+ const absolutePath = path.resolve(mergedOptions.cwd, file);
374
+ const content = await fs.readFile(absolutePath, 'utf8');
375
+ const parsed = parseStyleguideComments(content, {
376
+ filePath: file,
377
+ includeSource: mergedOptions.includeSource,
378
+ warnings,
379
+ });
380
+ mergeCategoryMaps(categoryMap, parsed);
381
+ }
382
+
383
+ return {
384
+ files,
385
+ categories: categoryMap,
386
+ warnings,
387
+ };
388
+ }
389
+
390
+ export function createOutputFiles(categories, options = {}) {
391
+ const mergedOptions = {
392
+ ...defaultOptions,
393
+ ...options,
394
+ };
395
+
396
+ return Object.entries(categories).map(([category, components]) => {
397
+ const safeCategory = slugify(category) || 'default';
398
+ const fileName = `${mergedOptions.prefix}${safeCategory}.json`;
399
+ const filePath = path.resolve(
400
+ mergedOptions.cwd,
401
+ mergedOptions.output,
402
+ fileName,
403
+ );
404
+
405
+ return {
406
+ category,
407
+ filePath,
408
+ data: components,
409
+ json: `${JSON.stringify(components, null, 2)}\n`,
410
+ };
411
+ });
412
+ }
413
+
414
+ export async function writeStyleguideData(categories, options = {}) {
415
+ const mergedOptions = {
416
+ ...defaultOptions,
417
+ ...options,
418
+ };
419
+ const outputFiles = createOutputFiles(categories, mergedOptions);
420
+
421
+ if (mergedOptions.dryRun) {
422
+ return outputFiles;
423
+ }
424
+
425
+ for (const outputFile of outputFiles) {
426
+ await fs.mkdir(path.dirname(outputFile.filePath), { recursive: true });
427
+ await fs.writeFile(outputFile.filePath, outputFile.json, 'utf8');
428
+ }
429
+
430
+ return outputFiles;
431
+ }
432
+
433
+ export async function generateStyleguideData(options = {}) {
434
+ const collected = await collectStyleguideData(options);
435
+ const outputFiles = await writeStyleguideData(collected.categories, options);
436
+
437
+ return {
438
+ ...collected,
439
+ outputFiles,
440
+ };
441
+ }