@highpixel-co/palda-design-system 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/guide/LICENSE +21 -0
- package/dist/guide/NOTICE +29 -0
- package/dist/guide/README.md +57 -0
- package/dist/guide/catalog/components.yml +272 -0
- package/dist/guide/catalog/drafts.yml +333 -0
- package/dist/guide/catalog/icons.yml +309 -0
- package/dist/guide/catalog/patterns.yml +124 -0
- package/dist/guide/catalog/tokens.yml +14 -0
- package/dist/guide/docs/ACCESSIBILITY.md +10 -0
- package/dist/guide/docs/AI_UI_DESIGNER_HANDOFF.md +256 -0
- package/dist/guide/docs/COMPONENT_POLICY.md +105 -0
- package/dist/guide/docs/CONSUMER_GUIDE.md +131 -0
- package/dist/guide/docs/CONTENT.md +148 -0
- package/dist/guide/docs/DESIGN_GRAMMAR.md +378 -0
- package/dist/guide/docs/DESIGN_PRINCIPLES.md +254 -0
- package/dist/guide/docs/FIGMA_ALIGNMENT_DELTA.md +141 -0
- package/dist/guide/docs/FIGMA_NAME_MAPPING.md +84 -0
- package/dist/guide/docs/FIGMA_WORKFLOW.md +33 -0
- package/dist/guide/docs/ICON_POLICY.md +175 -0
- package/dist/guide/docs/LAYOUT.md +221 -0
- package/dist/guide/docs/PATTERN_POLICY.md +13 -0
- package/dist/guide/docs/TOKEN_POLICY.md +255 -0
- package/dist/guide/icons/manifest.json +572 -0
- package/dist/harness/check.mjs +330 -0
- package/dist/harness/cli.mjs +88 -0
- package/dist/harness/metadata.json +1323 -0
- package/dist/scripts/check-examples.mjs +444 -0
- package/package.json +13 -2
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 화면 선언서와 시안 마크업을 검사한다.
|
|
4
|
+
*
|
|
5
|
+
* 화면은 그리기 전에 본문을 한 문장으로 선언하고, 선언에 들지 않은 것은 곁으로 내린다
|
|
6
|
+
* (`docs/decisions/0022-screens-declare-their-body.md`). 선언이 산문 규칙으로만 있으면
|
|
7
|
+
* 화면을 들일 때 빠지기 쉬워서, 셀 수 있는 부분만 검사로 둔다.
|
|
8
|
+
*
|
|
9
|
+
* 대상이 둘이다.
|
|
10
|
+
*
|
|
11
|
+
* 1. 승인 화면 문서(`examples/approved/*.md`) — 선언서의 항목과 개수.
|
|
12
|
+
* 2. 시안 HTML(`preview/`·`examples/`의 `.html`) — 같은 선언서 + 바디 블록의 소속 표시.
|
|
13
|
+
*
|
|
14
|
+
* 2번은 0022 §적용이 "강제되지 않는 것"에 남겨 둔 곁의 규칙들이다. 당시 근거는 "화면 코드가
|
|
15
|
+
* 이 저장소에 없어 셀 대상이 없다"였는데, 시안 HTML이 그 대상이다 — 시안 CSS를 `check:css`로
|
|
16
|
+
* 올린 것과 같은 자리다(`docs/COMPONENT_POLICY.md` §시안은 HTML로 먼저 확정한다).
|
|
17
|
+
* `AGENTS.md` §항상("반복되는 오류는 검사로 승격한다")을 따른다.
|
|
18
|
+
*
|
|
19
|
+
* 실제로 새어 나갔다 — 스토어 알림톡 시안이 결제·구독 요약(명백한 곁)을 `곁: 없음`이라고
|
|
20
|
+
* 선언해 놓고 `Card` + 행 넷 + 버튼으로 그렸다. 규칙 셋을 전부 어겼는데 선언서만 보는 검사는
|
|
21
|
+
* 통과했다.
|
|
22
|
+
*
|
|
23
|
+
* 곁의 규칙과 별개로 **상태를 누가 드는가**도 한 가지 센다. 행 하나의 실패를 섹션 배너로 올리면
|
|
24
|
+
* 같은 사실을 두 번 말하고, 면적이 큰 배너가 본문보다 강해진다
|
|
25
|
+
* (`docs/decisions/0024-state-belongs-to-the-nearest-owner.md`). 스토어 알림톡 시안이 실제로
|
|
26
|
+
* 그렇게 그려져 목록이 눈에 안 들어왔다.
|
|
27
|
+
*
|
|
28
|
+
* 읽지 못하는 것은 그대로 리뷰가 받는다 — 본문 문장의 폭과, 곁이 폭에 밀려 접히는지다. 곁의
|
|
29
|
+
* 규칙 1(세로로 한 줄)은 마크업으로 이미 갈라 둔 경우만 잡는다. 곁의 자리도 세지 않는다 —
|
|
30
|
+
* 0022가 자리를 정하지 않고, 본문보다 먼저 오는 것도 규칙 위반이 아니다
|
|
31
|
+
* (`docs/DESIGN_GRAMMAR.md` §자리는 시스템이 정하지 않는다).
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
34
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const DEFAULT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
38
|
+
|
|
39
|
+
/** 선언서는 문서 맨 위의 표다. 첫 칸이 항목 이름이고 백틱은 있어도 없어도 된다. */
|
|
40
|
+
const ROW = /^\|([^|]*)\|([^|]*)\|/;
|
|
41
|
+
/** 표의 구분선(`| --- | --- |`)은 항목이 아니다. */
|
|
42
|
+
const DIVIDER = /^[\s:-]*$/;
|
|
43
|
+
|
|
44
|
+
const PRIMARY_ACTION_PLACES = new Set(['헤더 우측', 'BottomBar']);
|
|
45
|
+
|
|
46
|
+
/** 시안을 찾는 곳. `preview/`는 untracked라 CI에서는 비어 있고 로컬에서만 걸린다. */
|
|
47
|
+
const SCREEN_DIRS = ['preview', 'examples'];
|
|
48
|
+
|
|
49
|
+
/** 셸의 바디 밴드. 이 안의 직계 자식이 화면의 블록이다 (`docs/LAYOUT.md` §콘텐츠 세로 순서). */
|
|
50
|
+
const BODY_BAND_CLASS = 'palda-app-shell__band--body';
|
|
51
|
+
|
|
52
|
+
/** 바디 직계 자식이 밝히는 소속. 순서도 이 순서다 — 헤더 → 본문 → 곁. */
|
|
53
|
+
const ROLE_ATTRIBUTE = /data-screen-role\s*=\s*"([^"]*)"/;
|
|
54
|
+
const ROLES = ['헤더', '본문', '곁'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 곁이 쓰지 않는 pattern 클래스 (0022 규칙 2 — 본문이 쓰는 pattern을 쓰지 않는다).
|
|
58
|
+
* 본문이 무엇을 쓰는지와 무관하게 pattern 자체가 "이 화면이 하는 일"을 담는 도구다.
|
|
59
|
+
* 곁에 남는 표현은 텍스트와 `Link`다(0022 §대가).
|
|
60
|
+
*/
|
|
61
|
+
const BODY_PATTERN_CLASSES = [
|
|
62
|
+
'palda-card',
|
|
63
|
+
'palda-form-section',
|
|
64
|
+
'palda-list-row',
|
|
65
|
+
'palda-empty-state',
|
|
66
|
+
'palda-live-preview',
|
|
67
|
+
'palda-bottom-bar',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 곁이 쓰지 않는 강조 채움 (0022 규칙 3 — 강조 예산은 본문이 갖는다).
|
|
72
|
+
* 액션의 채움만 본다. 상태 배지는 강조 예산을 쓰지 않는다
|
|
73
|
+
* (`docs/DESIGN_PRINCIPLES.md` §4의 "상태 색은 이 한도에 들어가지 않는다").
|
|
74
|
+
*/
|
|
75
|
+
const EMPHASIS_CLASSES = [
|
|
76
|
+
'palda-button--primary',
|
|
77
|
+
'palda-button--accent',
|
|
78
|
+
'palda-chip--primary',
|
|
79
|
+
'palda-chip--accent',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 곁이 세로로 한 줄인지(0022 규칙 1)를 정적으로 근사한다. 블록 요소가 하나라도 들어 있으면
|
|
84
|
+
* 줄이 나뉜다 — 곁에 남는 표현은 텍스트와 `Link`라(0022 §대가) 인라인만으로 충분하다.
|
|
85
|
+
* 줄바꿈 자체는 폭에 달려 있어 여전히 못 읽는다. 이 검사가 잡는 것은 마크업으로 이미 갈라 둔
|
|
86
|
+
* 경우다.
|
|
87
|
+
*/
|
|
88
|
+
const BLOCK_ELEMENTS = new Set([
|
|
89
|
+
'address',
|
|
90
|
+
'article',
|
|
91
|
+
'aside',
|
|
92
|
+
'blockquote',
|
|
93
|
+
'div',
|
|
94
|
+
'dl',
|
|
95
|
+
'fieldset',
|
|
96
|
+
'figure',
|
|
97
|
+
'footer',
|
|
98
|
+
'form',
|
|
99
|
+
'h1',
|
|
100
|
+
'h2',
|
|
101
|
+
'h3',
|
|
102
|
+
'h4',
|
|
103
|
+
'h5',
|
|
104
|
+
'h6',
|
|
105
|
+
'header',
|
|
106
|
+
'hr',
|
|
107
|
+
'li',
|
|
108
|
+
'main',
|
|
109
|
+
'nav',
|
|
110
|
+
'ol',
|
|
111
|
+
'p',
|
|
112
|
+
'section',
|
|
113
|
+
'table',
|
|
114
|
+
'ul',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 상태를 드는 자리 (0024 — 상태는 가장 가까운 자리가 든다).
|
|
119
|
+
* 한 섹션이 둘을 같이 쓰면 행 하나의 실패를 섹션이 대신 말한 것이다. `\b`가 `_`를 경계로 보지
|
|
120
|
+
* 않아 `palda-form-section__error`는 섹션 클래스로 세지 않는다.
|
|
121
|
+
*/
|
|
122
|
+
const FORM_SECTION_CLASS = 'palda-form-section';
|
|
123
|
+
const SECTION_FAILURE_CLASS = 'palda-form-section__error';
|
|
124
|
+
const ROW_FAILURE_CLASS = 'palda-badge--danger';
|
|
125
|
+
|
|
126
|
+
/** 닫는 태그가 없는 요소. 깊이를 올리지 않는다. */
|
|
127
|
+
const VOID_ELEMENTS = new Set([
|
|
128
|
+
'area',
|
|
129
|
+
'base',
|
|
130
|
+
'br',
|
|
131
|
+
'col',
|
|
132
|
+
'embed',
|
|
133
|
+
'hr',
|
|
134
|
+
'img',
|
|
135
|
+
'input',
|
|
136
|
+
'link',
|
|
137
|
+
'meta',
|
|
138
|
+
'param',
|
|
139
|
+
'source',
|
|
140
|
+
'track',
|
|
141
|
+
'wbr',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
function cell(text) {
|
|
145
|
+
return text.replace(/`/g, '').trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 선언서 표의 행을 읽는다. 마크다운이든 HTML 주석 안이든 서식이 같다. */
|
|
149
|
+
function declarationRows(source) {
|
|
150
|
+
const rows = [];
|
|
151
|
+
for (const line of source.split('\n')) {
|
|
152
|
+
const match = ROW.exec(line.trim());
|
|
153
|
+
if (!match || DIVIDER.test(match[1])) continue;
|
|
154
|
+
rows.push({ label: cell(match[1]), value: cell(match[2]) });
|
|
155
|
+
}
|
|
156
|
+
return rows;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 선언서의 셀 수 있는 부분 — 항목이 있는지, 본문이 하나인지, 주 액션 값이 둘 중 하나인지. */
|
|
160
|
+
function checkDeclaration(rows, where, problems) {
|
|
161
|
+
const body = rows.filter((row) => row.label === '본문');
|
|
162
|
+
const primary = rows.filter((row) => row.label === '주 액션');
|
|
163
|
+
|
|
164
|
+
if (body.length === 0) {
|
|
165
|
+
problems.push(`${where}: 선언서에 \`본문\` 행이 없다`);
|
|
166
|
+
} else if (body.length > 1) {
|
|
167
|
+
problems.push(`${where}: 본문이 ${body.length}개다 — 하나여야 하고, 둘이면 화면을 쪼갠다`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (primary.length === 0) {
|
|
171
|
+
problems.push(`${where}: 선언서에 \`주 액션\` 행이 없다`);
|
|
172
|
+
} else if (primary.length > 1) {
|
|
173
|
+
problems.push(`${where}: 주 액션이 ${primary.length}개다 — 자리는 하나다`);
|
|
174
|
+
} else if (!PRIMARY_ACTION_PLACES.has(primary[0].value)) {
|
|
175
|
+
problems.push(
|
|
176
|
+
`${where}: 주 액션 자리가 \`${primary[0].value}\`다 — ${[...PRIMARY_ACTION_PLACES]
|
|
177
|
+
.map((place) => `\`${place}\``)
|
|
178
|
+
.join(' 또는 ')} 중 하나여야 한다`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `곁` 행에 `없음`이라고 적으면 곁이 0개라는 뜻이다. */
|
|
183
|
+
const asides = rows
|
|
184
|
+
.filter((row) => row.label === '곁')
|
|
185
|
+
.filter((row) => row.value !== '' && row.value !== '없음');
|
|
186
|
+
|
|
187
|
+
return { declared: body.length > 0, asides: asides.length };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 주석을 같은 길이의 공백으로 지운다. 주석 안의 예시 마크업이 태그로 세지지 않게 한다. */
|
|
191
|
+
function stripComments(html) {
|
|
192
|
+
return html.replace(/<!--[\s\S]*?-->/g, (match) => ' '.repeat(match.length));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function scanTags(html) {
|
|
196
|
+
const TAG = /<(\/)?([a-zA-Z][\w-]*)((?:'[^']*'|"[^"]*"|[^'">])*)>/g;
|
|
197
|
+
const tags = [];
|
|
198
|
+
let match;
|
|
199
|
+
while ((match = TAG.exec(html)) !== null) {
|
|
200
|
+
const attributes = match[3] ?? '';
|
|
201
|
+
tags.push({
|
|
202
|
+
closing: match[1] === '/',
|
|
203
|
+
name: match[2].toLowerCase(),
|
|
204
|
+
attributes,
|
|
205
|
+
selfClosing: attributes.trimEnd().endsWith('/'),
|
|
206
|
+
start: match.index,
|
|
207
|
+
end: TAG.lastIndex,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return tags;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 셸 바디 밴드의 직계 자식을 순서대로 돌려준다.
|
|
215
|
+
* 밴드가 없으면 `null`이다 — 컴포넌트 시안은 화면이 아니라 이 검사를 받지 않는다.
|
|
216
|
+
*/
|
|
217
|
+
function bodyBlocks(html) {
|
|
218
|
+
const source = stripComments(html);
|
|
219
|
+
const tags = scanTags(source);
|
|
220
|
+
const band = tags.findIndex((tag) => !tag.closing && tag.attributes.includes(BODY_BAND_CLASS));
|
|
221
|
+
if (band < 0) return null;
|
|
222
|
+
|
|
223
|
+
const blocks = [];
|
|
224
|
+
let depth = 0;
|
|
225
|
+
let open = null;
|
|
226
|
+
|
|
227
|
+
for (let index = band + 1; index < tags.length; index += 1) {
|
|
228
|
+
const tag = tags[index];
|
|
229
|
+
|
|
230
|
+
if (tag.closing) {
|
|
231
|
+
// 밴드 자신이 닫혔다.
|
|
232
|
+
if (depth === 0) break;
|
|
233
|
+
depth -= 1;
|
|
234
|
+
if (depth === 0 && open) {
|
|
235
|
+
blocks.push({ tag: open, html: source.slice(open.start, tag.end) });
|
|
236
|
+
open = null;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (depth === 0) {
|
|
242
|
+
if (tag.selfClosing || VOID_ELEMENTS.has(tag.name)) {
|
|
243
|
+
blocks.push({ tag, html: source.slice(tag.start, tag.end) });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
open = tag;
|
|
247
|
+
}
|
|
248
|
+
if (!tag.selfClosing && !VOID_ELEMENTS.has(tag.name)) depth += 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return blocks;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 블록 안의 `.palda-form-section` 요소를 HTML 조각으로 하나씩 돌려준다.
|
|
256
|
+
* 섹션은 중첩되지 않지만, 안쪽 태그의 닫는 순서에 속지 않도록 깊이를 센다.
|
|
257
|
+
*/
|
|
258
|
+
function formSections(html) {
|
|
259
|
+
const tags = scanTags(html);
|
|
260
|
+
const sections = [];
|
|
261
|
+
const isSection = new RegExp(`\\b${FORM_SECTION_CLASS}\\b`);
|
|
262
|
+
|
|
263
|
+
for (let index = 0; index < tags.length; index += 1) {
|
|
264
|
+
const open = tags[index];
|
|
265
|
+
if (open.closing || !isSection.test(open.attributes)) continue;
|
|
266
|
+
|
|
267
|
+
let depth = 0;
|
|
268
|
+
for (let cursor = index; cursor < tags.length; cursor += 1) {
|
|
269
|
+
const tag = tags[cursor];
|
|
270
|
+
if (tag.closing) {
|
|
271
|
+
depth -= 1;
|
|
272
|
+
if (depth === 0) {
|
|
273
|
+
sections.push(html.slice(open.start, tag.end));
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!tag.selfClosing && !VOID_ELEMENTS.has(tag.name)) depth += 1;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return sections;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 바디 블록의 소속 표시와 곁의 규칙을 센다. */
|
|
286
|
+
function checkBlocks(blocks, declaredAsides, where, problems) {
|
|
287
|
+
const roles = [];
|
|
288
|
+
|
|
289
|
+
for (const [index, block] of blocks.entries()) {
|
|
290
|
+
const match = ROLE_ATTRIBUTE.exec(block.tag.attributes);
|
|
291
|
+
const role = match ? match[1].trim() : null;
|
|
292
|
+
|
|
293
|
+
if (!role) {
|
|
294
|
+
problems.push(
|
|
295
|
+
`${where}: 바디 ${index + 1}번째 블록(<${block.tag.name}>)에 data-screen-role이 없다 — ${ROLES.map((name) => `\`${name}\``).join('·')} 중 하나를 밝힌다`,
|
|
296
|
+
);
|
|
297
|
+
roles.push(null);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!ROLES.includes(role)) {
|
|
301
|
+
problems.push(
|
|
302
|
+
`${where}: 바디 ${index + 1}번째 블록의 data-screen-role이 \`${role}\`다 — ${ROLES.map((name) => `\`${name}\``).join('·')} 중 하나여야 한다`,
|
|
303
|
+
);
|
|
304
|
+
roles.push(null);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
roles.push(role);
|
|
308
|
+
|
|
309
|
+
if (role !== '곁') continue;
|
|
310
|
+
|
|
311
|
+
// 규칙 2 — 본문이 쓰는 pattern을 쓰지 않는다.
|
|
312
|
+
for (const pattern of BODY_PATTERN_CLASSES) {
|
|
313
|
+
if (new RegExp(`\\b${pattern}\\b`).test(block.html)) {
|
|
314
|
+
problems.push(
|
|
315
|
+
`${where}: 곁이 \`${pattern}\`을 쓴다 — 곁은 본문이 쓰는 pattern을 쓰지 않는다 (0022 규칙 2)`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 규칙 3 — 강조 예산을 쓰지 않는다.
|
|
321
|
+
for (const emphasis of EMPHASIS_CLASSES) {
|
|
322
|
+
if (new RegExp(`\\b${emphasis}\\b`).test(block.html)) {
|
|
323
|
+
problems.push(
|
|
324
|
+
`${where}: 곁이 \`${emphasis}\`를 쓴다 — 강조 예산은 본문이 갖는다 (0022 규칙 3)`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 규칙 1 — 세로로 한 줄이다. 블록 요소가 들어 있으면 이미 여러 줄이다.
|
|
330
|
+
const inner = scanTags(block.html)
|
|
331
|
+
.slice(1)
|
|
332
|
+
.filter((tag) => !tag.closing && BLOCK_ELEMENTS.has(tag.name));
|
|
333
|
+
if (inner.length > 0) {
|
|
334
|
+
problems.push(
|
|
335
|
+
`${where}: 곁 안에 블록 요소(<${inner[0].name}>)가 있다 — 곁은 세로로 한 줄이다 (0022 규칙 1)`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 상태는 가장 가까운 자리가 든다 (0024). 소속과 무관하게 바디 전체를 본다.
|
|
341
|
+
for (const block of blocks) {
|
|
342
|
+
for (const section of formSections(block.html)) {
|
|
343
|
+
if (!section.includes(SECTION_FAILURE_CLASS)) continue;
|
|
344
|
+
if (!new RegExp(`\\b${ROW_FAILURE_CLASS}\\b`).test(section)) continue;
|
|
345
|
+
problems.push(
|
|
346
|
+
`${where}: 한 섹션이 \`${SECTION_FAILURE_CLASS}\` 배너와 \`${ROW_FAILURE_CLASS}\` 배지로 같은 실패를 두 번 말한다 — 행 하나의 실패는 그 행이 든다 (0024)`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 헤더는 하나이고 맨 앞이다 (`docs/LAYOUT.md` §콘텐츠 세로 순서).
|
|
352
|
+
const headers = roles.filter((role) => role === '헤더').length;
|
|
353
|
+
if (headers > 1) {
|
|
354
|
+
problems.push(`${where}: 페이지 헤더가 ${headers}개다 — 하나다`);
|
|
355
|
+
} else if (headers === 1 && roles[0] !== '헤더') {
|
|
356
|
+
problems.push(`${where}: 페이지 헤더가 첫 블록이 아니다 — 바디의 맨 위다`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 본문은 블록 여러 개로 나뉘어도 되지만 사이가 갈리지 않는다. 선언서의 본문은 한 문장이고,
|
|
360
|
+
// 그 한 문장이 곁을 사이에 두고 두 덩어리로 읽히면 화면이 둘이다.
|
|
361
|
+
const bodyIndexes = roles.flatMap((role, index) => (role === '본문' ? [index] : []));
|
|
362
|
+
if (
|
|
363
|
+
bodyIndexes.length > 1 &&
|
|
364
|
+
bodyIndexes[bodyIndexes.length - 1] - bodyIndexes[0] + 1 !== bodyIndexes.length
|
|
365
|
+
) {
|
|
366
|
+
problems.push(
|
|
367
|
+
`${where}: 본문 블록 사이에 다른 것이 끼어 있다 — 본문은 여러 블록이어도 붙어 있어야 한다`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 선언되지 않은 곁은 화면에 들어오지 못한다.
|
|
372
|
+
const drawn = roles.filter((role) => role === '곁').length;
|
|
373
|
+
if (drawn !== declaredAsides) {
|
|
374
|
+
problems.push(
|
|
375
|
+
`${where}: 곁이 선언서에 ${declaredAsides}개인데 화면에 ${drawn}개다 — 적히지 않은 곁은 화면에 들어오지 못한다`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function htmlFiles(dir, out = []) {
|
|
381
|
+
for (const name of readdirSync(dir)) {
|
|
382
|
+
const full = join(dir, name);
|
|
383
|
+
if (statSync(full).isDirectory()) htmlFiles(full, out);
|
|
384
|
+
else if (name.endsWith('.html')) out.push(full);
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function validateScreenHtml(source, where, requireScreen = false) {
|
|
390
|
+
const problems = [];
|
|
391
|
+
const blocks = bodyBlocks(source);
|
|
392
|
+
if (blocks === null) {
|
|
393
|
+
if (requireScreen) problems.push(`${where}: DS 셸 바디가 없는 HTML은 화면 검사 대상이 아니다`);
|
|
394
|
+
return problems;
|
|
395
|
+
}
|
|
396
|
+
const rows = declarationRows(source);
|
|
397
|
+
if (rows.length === 0) {
|
|
398
|
+
problems.push(`${where}: 화면 선언 표가 없다 — 파일 맨 위 주석에 둔다`);
|
|
399
|
+
return problems;
|
|
400
|
+
}
|
|
401
|
+
const { asides } = checkDeclaration(rows, where, problems);
|
|
402
|
+
checkBlocks(blocks, asides, where, problems);
|
|
403
|
+
return problems;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function validateExamples(root = DEFAULT_ROOT) {
|
|
407
|
+
const problems = [];
|
|
408
|
+
|
|
409
|
+
// 1. 승인 화면 문서 — 선언서만 본다. 화면은 시안 HTML이 갖는다.
|
|
410
|
+
const approvedDir = join(root, 'examples/approved');
|
|
411
|
+
if (existsSync(approvedDir)) {
|
|
412
|
+
for (const name of readdirSync(approvedDir)) {
|
|
413
|
+
if (!name.endsWith('.md') || name === 'README.md') continue;
|
|
414
|
+
const file = join(approvedDir, name);
|
|
415
|
+
checkDeclaration(declarationRows(readFileSync(file, 'utf8')), relative(root, file), problems);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 2. 시안 HTML — 선언서 + 바디 블록의 소속.
|
|
420
|
+
for (const dir of SCREEN_DIRS) {
|
|
421
|
+
const full = join(root, dir);
|
|
422
|
+
if (!existsSync(full)) continue;
|
|
423
|
+
|
|
424
|
+
for (const file of htmlFiles(full)) {
|
|
425
|
+
const source = readFileSync(file, 'utf8');
|
|
426
|
+
const where = relative(root, file);
|
|
427
|
+
problems.push(...validateScreenHtml(source, where));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return problems;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function main() {
|
|
435
|
+
const problems = validateExamples(process.env.DS_ROOT ?? DEFAULT_ROOT);
|
|
436
|
+
if (problems.length > 0) {
|
|
437
|
+
console.error(`화면 선언·시안 불일치 ${problems.length}건\n`);
|
|
438
|
+
for (const problem of problems) console.error(` ${problem}`);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
console.log('화면 선언·시안 검사 통과');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@highpixel-co/palda-design-system",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Palda product design tokens, icons, React components, and patterns",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
],
|
|
12
12
|
"main": "./dist/index.js",
|
|
13
13
|
"types": "./dist/index.d.ts",
|
|
14
|
+
"bin": {
|
|
15
|
+
"palda-ds": "./dist/harness/cli.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12.0"
|
|
19
|
+
},
|
|
14
20
|
"exports": {
|
|
15
21
|
".": {
|
|
16
22
|
"types": "./dist/index.d.ts",
|
|
@@ -32,7 +38,8 @@
|
|
|
32
38
|
"types": "./dist/tokens.d.ts",
|
|
33
39
|
"import": "./dist/tokens.js"
|
|
34
40
|
},
|
|
35
|
-
"./styles.css": "./dist/styles.css"
|
|
41
|
+
"./styles.css": "./dist/styles.css",
|
|
42
|
+
"./catalog": "./dist/harness/metadata.json"
|
|
36
43
|
},
|
|
37
44
|
"sideEffects": [
|
|
38
45
|
"./dist/styles.css"
|
|
@@ -52,5 +59,9 @@
|
|
|
52
59
|
"publishConfig": {
|
|
53
60
|
"registry": "https://registry.npmjs.org/",
|
|
54
61
|
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"js-yaml": "4.3.0",
|
|
65
|
+
"typescript": "6.0.3"
|
|
55
66
|
}
|
|
56
67
|
}
|