@kaydash9999/banker-plugins 0.17.1 → 0.19.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +72 -0
- package/README.md +26 -8
- package/codex/manifest.json +3 -1
- package/codex/transform-matrix.md +5 -2
- package/commands/front-qa.md +3 -3
- package/hooks/graceful-pause.mjs +4 -2
- package/hooks/obsidize-hook.mjs +38 -28
- package/hooks/progress-plan.mjs +14 -8
- package/hooks/progress.mjs +174 -47
- package/hooks/register.mjs +14 -7
- package/hooks/update-checkin.mjs +29 -27
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +3 -3
- package/skills/3d-intro-setup/SKILL.md +3 -3
- package/skills/anti-drm/SKILL.md +254 -0
- package/skills/anti-drm/scripts/anti-drm.mjs +643 -0
- package/skills/anti-drm/scripts/instruction-toggle.mjs +733 -0
- package/skills/lineage/SKILL.md +3 -3
- package/skills/lineage/lineage.py +43 -7
- package/skills/omc-patch/SKILL.md +18 -6
- package/skills/omc-patch/scripts/omc-patch.mjs +400 -87
- package/skills/omc-reference/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +19 -19
- package/skills/theme-selection/SKILL.md +165 -0
- package/skills/theme-selection/assets/LICENSE +21 -0
- package/skills/theme-selection/assets/NOTICE.md +30 -0
- package/skills/theme-selection/assets/palettes.json +81 -0
- package/skills/theme-selection/references/contrast.md +138 -0
- package/skills/theme-selection/references/integrate.md +161 -0
- package/skills/theme-selection/scripts/themes.mjs +1067 -0
- package/skills/tone-compact/SKILL.md +8 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +733 -0
- package/skills/tone-compact/scripts/tone-compact.mjs +48 -397
- package/skills/ultra-chain/SKILL.md +7 -6
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -0,0 +1,1067 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 테마 엔진과 CLI. Node ESM, 의존성 0건.
|
|
3
|
+
//
|
|
4
|
+
// 🔴 네트워크를 쓰지 않습니다. `fetch` · `node:http(s)` · `node:child_process`
|
|
5
|
+
// 가 이 파일에 없는 것이 사양입니다. 폐쇄망에서 도는 스킬이고, 인터넷이 필요한
|
|
6
|
+
// 줄 하나가 들어오면 그 자리에서 스킬 전체가 쓸 수 없는 것이 됩니다.
|
|
7
|
+
//
|
|
8
|
+
// 🔴 이 파일은 CLI 로도 돌고 import 로도 돕니다. 최상위에서 바로 실행하지
|
|
9
|
+
// 않고 맨 아래에서 실제 경로를 비교해 직접 실행인지 보고 `main()` 을 부릅니다.
|
|
10
|
+
// 자기 시험이 엔진 함수를 직접 불러야 하기 때문입니다.
|
|
11
|
+
//
|
|
12
|
+
// 🔴 색 계산은 파이썬 원본(대비 보정, DESIGN.md 읽기)의 포팅입니다. 값이 한
|
|
13
|
+
// 자리라도 갈리면 안 되므로 파이썬의 `round`(짝수 쪽 반올림)와
|
|
14
|
+
// `colorsys`(HLS, 인자 순서가 `h, l, s`)를 손으로 옮겼습니다. 원본과 값을
|
|
15
|
+
// 대조하는 도구는 이 패키지에 들어 있지 않습니다.
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
readFileSync, writeFileSync, readdirSync, statSync, lstatSync, realpathSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { dirname, join, resolve, basename, extname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
|
|
25
|
+
export const WHITE = "#ffffff";
|
|
26
|
+
export const BLACK = "#000000";
|
|
27
|
+
|
|
28
|
+
// 대비 보정의 목표. 🔴 3.0 이고 4.5 가 아닙니다. 여기서 고치는 것은 본문이
|
|
29
|
+
// 아니라 안 보이면 기능이 사라지는 셋입니다. 팔레트 74개를 전부 4.5 로
|
|
30
|
+
// 끌어올리면 테마가 테마가 아닙니다.
|
|
31
|
+
export const TARGET = 3.0;
|
|
32
|
+
|
|
33
|
+
// 버튼 글자의 목표. 버튼 배경은 3.0 으로 맞춘 색이고, 그 위 글자는 본문
|
|
34
|
+
// 글자라 4.5 입니다. 같은 쌍을 쓰면 배경까지 4.5 로 밀려 딸려 가는 자리가
|
|
35
|
+
// 생기므로 글자는 새 이름으로 따로 고릅니다.
|
|
36
|
+
export const LABEL_TARGET = 4.5;
|
|
37
|
+
|
|
38
|
+
// 제자리에서 옮기는 색. [고칠 색, 3.0 을 만족해야 하는 배경, 나빠지면 안 되는 배경].
|
|
39
|
+
export const CONTRAST_JOBS = [["accent", "bg", "surface"]];
|
|
40
|
+
|
|
41
|
+
// 배경별로 쪼개는 색. 결과는 `--<색>-on-surface` 라는 새 이름입니다.
|
|
42
|
+
// 🔴 `ink` · `dim` 을 제자리에서 옮기면 본문 전체가 딸려 갑니다
|
|
43
|
+
// (`lamborghini/light` 에서 12.79 -> 4.20, 크로미움 실측 2026-09-08).
|
|
44
|
+
export const SURFACE_JOBS = [["ink", "surface", "bg"],
|
|
45
|
+
["dim", "surface", "bg"],
|
|
46
|
+
["accent", "surface", "bg"],
|
|
47
|
+
["warn", "surface", "bg"]];
|
|
48
|
+
|
|
49
|
+
// [글자 변수, 그 글자가 올라가는 배경 변수]. 🔴 배경마다 하나씩입니다.
|
|
50
|
+
// 같은 이름 하나로 두 배경을 맞추면 한쪽이 4.5 아래로 떨어집니다.
|
|
51
|
+
export const LABEL_JOBS = [["label-on-accent", "accent-on-surface"],
|
|
52
|
+
["label-on-page-accent", "accent"]];
|
|
53
|
+
|
|
54
|
+
// 팔레트 값의 순서. 🔴 `assets/palettes.json` 의 `fields` 와 같아야 합니다.
|
|
55
|
+
export const FIELDS = ["bg", "surface", "ink", "dim", "rule", "accent", "warn"];
|
|
56
|
+
|
|
57
|
+
// 조합 하나가 CSS 로 내는 열세 이름. 순서도 이대로입니다.
|
|
58
|
+
export const OUT_NAMES = [
|
|
59
|
+
...FIELDS,
|
|
60
|
+
...SURFACE_JOBS.map(([field]) => `${field}-on-surface`),
|
|
61
|
+
...LABEL_JOBS.map(([name]) => name),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// `--shadow` 는 테마의 성질이 아니라 모드의 성질입니다. 테마마다 넣으면 같은
|
|
65
|
+
// 두 값이 148번 반복됩니다.
|
|
66
|
+
const SHADOW_LIGHT = "rgba(0,0,0,.08)";
|
|
67
|
+
const SHADOW_DARK = "rgba(0,0,0,.45)";
|
|
68
|
+
|
|
69
|
+
/* ------------------------------------------------------------------ 수치 */
|
|
70
|
+
|
|
71
|
+
// 파이썬의 `x % 1.0`. 🔴 JS 의 `%` 는 피제수의 부호를 따라가므로 음수 색조에서
|
|
72
|
+
// 파이썬과 갈립니다. CPython 과 같은 순서로 보정합니다.
|
|
73
|
+
export function pymod1(x) {
|
|
74
|
+
let mod = x % 1.0;
|
|
75
|
+
if (mod !== 0) {
|
|
76
|
+
if (mod < 0) mod += 1.0;
|
|
77
|
+
} else {
|
|
78
|
+
mod = 0;
|
|
79
|
+
}
|
|
80
|
+
return mod;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// C 의 `round()`. 반올림이고 0.5 는 0 에서 멀어지는 쪽입니다.
|
|
84
|
+
function cround(x) {
|
|
85
|
+
const abs = Math.abs(x);
|
|
86
|
+
const floor = Math.floor(abs);
|
|
87
|
+
const rounded = abs - floor >= 0.5 ? floor + 1 : floor;
|
|
88
|
+
return x < 0 ? -rounded : rounded;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 파이썬의 `round(x)`. 🔴 0.5 를 **짝수** 쪽으로 보냅니다. JS 의 `Math.round`
|
|
92
|
+
// 는 위로 보내므로, 이 한 글자 차이가 채널 값을 1 씩 어긋내고 대비 판정
|
|
93
|
+
// 경계에서 다른 색을 고르게 만듭니다. CPython `float.__round__` 와 같은 순서.
|
|
94
|
+
export function pyround(x) {
|
|
95
|
+
let rounded = cround(x);
|
|
96
|
+
if (Math.abs(x - rounded) === 0.5) rounded = 2 * cround(x / 2);
|
|
97
|
+
return rounded;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hex2(value) {
|
|
101
|
+
const text = value.toString(16);
|
|
102
|
+
return text.length < 2 ? `0${text}` : text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function channels(value) {
|
|
106
|
+
return [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16) / 255);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// WCAG 상대 휘도. 값은 `#rrggbb` 입니다.
|
|
110
|
+
export function luminance(value) {
|
|
111
|
+
const chans = channels(value).map(
|
|
112
|
+
(c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
|
113
|
+
return 0.2126 * chans[0] + 0.7152 * chans[1] + 0.0722 * chans[2];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 두 색의 대비비. 1.0(같은 색, 안 보임) ~ 21.0(검정과 흰색).
|
|
117
|
+
export function contrast(one, other) {
|
|
118
|
+
const a = luminance(one);
|
|
119
|
+
const b = luminance(other);
|
|
120
|
+
const low = Math.min(a, b);
|
|
121
|
+
const high = Math.max(a, b);
|
|
122
|
+
return (high + 0.05) / (low + 0.05);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// `colorsys.rgb_to_hls` 의 포팅. 🔴 돌려주는 순서는 `[h, l, s]` 입니다 - HSL 과
|
|
126
|
+
// 가운데 둘이 바뀌어 있어, 순서를 바꿔 받으면 1,924 값이 전부 조금씩 틀립니다.
|
|
127
|
+
export function rgbToHls(red, green, blue) {
|
|
128
|
+
const maxc = Math.max(red, green, blue);
|
|
129
|
+
const minc = Math.min(red, green, blue);
|
|
130
|
+
const sumc = maxc + minc;
|
|
131
|
+
const rangec = maxc - minc;
|
|
132
|
+
const light = sumc / 2.0;
|
|
133
|
+
if (minc === maxc) return [0.0, light, 0.0];
|
|
134
|
+
const sat = light <= 0.5 ? rangec / sumc : rangec / (2.0 - maxc - minc);
|
|
135
|
+
const rc = (maxc - red) / rangec;
|
|
136
|
+
const gc = (maxc - green) / rangec;
|
|
137
|
+
const bc = (maxc - blue) / rangec;
|
|
138
|
+
let hue;
|
|
139
|
+
if (red === maxc) hue = bc - gc;
|
|
140
|
+
else if (green === maxc) hue = 2.0 + rc - bc;
|
|
141
|
+
else hue = 4.0 + gc - rc;
|
|
142
|
+
return [pymod1(hue / 6.0), light, sat];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ONE_THIRD = 1.0 / 3.0;
|
|
146
|
+
const ONE_SIXTH = 1.0 / 6.0;
|
|
147
|
+
const TWO_THIRD = 2.0 / 3.0;
|
|
148
|
+
|
|
149
|
+
function hlsValue(m1, m2, hue) {
|
|
150
|
+
const spot = pymod1(hue);
|
|
151
|
+
if (spot < ONE_SIXTH) return m1 + (m2 - m1) * spot * 6.0;
|
|
152
|
+
if (spot < 0.5) return m2;
|
|
153
|
+
if (spot < TWO_THIRD) return m1 + (m2 - m1) * (TWO_THIRD - spot) * 6.0;
|
|
154
|
+
return m1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `colorsys.hls_to_rgb` 의 포팅. 인자 순서는 `(h, l, s)` 입니다.
|
|
158
|
+
export function hlsToRgb(hue, light, sat) {
|
|
159
|
+
if (sat === 0.0) return [light, light, light];
|
|
160
|
+
const m2 = light <= 0.5 ? light * (1.0 + sat) : light + sat - light * sat;
|
|
161
|
+
const m1 = 2.0 * light - m2;
|
|
162
|
+
return [hlsValue(m1, m2, hue + ONE_THIRD),
|
|
163
|
+
hlsValue(m1, m2, hue),
|
|
164
|
+
hlsValue(m1, m2, hue - ONE_THIRD)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 색조와 채도는 그대로 두고 명도만 옮깁니다. 테마를 고른 사람이 고른 것은
|
|
168
|
+
// 색이지 밝기가 아니라서, HLS 로 옮기면 라즈베리는 여전히 라즈베리입니다.
|
|
169
|
+
export function withLightness(value, light) {
|
|
170
|
+
const [red, green, blue] = channels(value);
|
|
171
|
+
const [hue, , sat] = rgbToHls(red, green, blue);
|
|
172
|
+
const out = hlsToRgb(hue, Math.min(Math.max(light, 0.0), 1.0), sat);
|
|
173
|
+
return `#${out.map((c) => hex2(pyround(c * 255))).join("")}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 두 색을 `t` 만큼 섞습니다. 🔴 여기의 반올림도 파이썬 규칙입니다.
|
|
177
|
+
export function mix(one, other, t) {
|
|
178
|
+
let out = "#";
|
|
179
|
+
for (const i of [1, 3, 5]) {
|
|
180
|
+
const av = parseInt(one.slice(i, i + 2), 16);
|
|
181
|
+
const bv = parseInt(other.slice(i, i + 2), 16);
|
|
182
|
+
out += hex2(pyround(av + (bv - av) * t));
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ------------------------------------------------------------------ 보정 */
|
|
188
|
+
|
|
189
|
+
// `needBg` 위에서 목표를 만들되 `keepBg` 위에서 나빠지지 않게.
|
|
190
|
+
//
|
|
191
|
+
// 🔴 `keepBg` 의 하한은 `min(원래 대비, 목표)` 입니다. 원래 2.7 이었으면 2.7 만
|
|
192
|
+
// 지키면 됩니다 - 더 좋아지라고 요구하지 않습니다. 양쪽에 목표를 걸면 낮은
|
|
193
|
+
// 쪽을 끌어올리려다 높은 쪽을 떨어뜨려 답이 없어지는 조합이 생깁니다.
|
|
194
|
+
//
|
|
195
|
+
// 🔴 방향을 미리 정하지 않고 0.000~1.000 을 **1001 단계** 전부 훑어 원래
|
|
196
|
+
// 명도에서 가장 가까운 후보를 고릅니다. 밝은 `bg` 와 검은 `surface` 사이에 낀
|
|
197
|
+
// 색은 위도 아래도 아닌 가운데로 가야 하고, "어두우면 밝힌다" 같은 규칙은
|
|
198
|
+
// 바로 그 자리에서 틀립니다. 같은 입력에는 같은 출력입니다.
|
|
199
|
+
//
|
|
200
|
+
// 답이 없으면 원래 값을 그대로 돌려줍니다. 조용히 포기하는 것이 아니라,
|
|
201
|
+
// `check` 가 나가는 값을 재고 있어서 그 자리는 빨강으로 보입니다.
|
|
202
|
+
export function corrected(value, needBg, keepBg, target = TARGET) {
|
|
203
|
+
const floor = Math.min(contrast(value, keepBg), target);
|
|
204
|
+
if (contrast(value, needBg) >= target) return value;
|
|
205
|
+
const [, origin] = rgbToHls(...channels(value));
|
|
206
|
+
let best = value;
|
|
207
|
+
let nearest = null;
|
|
208
|
+
for (let step = 0; step < 1001; step += 1) {
|
|
209
|
+
const light = step / 1000.0;
|
|
210
|
+
const candidate = withLightness(value, light);
|
|
211
|
+
// 🔴 `floor - 1e-9` 의 여유를 빼지 마십시오. 부동소수 비교라 없으면
|
|
212
|
+
// 원래 값이 그대로 답인 경계 조합이 답 없음으로 갈립니다.
|
|
213
|
+
if (contrast(candidate, needBg) >= target
|
|
214
|
+
&& contrast(candidate, keepBg) >= floor - 1e-9) {
|
|
215
|
+
const gap = Math.abs(light - origin);
|
|
216
|
+
if (nearest === null || gap < nearest) {
|
|
217
|
+
best = candidate;
|
|
218
|
+
nearest = gap;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return best;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 버튼(배경 `button`) 위 글자색. 목표를 넘는 **첫 후보**입니다.
|
|
226
|
+
//
|
|
227
|
+
// 🔴 첫 후보는 원래 글자색인 `surface` 입니다. 이미 목표를 넘는 조합에서는
|
|
228
|
+
// 화면이 한 픽셀도 안 바뀝니다. 다음은 그 테마의 `bg` · `ink` 이고 마지막이
|
|
229
|
+
// 흰색과 검정입니다. 어떤 색이든 둘 중 하나와는 4.58 이상이라 기본 목표
|
|
230
|
+
// 4.5 에서는 답 없는 조합이 없습니다. 명도를 훑지 않는 까닭은 이 글자가 버튼
|
|
231
|
+
// 위 짧은 한 줄이라, 테마 색 셋으로 안 되면 흑백이 가장 읽히기 때문입니다.
|
|
232
|
+
//
|
|
233
|
+
// 🔴 `--label-target` 을 4.58 보다 높게 주면 답 없는 조합이 생깁니다. 그때는
|
|
234
|
+
// 던지지 않고 대비가 가장 높은 후보를 돌려줍니다. `corrected` 와 같이 `check`
|
|
235
|
+
// 가 나가는 값을 재므로 그 자리는 빨강으로 보입니다. 던지면 CLI 가 스택 추적과
|
|
236
|
+
// 종료 코드 1(`check` 의 "어긋남" 과 같은 값)로 끝나 원인이 가려집니다.
|
|
237
|
+
//
|
|
238
|
+
// 🔴 후보는 `seen`(원본 팔레트)에서 가져옵니다. 보정된 값에서 가져오면 글자색이
|
|
239
|
+
// 목록 순서에 딸려 갑니다.
|
|
240
|
+
export function labelOnAccent(seen, button, target = LABEL_TARGET) {
|
|
241
|
+
const candidates = [seen.surface, seen.bg, seen.ink, WHITE, BLACK];
|
|
242
|
+
let best = candidates[0];
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
if (contrast(candidate, button) >= target) return candidate;
|
|
245
|
+
if (contrast(candidate, button) > contrast(best, button)) best = candidate;
|
|
246
|
+
}
|
|
247
|
+
return best;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 한 조합이 CSS 로 나갈 열세 쌍.
|
|
251
|
+
//
|
|
252
|
+
// 🔴 배경(`bg` · `surface`)과 `rule` 은 건드리지 않습니다. 배경을 옮기면 그
|
|
253
|
+
// 배경을 쓰는 다른 모든 색의 대비가 같이 움직여서 방금 맞춘 자리가 다시
|
|
254
|
+
// 어긋납니다.
|
|
255
|
+
//
|
|
256
|
+
// 🔴 판정의 입력은 **언제나 원본 팔레트**(`seen`)입니다. 고친 값을 다음 계산에
|
|
257
|
+
// 먹이면 결과가 목록의 순서에 딸려 갑니다. `seen` 과 `fixed` 를 따로 두는
|
|
258
|
+
// 까닭이 그것입니다.
|
|
259
|
+
export function correctedPalette(values, opts = {}) {
|
|
260
|
+
const target = opts.target === undefined ? TARGET : opts.target;
|
|
261
|
+
const labelTarget =
|
|
262
|
+
opts.labelTarget === undefined ? LABEL_TARGET : opts.labelTarget;
|
|
263
|
+
const seen = {};
|
|
264
|
+
FIELDS.forEach((field, i) => { seen[field] = values[i]; });
|
|
265
|
+
const fixed = { ...seen };
|
|
266
|
+
for (const [field, need, keep] of CONTRAST_JOBS) {
|
|
267
|
+
fixed[field] = corrected(seen[field], seen[need], seen[keep], target);
|
|
268
|
+
}
|
|
269
|
+
const baked = FIELDS.map((field) => [field, fixed[field]]);
|
|
270
|
+
for (const [field, need, keep] of SURFACE_JOBS) {
|
|
271
|
+
baked.push([`${field}-on-surface`,
|
|
272
|
+
corrected(seen[field], seen[need], seen[keep], target)]);
|
|
273
|
+
}
|
|
274
|
+
const done = Object.fromEntries(baked);
|
|
275
|
+
for (const [name, back] of LABEL_JOBS) {
|
|
276
|
+
baked.push([name, labelOnAccent(seen, done[back], labelTarget)]);
|
|
277
|
+
}
|
|
278
|
+
return baked;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 보정을 끈 열세 쌍. 🔴 대조군 전용입니다. `-on-surface` 는 원본과 같고 버튼
|
|
282
|
+
// 글자는 원래 글자색인 `surface` 입니다. 이것으로 `check` 를 돌려 어긋난 자리가
|
|
283
|
+
// 0보다 커야 보정이 일을 하고 있다는 뜻이 됩니다.
|
|
284
|
+
export function rawPalette(values) {
|
|
285
|
+
const seen = {};
|
|
286
|
+
FIELDS.forEach((field, i) => { seen[field] = values[i]; });
|
|
287
|
+
const baked = FIELDS.map((field) => [field, seen[field]]);
|
|
288
|
+
for (const [field] of SURFACE_JOBS) {
|
|
289
|
+
baked.push([`${field}-on-surface`, seen[field]]);
|
|
290
|
+
}
|
|
291
|
+
for (const [name] of LABEL_JOBS) baked.push([name, seen.surface]);
|
|
292
|
+
return baked;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function palettePairs(values, opts = {}) {
|
|
296
|
+
return opts.correct === false
|
|
297
|
+
? rawPalette(values)
|
|
298
|
+
: correctedPalette(values, opts);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* --------------------------------------------------- DESIGN.md 파싱 */
|
|
302
|
+
|
|
303
|
+
// 🔴 `\b` 를 유니코드로 씁니다. 파이썬의 `\b` 는 한글·악센트 글자도 낱말
|
|
304
|
+
// 글자로 보는데 JS 의 기본 `\b` 는 ASCII 만 봐서, 같은 문서에서 파이썬은 안
|
|
305
|
+
// 잡고 JS 는 잡는 줄이 생깁니다.
|
|
306
|
+
const HEX = /#[0-9a-fA-F]{6}(?![\p{L}\p{N}_])/gu;
|
|
307
|
+
|
|
308
|
+
// 파이썬 `str.splitlines()`. 🔴 `split("\n")` 이 아닙니다. 파이썬은 `\v` · `\f` ·
|
|
309
|
+
// `\x85` · `U+2028` 에서도 줄을 끊고, DESIGN.md 74건에 그런 글자가 하나라도
|
|
310
|
+
// 있으면 그 줄부터 결과가 갈립니다.
|
|
311
|
+
function pySplitlines(text) {
|
|
312
|
+
if (text === "") return [];
|
|
313
|
+
const out = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u{2028}\u{2029}]/u);
|
|
314
|
+
if (out.length && out[out.length - 1] === "") out.pop();
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 파이썬 `Path.read_text()` 의 universal newlines 를 흉내 냅니다. 이것을 빼면
|
|
319
|
+
// CRLF 문서에서 줄 끝에 `\r` 가 남아 `line.strip()` 과 정규식이 갈립니다.
|
|
320
|
+
function readDesign(path) {
|
|
321
|
+
return readFileSync(path, "utf8").replace(/\r\n|\r/g, "\n");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// `colors:` YAML 블록. 🔴 들여쓰기가 없는 줄을 만나면 멈추지만 **빈 줄은
|
|
325
|
+
// 멈추지 않습니다** - 빈 줄에서 멈추면 블록 가운데가 통째로 날아갑니다.
|
|
326
|
+
export function parseYamlColors(text) {
|
|
327
|
+
const head = /^colors:\s*$/m.exec(text);
|
|
328
|
+
if (head === null) return new Map();
|
|
329
|
+
const out = new Map();
|
|
330
|
+
const body = text.slice(head.index + head[0].length);
|
|
331
|
+
for (const line of pySplitlines(body)) {
|
|
332
|
+
if (line.trim() && !(line.startsWith(" ") || line.startsWith("\t"))) break;
|
|
333
|
+
const hit = /^\s+([a-z0-9-]+):\s*"?(#[0-9a-fA-F]{6})"?/.exec(line);
|
|
334
|
+
// 🔴 YAML 쪽은 뒤에 온 값이 앞을 **덮습니다**. 산문 쪽과 반대입니다.
|
|
335
|
+
if (hit) out.set(hit[1], hit[2].toLowerCase());
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 산문에서 역할을 추측합니다. 이름은 브랜드마다 달라도 설명에 쓰는 낱말
|
|
341
|
+
// (primary, background, text, border)은 겹칩니다. 74종 중 10종이 이쪽입니다.
|
|
342
|
+
//
|
|
343
|
+
// 🔴 `take()` 는 먼저 잡은 것을 **안 덮습니다**. 줄 순서가 결과를 정하므로
|
|
344
|
+
// 아래 호출 순서와 낱말 순서를 바꾸면 다른 팔레트가 나옵니다.
|
|
345
|
+
export function parseProseColors(text) {
|
|
346
|
+
const out = new Map();
|
|
347
|
+
for (const line of pySplitlines(text)) {
|
|
348
|
+
const hexes = line.match(HEX);
|
|
349
|
+
if (!hexes) continue;
|
|
350
|
+
const low = line.toLowerCase();
|
|
351
|
+
const value = hexes[0].toLowerCase();
|
|
352
|
+
const take = (key, ...words) => {
|
|
353
|
+
if (!out.has(key) && words.some((word) => low.includes(word))) {
|
|
354
|
+
out.set(key, value);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
take("primary", "primary cta", "primary button", "brand accent",
|
|
358
|
+
"primary brand", "brand color", "primary:");
|
|
359
|
+
take("canvas", "background", "canvas", "page bg");
|
|
360
|
+
take("ink", "text", "near-black", "headline", "foreground");
|
|
361
|
+
take("hairline", "border", "divider", "hairline", "rule");
|
|
362
|
+
take("muted", "muted", "secondary text", "subtle text", "gray");
|
|
363
|
+
take("surface-card", "card", "surface", "panel");
|
|
364
|
+
take("warning", "warning", "error", "danger");
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 역할 이름 -> 일곱 변수 둘(라이트, 다크).
|
|
370
|
+
//
|
|
371
|
+
// 🔴 문장의 **순서**가 결과를 정합니다. `dim` · `rule` 의 기본값은 `bg` 휘도
|
|
372
|
+
// 0.4 검사보다 **앞에서** 굽히므로 되돌리기 전의 `ink` · `bg` 를 씁니다. 반대로
|
|
373
|
+
// `d_*` 는 그 검사 **뒤라서** 되돌린 값을 씁니다. 줄을 옮기면 조용히 다른 색이
|
|
374
|
+
// 나옵니다.
|
|
375
|
+
//
|
|
376
|
+
// 🔴 대비는 fail-closed 입니다. 산문 파싱이 배경색을 글자색으로 집는 일이
|
|
377
|
+
// 있었습니다(`lovable`: 대비 1.00, 안 보이는 화면).
|
|
378
|
+
export function buildFromColors(colors) {
|
|
379
|
+
const pick = (names, fallback) => {
|
|
380
|
+
for (const name of names) if (colors.has(name)) return colors.get(name);
|
|
381
|
+
return fallback;
|
|
382
|
+
};
|
|
383
|
+
const accent = pick(["primary", "accent", "brand"], "#0b6e99");
|
|
384
|
+
let ink = pick(["ink", "body-strong", "charcoal", "text"], "#14232e");
|
|
385
|
+
let bg = pick(["canvas", "canvas-soft", "surface-soft", "background"],
|
|
386
|
+
"#eaeef2");
|
|
387
|
+
let surface = pick(["surface-card", "surface-soft", "surface", "card"], WHITE);
|
|
388
|
+
let dim = pick(["muted", "mute", "muted-soft", "body", "stone"],
|
|
389
|
+
mix(ink, bg, 0.45));
|
|
390
|
+
const rule = pick(["hairline", "hairline-soft", "hairline-strong", "border"],
|
|
391
|
+
mix(ink, bg, 0.8));
|
|
392
|
+
const warn = pick(["warning", "error", "danger"], "#9c4a12");
|
|
393
|
+
|
|
394
|
+
// 🔴 `canvas` 가 이미 어두운 테마가 있습니다(다크 정체성 브랜드). 그대로
|
|
395
|
+
// 두면 라이트 쪽이 다크가 되어 토글이 뒤집혀 보입니다.
|
|
396
|
+
if (luminance(bg) < 0.4) {
|
|
397
|
+
const nextBg = mix(bg, WHITE, 0.93);
|
|
398
|
+
const nextInk = mix(ink, BLACK, 0.85);
|
|
399
|
+
bg = nextBg;
|
|
400
|
+
ink = nextInk;
|
|
401
|
+
surface = WHITE;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const dBg = pick(["surface-dark", "surface-dark-soft"], mix(ink, BLACK, 0.55));
|
|
405
|
+
const dSurface = pick(["surface-dark-elevated"], mix(dBg, WHITE, 0.07));
|
|
406
|
+
let dInk = pick(["on-dark"], mix(bg, WHITE, 0.55));
|
|
407
|
+
let dDim = pick(["on-dark-soft"], mix(dInk, dBg, 0.45));
|
|
408
|
+
const dRule = mix(dInk, dBg, 0.78);
|
|
409
|
+
// 어두운 배경에서 짙은 강조색은 가라앉습니다.
|
|
410
|
+
const dAccent = luminance(accent) > 0.18 ? accent : mix(accent, WHITE, 0.35);
|
|
411
|
+
const dWarn = pick(["warning", "error"], "#e0a04a");
|
|
412
|
+
|
|
413
|
+
const readable = (fg, back) => (contrast(fg, back) >= 4.5
|
|
414
|
+
? fg
|
|
415
|
+
: (luminance(back) > 0.5 ? BLACK : WHITE));
|
|
416
|
+
ink = readable(ink, bg);
|
|
417
|
+
dInk = readable(dInk, dBg);
|
|
418
|
+
if (contrast(dim, bg) < 3.0) dim = mix(ink, bg, 0.35);
|
|
419
|
+
if (contrast(dDim, dBg) < 3.0) dDim = mix(dInk, dBg, 0.35);
|
|
420
|
+
|
|
421
|
+
return [[bg, surface, ink, dim, rule, accent, warn],
|
|
422
|
+
[dBg, dSurface, dInk, dDim, dRule, dAccent, dWarn]];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// `ide-vscode` -> `Ide Vscode`. 두 글자 이하 낱말은 대문자입니다.
|
|
426
|
+
export function labelOf(slug) {
|
|
427
|
+
const words = [];
|
|
428
|
+
for (const word of slug.replace(/_/g, "-").split("-")) {
|
|
429
|
+
if (!word) continue;
|
|
430
|
+
words.push(word.length <= 2
|
|
431
|
+
? word.toUpperCase()
|
|
432
|
+
: word.slice(0, 1).toUpperCase() + word.slice(1));
|
|
433
|
+
}
|
|
434
|
+
return words.join(" ");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// DESIGN.md 한 건을 테마로. 🔴 YAML 을 먼저 보고 없으면 산문입니다. 한쪽만
|
|
438
|
+
// 읽으면 열 개가 조용히 기본값으로 남습니다.
|
|
439
|
+
export function themeFromDesign(text) {
|
|
440
|
+
let colors = parseYamlColors(text);
|
|
441
|
+
let prose = false;
|
|
442
|
+
if (colors.size === 0) {
|
|
443
|
+
colors = parseProseColors(text);
|
|
444
|
+
prose = true;
|
|
445
|
+
}
|
|
446
|
+
if (colors.size === 0) return null;
|
|
447
|
+
const [light, dark] = buildFromColors(colors);
|
|
448
|
+
return { light, dark, prose };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 슬러그는 폴더 이름입니다. 파일을 직접 주면 확장자를 뗀 파일 이름이고,
|
|
452
|
+
// `DESIGN.md` 를 직접 주면 그 부모 폴더 이름입니다.
|
|
453
|
+
function slugForFile(path) {
|
|
454
|
+
const name = basename(path);
|
|
455
|
+
if (name.toLowerCase() === "design.md") return basename(dirname(path));
|
|
456
|
+
return basename(name, extname(name));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 테마 이름(슬러그)에 쓰는 글자. 🔴 이름은 CSS 선택자의 따옴표 안, 고르개의
|
|
460
|
+
// HTML 속성, `<script>` 안의 JSON 에 그대로 들어갑니다. `"` 나 `<` 가 든 폴더
|
|
461
|
+
// 이름 하나가 산출물을 깨거나 스크립트를 끼워 넣습니다. 글자와 숫자는 유니코드
|
|
462
|
+
// 전체(한글 폴더 이름 포함)를 받고, 기호는 `.` `-` `_` 만 받습니다. 빈칸, 제어
|
|
463
|
+
// 문자, 서식 문자(방향 바꿈 등), 줄 구분자는 글자가 아니라서 걸립니다. 번들
|
|
464
|
+
// 74종 중 다섯은 이름에 점이 있습니다(`x.ai` 등).
|
|
465
|
+
export const SLUG = /^[\p{L}\p{N}][\p{L}\p{M}\p{N}._-]*$/u;
|
|
466
|
+
|
|
467
|
+
// 🔴 보이지 않는 글자(Default_Ignorable. 예: U+3164 한글 채움 문자, U+FE0F 변형
|
|
468
|
+
// 선택자)는 글자로 분류돼도 받지 않습니다. 고르개에 빈칸처럼 보이거나 다른
|
|
469
|
+
// 이름과 똑같아 보이는 항목이 생깁니다.
|
|
470
|
+
const INVISIBLE = /\p{Default_Ignorable_Code_Point}/u;
|
|
471
|
+
|
|
472
|
+
const SLUG_RULE = `may use only letters, digits, ".", "-" and "_" (no invisible characters)`;
|
|
473
|
+
|
|
474
|
+
export function validSlug(slug) {
|
|
475
|
+
return SLUG.test(slug) && !INVISIBLE.test(slug);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 🔴 산출물에 들어가는 이름은 모두 `resolveThemes` 의 목록에서 나옵니다.
|
|
479
|
+
// 출처(번들, DESIGN.md)와 상관없이 거기서 한 번 더 봅니다.
|
|
480
|
+
export function assertSlugs(names) {
|
|
481
|
+
for (const slug of names) {
|
|
482
|
+
if (!validSlug(slug)) {
|
|
483
|
+
throw new Error(`theme name ${JSON.stringify(slug)} ${SLUG_RULE}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isFile(path) {
|
|
489
|
+
try { return statSync(path).isFile(); } catch { return false; }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// `--design-md` 가 받는 세 모양: 파일 / `DESIGN.md` 를 담은 폴더 /
|
|
493
|
+
// `<이름>/DESIGN.md` 를 담은 폴더. [슬러그, 파일] 목록을 냅니다.
|
|
494
|
+
function designFiles(full) {
|
|
495
|
+
if (statSync(full).isFile()) return [[slugForFile(full), full]];
|
|
496
|
+
const own = join(full, "DESIGN.md");
|
|
497
|
+
if (isFile(own)) return [[basename(full), own]];
|
|
498
|
+
return readdirSync(full).sort()
|
|
499
|
+
.map((entry) => [entry, join(full, entry, "DESIGN.md")])
|
|
500
|
+
.filter(([, design]) => isFile(design));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function loadDesignMd(path) {
|
|
504
|
+
const out = [];
|
|
505
|
+
for (const [raw, file] of designFiles(resolve(path))) {
|
|
506
|
+
// 🔴 이름을 NFC 로 맞춥니다. macOS 는 한글 폴더 이름을 흔히 분해형(NFD)으로
|
|
507
|
+
// 두어, 그대로 쓰면 사용자가 친 `--only 쇼핑몰`(NFC)과 맞지 않습니다.
|
|
508
|
+
const slug = raw.normalize("NFC");
|
|
509
|
+
// 🔴 걸러 버리지 않고 멈춥니다. 조용히 빠진 테마는 고르개에서 사라진 뒤에야
|
|
510
|
+
// 들킵니다.
|
|
511
|
+
if (!validSlug(slug)) {
|
|
512
|
+
throw new Error(`--design-md ${JSON.stringify(file)}: theme name `
|
|
513
|
+
+ `${JSON.stringify(slug)} ${SLUG_RULE}`);
|
|
514
|
+
}
|
|
515
|
+
const theme = themeFromDesign(readDesign(file));
|
|
516
|
+
if (theme === null) continue;
|
|
517
|
+
out.push([slug, { label: labelOf(slug), light: theme.light,
|
|
518
|
+
dark: theme.dark, source: file }]);
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/* ------------------------------------------------------------ 테마 모음 */
|
|
524
|
+
|
|
525
|
+
export function loadBundled(file = join(HERE, "..", "assets", "palettes.json")) {
|
|
526
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
527
|
+
// 🔴 `fields` 를 믿고 그대로 쓰지 않고 엔진의 순서와 맞는지 봅니다. 둘이
|
|
528
|
+
// 어긋난 팔레트는 오류가 아니라 그냥 다른 테마라서 눈으로만 잡힙니다.
|
|
529
|
+
if (data.fields.join(",") !== FIELDS.join(",")) {
|
|
530
|
+
throw new Error(`palettes.json fields mismatch: ${data.fields.join(",")}`);
|
|
531
|
+
}
|
|
532
|
+
const themes = new Map();
|
|
533
|
+
for (const slug of Object.keys(data.themes)) {
|
|
534
|
+
const body = data.themes[slug];
|
|
535
|
+
themes.set(slug, { label: body.label, light: body.light, dark: body.dark,
|
|
536
|
+
source: "bundled" });
|
|
537
|
+
}
|
|
538
|
+
return { themes, default: data.default, source: data.source };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 번들 위에 `--design-md` 를 얹습니다. 🔴 뒤가 이기고, 같은 슬러그는 번들을
|
|
542
|
+
// 덮습니다. 그 덮기가 없으면 소비자가 자기 DESIGN.md 로 번들 테마를 고칠 수
|
|
543
|
+
// 없습니다. `bundled` 는 시험이 다른 번들을 넣을 때만 줍니다.
|
|
544
|
+
export function resolveThemes(opts, bundled = loadBundled()) {
|
|
545
|
+
const themes = new Map(bundled.themes);
|
|
546
|
+
for (const path of opts.designMd) {
|
|
547
|
+
for (const [slug, theme] of loadDesignMd(path)) themes.set(slug, theme);
|
|
548
|
+
}
|
|
549
|
+
assertSlugs(themes.keys());
|
|
550
|
+
let slugs = [...themes.keys()];
|
|
551
|
+
if (opts.only !== null) {
|
|
552
|
+
const allow = new Set(opts.only);
|
|
553
|
+
slugs = slugs.filter((slug) => allow.has(slug));
|
|
554
|
+
}
|
|
555
|
+
slugs.sort();
|
|
556
|
+
const picked = new Map(slugs.map((slug) => [slug, themes.get(slug)]));
|
|
557
|
+
// 🔴 기본 테마가 걸러져 없으면 첫 슬러그입니다. 없는 이름을 `:root` 에
|
|
558
|
+
// 박으면 화면이 색을 통째로 잃습니다.
|
|
559
|
+
let fallback = opts.default === null ? bundled.default : opts.default;
|
|
560
|
+
if (!picked.has(fallback)) fallback = slugs.length ? slugs[0] : null;
|
|
561
|
+
return { themes: picked, default: fallback, source: bundled.source };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 표시명 순서. 고르개가 보여 주는 순서와 `list` 를 같게 둡니다.
|
|
565
|
+
export function byLabel(themes) {
|
|
566
|
+
return [...themes.entries()].sort((one, other) => {
|
|
567
|
+
const a = one[1].label.toLowerCase();
|
|
568
|
+
const b = other[1].label.toLowerCase();
|
|
569
|
+
if (a < b) return -1;
|
|
570
|
+
if (a > b) return 1;
|
|
571
|
+
return one[0] < other[0] ? -1 : (one[0] > other[0] ? 1 : 0);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// 🔴 저장된 이름을 닫힌 집합에 대어 봅니다. 없으면 기본으로 되돌립니다.
|
|
576
|
+
// 이것이 없으면 테마 하나를 지운 다음 배포에서, 그 테마를 고른 사람의 화면이
|
|
577
|
+
// 변수를 잃고 색 없이 뜹니다.
|
|
578
|
+
export function pickTheme(saved, allowed, fallback) {
|
|
579
|
+
const list = Array.isArray(allowed) ? allowed : [...allowed];
|
|
580
|
+
return list.includes(saved) ? saved : fallback;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ------------------------------------------------------------- 산출물 */
|
|
584
|
+
|
|
585
|
+
function declarations(pairs, prefix) {
|
|
586
|
+
return pairs.map(([name, value]) => `--${prefix}${name}:${value}`).join(";");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// `--dark-via` 가 고른 자리에 다크 규칙을 겁니다. `base` 는 라이트 규칙의
|
|
590
|
+
// 선택자입니다.
|
|
591
|
+
function darkRule(base, body, darkVia) {
|
|
592
|
+
if (darkVia === "media") return `@media (prefers-color-scheme: dark){${base}{${body}}}`;
|
|
593
|
+
if (darkVia === "class") return `${base}.dark{${body}}`;
|
|
594
|
+
return `${base}[data-mode="dark"]{${body}}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function renderCss(resolved, opts) {
|
|
598
|
+
const { prefix, darkVia } = opts;
|
|
599
|
+
const out = [];
|
|
600
|
+
// 🔴 `:root` 가 언제나 먼저이고 기본 테마의 라이트와 다크를 둘 다 듭니다.
|
|
601
|
+
// 테마 규칙은 `html[data-theme=...]` 이 붙어 같은 모드의 `:root` 규칙보다
|
|
602
|
+
// 특정성이 높아 이깁니다. `:root` 를 두는 까닭은 JS 가 꺼졌거나 저장된 이름이
|
|
603
|
+
// 목록 밖일 때도 화면이 읽히게 하는 것입니다. 다크가 빠지면 고르개 없이
|
|
604
|
+
// 모드만 쓰는 화면(`<html>` 에 `data-theme` 이 없음)에서 다크가 켜지지 않습니다.
|
|
605
|
+
if (resolved.default !== null) {
|
|
606
|
+
const base = resolved.themes.get(resolved.default);
|
|
607
|
+
out.push(`:root{${declarations(palettePairs(base.light, opts), prefix)}}`);
|
|
608
|
+
out.push(darkRule(":root", declarations(palettePairs(base.dark, opts), prefix),
|
|
609
|
+
darkVia));
|
|
610
|
+
}
|
|
611
|
+
for (const [slug, theme] of [...resolved.themes.entries()].sort()) {
|
|
612
|
+
const selector = `html[data-theme="${slug}"]`;
|
|
613
|
+
out.push(`${selector}{${declarations(palettePairs(theme.light, opts), prefix)}}`);
|
|
614
|
+
out.push(darkRule(selector,
|
|
615
|
+
declarations(palettePairs(theme.dark, opts), prefix), darkVia));
|
|
616
|
+
}
|
|
617
|
+
out.push("/* 그림자는 모드의 성질입니다. 테마마다 두면 같은 값이 반복됩니다. */");
|
|
618
|
+
out.push(`html{--${prefix}shadow:${SHADOW_LIGHT}}`);
|
|
619
|
+
out.push(darkRule("html", `--${prefix}shadow:${SHADOW_DARK}`, darkVia));
|
|
620
|
+
return out.join("\n");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function renderJson(resolved, opts) {
|
|
624
|
+
const themes = {};
|
|
625
|
+
for (const [slug, theme] of [...resolved.themes.entries()].sort()) {
|
|
626
|
+
const asObject = (values) => Object.fromEntries(
|
|
627
|
+
palettePairs(values, opts).map(([name, value]) =>
|
|
628
|
+
[`${opts.prefix}${name}`, value]));
|
|
629
|
+
themes[slug] = { label: theme.label, light: asObject(theme.light),
|
|
630
|
+
dark: asObject(theme.dark) };
|
|
631
|
+
}
|
|
632
|
+
return JSON.stringify({
|
|
633
|
+
names: OUT_NAMES.map((name) => `${opts.prefix}${name}`),
|
|
634
|
+
default: resolved.default,
|
|
635
|
+
corrected: opts.correct !== false,
|
|
636
|
+
target: opts.target,
|
|
637
|
+
labelTarget: opts.labelTarget,
|
|
638
|
+
source: resolved.source,
|
|
639
|
+
themes,
|
|
640
|
+
}, null, 2);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function esc(text) {
|
|
644
|
+
return text.replace(/&/g, "&").replace(/</g, "<")
|
|
645
|
+
.replace(/>/g, ">").replace(/"/g, """);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 고르개 마크업. 테마 목록이 닫힌 집합으로 박혀 있습니다.
|
|
649
|
+
//
|
|
650
|
+
// 🔴 `--dark-via media` 면 모드 버튼을 내지 않습니다. 그 CSS 의 다크는 OS 설정만
|
|
651
|
+
// 따르고, 버튼은 그것을 바꿀 수 없어서 눌러도 아무 일이 없는 버튼이 됩니다.
|
|
652
|
+
function widgetMarkup(entries, darkVia) {
|
|
653
|
+
const options = entries
|
|
654
|
+
.map(([slug, theme]) =>
|
|
655
|
+
` <option value="${esc(slug)}">${esc(theme.label)}</option>`)
|
|
656
|
+
.join("\n");
|
|
657
|
+
const button = darkVia === "media" ? "" : `
|
|
658
|
+
<button id="theme-mode" type="button" aria-pressed="false"
|
|
659
|
+
aria-label="Dark mode">Dark</button>`;
|
|
660
|
+
return `<!-- 1. 고르개 마크업. 테마 목록이 닫힌 집합으로 박혀 있습니다. -->
|
|
661
|
+
<div class="theme-pick-wrap">
|
|
662
|
+
<select id="theme-pick" aria-label="Theme">
|
|
663
|
+
${options}
|
|
664
|
+
</select>${button}
|
|
665
|
+
</div>`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// head 스크립트. 닫힌 집합과 기본 테마를 박아 넣습니다.
|
|
669
|
+
function widgetHead(slugs, fallback) {
|
|
670
|
+
return `<!-- 2. head 스크립트. 🔴 <head> 안에 두십시오. DOM 을 보지 않으므로 즉시 돌고,
|
|
671
|
+
그것이 첫 그림의 깜빡임을 막는 유일한 방법입니다. 본문 뒤로 옮기면 기본
|
|
672
|
+
테마가 한 프레임 보인 다음 바뀝니다. -->
|
|
673
|
+
<script>
|
|
674
|
+
(function () {
|
|
675
|
+
// 🔴 닫힌 집합입니다. 저장된 이름을 여기에 대어 보고 없으면 기본으로
|
|
676
|
+
// 되돌립니다. 대어 보지 않으면 지워진 테마를 고른 사람의 화면이 색을
|
|
677
|
+
// 통째로 잃습니다.
|
|
678
|
+
var THEMES = ${JSON.stringify(slugs)};
|
|
679
|
+
var FALLBACK = ${JSON.stringify(fallback)};
|
|
680
|
+
function pickTheme(saved, allowed, fallback) {
|
|
681
|
+
return allowed.indexOf(saved) >= 0 ? saved : fallback;
|
|
682
|
+
}
|
|
683
|
+
var root = document.documentElement;
|
|
684
|
+
var saved = null, mode = null;
|
|
685
|
+
try {
|
|
686
|
+
saved = localStorage.getItem("theme");
|
|
687
|
+
mode = localStorage.getItem("mode");
|
|
688
|
+
} catch (err) { /* 저장소가 막힌 브라우저. 기본값으로 갑니다. */ }
|
|
689
|
+
root.setAttribute("data-theme", pickTheme(saved, THEMES, FALLBACK));
|
|
690
|
+
root.setAttribute("data-mode", mode === "dark" ? "dark" : "light");
|
|
691
|
+
// 🔴 .dark 클래스도 같이 겁니다. --dark-via class 로 낸 CSS 는 이것만 봅니다.
|
|
692
|
+
root.classList.toggle("dark", mode === "dark");
|
|
693
|
+
window.__themePick = pickTheme;
|
|
694
|
+
window.__themeList = THEMES;
|
|
695
|
+
window.__themeFallback = FALLBACK;
|
|
696
|
+
})();
|
|
697
|
+
</script>`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// 배선 스크립트. 박아 넣을 값이 없어 상수입니다.
|
|
701
|
+
const WIDGET_WIRE = `<!-- 3. 배선 스크립트.
|
|
702
|
+
🔴 경고: 이 스크립트는 자기가 배선할 마크업보다 **먼저 돌 수 있습니다.**
|
|
703
|
+
그러면 getElementById 가 빈손이고 고르개가 영원히 안 움직입니다
|
|
704
|
+
(실제 배포에서 두 번 났습니다). readyState 를 보고 즉시 또는
|
|
705
|
+
DOMContentLoaded 에서 부르는 래퍼로 감쌉니다. -->
|
|
706
|
+
<script>
|
|
707
|
+
(function () {
|
|
708
|
+
function wire() {
|
|
709
|
+
var root = document.documentElement;
|
|
710
|
+
var pick = document.getElementById("theme-pick");
|
|
711
|
+
var mode = document.getElementById("theme-mode");
|
|
712
|
+
function save(key, value) {
|
|
713
|
+
try { localStorage.setItem(key, value); } catch (err) { /* 막힌 저장소 */ }
|
|
714
|
+
}
|
|
715
|
+
if (pick) {
|
|
716
|
+
pick.value = root.getAttribute("data-theme");
|
|
717
|
+
pick.addEventListener("change", function () {
|
|
718
|
+
var next = (window.__themePick || function (s) { return s; })(
|
|
719
|
+
pick.value, window.__themeList || [], window.__themeFallback);
|
|
720
|
+
root.setAttribute("data-theme", next);
|
|
721
|
+
save("theme", next);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
if (mode) {
|
|
725
|
+
var paint = function () {
|
|
726
|
+
// 🔴 저장하는 값은 "light" 와 "dark" 둘뿐입니다.
|
|
727
|
+
var dark = root.getAttribute("data-mode") === "dark";
|
|
728
|
+
mode.setAttribute("aria-pressed", dark ? "true" : "false");
|
|
729
|
+
mode.textContent = dark ? "Light" : "Dark";
|
|
730
|
+
};
|
|
731
|
+
paint();
|
|
732
|
+
mode.addEventListener("click", function () {
|
|
733
|
+
var next = root.getAttribute("data-mode") === "dark" ? "light" : "dark";
|
|
734
|
+
root.setAttribute("data-mode", next);
|
|
735
|
+
root.classList.toggle("dark", next === "dark");
|
|
736
|
+
save("mode", next);
|
|
737
|
+
paint();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (document.readyState === "loading") {
|
|
742
|
+
document.addEventListener("DOMContentLoaded", wire);
|
|
743
|
+
} else {
|
|
744
|
+
wire();
|
|
745
|
+
}
|
|
746
|
+
})();
|
|
747
|
+
</script>`;
|
|
748
|
+
|
|
749
|
+
// 🔴 세 덩이는 빈 줄 하나로 가릅니다. 두는 곳이 서로 달라서(마크업 자리,
|
|
750
|
+
// <head>, 마크업 뒤) 한 덩이로 합치지 않습니다. 모드 버튼은 `data-mode` 와
|
|
751
|
+
// `.dark` 를 함께 바꾸므로 `--dark-via attr` 와 `class` CSS 에 모두 맞습니다.
|
|
752
|
+
//
|
|
753
|
+
// 🔴 `<script>` 안의 JSON 에는 이름만 들어갑니다. 이름은 `resolveThemes` 가
|
|
754
|
+
// SLUG 로 검사해 `<`, `/`, 줄 구분자가 없으므로 따로 이스케이프하지 않습니다.
|
|
755
|
+
export function renderWidget(resolved, opts = {}) {
|
|
756
|
+
const entries = byLabel(resolved.themes);
|
|
757
|
+
const slugs = entries.map(([slug]) => slug);
|
|
758
|
+
return [widgetMarkup(entries, opts.darkVia), widgetHead(slugs, resolved.default),
|
|
759
|
+
WIDGET_WIRE].join("\n\n");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* ---------------------------------------------------------------- check */
|
|
763
|
+
|
|
764
|
+
// 조합마다 재는 자리. 🔴 본문 대비(`ink` 대 `bg`)는 **보고만** 하고 판정하지
|
|
765
|
+
// 않습니다. 팔레트 74개를 전부 4.5 로 끌어올리면 테마가 테마가 아닙니다.
|
|
766
|
+
export function auditCombo(values, pairs, opts) {
|
|
767
|
+
const target = opts.target;
|
|
768
|
+
const labelTarget = opts.labelTarget;
|
|
769
|
+
const seen = {};
|
|
770
|
+
FIELDS.forEach((field, i) => { seen[field] = values[i]; });
|
|
771
|
+
const out = Object.fromEntries(pairs);
|
|
772
|
+
const rows = [];
|
|
773
|
+
const add = (spot, fg, bg, need) => {
|
|
774
|
+
rows.push({ spot, fg, bg, got: contrast(fg, bg), need });
|
|
775
|
+
};
|
|
776
|
+
add("accent vs bg", out.accent, seen.bg, target);
|
|
777
|
+
// 🔴 하한은 `min(원래 대비, 목표)` 입니다. 더 좋아지라고 요구하지 않습니다.
|
|
778
|
+
add("accent vs surface(keep)", out.accent, seen.surface,
|
|
779
|
+
Math.min(contrast(seen.accent, seen.surface), target));
|
|
780
|
+
for (const [field] of SURFACE_JOBS) {
|
|
781
|
+
const value = out[`${field}-on-surface`];
|
|
782
|
+
add(`${field}-on-surface vs surface`, value, seen.surface, target);
|
|
783
|
+
add(`${field}-on-surface vs bg(keep)`, value, seen.bg,
|
|
784
|
+
Math.min(contrast(seen[field], seen.bg), target));
|
|
785
|
+
}
|
|
786
|
+
for (const [name, back] of LABEL_JOBS) {
|
|
787
|
+
add(`${name} vs ${back}`, out[name], out[back], labelTarget);
|
|
788
|
+
}
|
|
789
|
+
return {
|
|
790
|
+
rows,
|
|
791
|
+
body: contrast(out.ink, seen.bg),
|
|
792
|
+
bad: rows.filter((row) => row.got < row.need - 1e-9),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function audit(resolved, opts) {
|
|
797
|
+
const combos = [];
|
|
798
|
+
let checks = 0;
|
|
799
|
+
const bad = [];
|
|
800
|
+
for (const [slug, theme] of [...resolved.themes.entries()].sort()) {
|
|
801
|
+
for (const [mode, values] of [["light", theme.light], ["dark", theme.dark]]) {
|
|
802
|
+
const pairs = palettePairs(values, opts);
|
|
803
|
+
const seen = auditCombo(values, pairs, opts);
|
|
804
|
+
checks += seen.rows.length;
|
|
805
|
+
combos.push({ slug, mode, body: seen.body, bad: seen.bad });
|
|
806
|
+
for (const row of seen.bad) bad.push({ slug, mode, ...row });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return { themes: resolved.themes.size, combos: combos.length, checks, bad,
|
|
810
|
+
corrected: opts.correct !== false, detail: combos };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/* ------------------------------------------------------------------ CLI */
|
|
814
|
+
|
|
815
|
+
const COMMANDS = new Set(["list", "css", "json", "widget", "check"]);
|
|
816
|
+
const DARK_VIA = new Set(["attr", "media", "class"]);
|
|
817
|
+
|
|
818
|
+
// `--prefix` 에 쓰는 글자. 🔴 접두사는 `--<접두사><이름>` 에 그대로 들어갑니다.
|
|
819
|
+
// `;` 나 `}` 가 들면 그 뒤 선언이 통째로 깨집니다. 기본은 빈 문자열입니다.
|
|
820
|
+
const PREFIX = /^[A-Za-z0-9_-]*$/;
|
|
821
|
+
|
|
822
|
+
const USAGE = `node scripts/themes.mjs <list|css|json|widget|check> [options]
|
|
823
|
+
|
|
824
|
+
--design-md PATH DESIGN.md 를 읽어 테마를 더하거나 덮는다 (여러 번 가능)
|
|
825
|
+
--only SLUGS 쉼표로 가른 허용 목록
|
|
826
|
+
--default SLUG :root 가 들 테마 (기본 superhuman)
|
|
827
|
+
--prefix STR 변수 이름 앞에 붙는다 (영문, 숫자, -, _)
|
|
828
|
+
--dark-via MODE attr | media | class
|
|
829
|
+
--target N -on-surface 와 accent 의 목표 (기본 3.0)
|
|
830
|
+
--label-target N 버튼 글자 목표 (기본 4.5)
|
|
831
|
+
--no-correct 원본 값을 그대로 낸다 (대조군 전용)
|
|
832
|
+
--out FILE 표준출력 대신 파일로
|
|
833
|
+
--json check 에서만. 기계가 읽는 감사 결과`;
|
|
834
|
+
|
|
835
|
+
class ArgError extends Error {}
|
|
836
|
+
|
|
837
|
+
function takeValue(flag, rest) {
|
|
838
|
+
if (!rest.length) throw new ArgError(`${flag} needs a value`);
|
|
839
|
+
return rest.shift();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function positive(flag, text) {
|
|
843
|
+
const value = Number(text);
|
|
844
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
845
|
+
throw new ArgError(`${flag} needs a positive number, got ${text}`);
|
|
846
|
+
}
|
|
847
|
+
return value;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function darkVia(text) {
|
|
851
|
+
if (!DARK_VIA.has(text)) {
|
|
852
|
+
throw new ArgError(`--dark-via must be attr|media|class`);
|
|
853
|
+
}
|
|
854
|
+
return text;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function prefixOf(text) {
|
|
858
|
+
if (!PREFIX.test(text)) {
|
|
859
|
+
throw new ArgError(`--prefix may use only letters, digits, "-" and "_",`
|
|
860
|
+
+ ` got ${JSON.stringify(text)}`);
|
|
861
|
+
}
|
|
862
|
+
return text;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 값을 받는 옵션. 🔴 다음 인자는 `-` 로 시작해도 값입니다(`--prefix -x`).
|
|
866
|
+
const VALUE_OPTIONS = new Map([
|
|
867
|
+
["--design-md", (opts, value) => { opts.designMd.push(value); }],
|
|
868
|
+
// 🔴 이름은 NFC 로 맞춥니다. 슬러그도 NFC 라 분해형으로 친 한글 이름이
|
|
869
|
+
// 말없이 걸러지지 않습니다.
|
|
870
|
+
["--only", (opts, value) => {
|
|
871
|
+
opts.only = value.split(",").map((s) => s.trim().normalize("NFC")).filter(Boolean);
|
|
872
|
+
}],
|
|
873
|
+
["--default", (opts, value) => { opts.default = value.normalize("NFC"); }],
|
|
874
|
+
["--prefix", (opts, value) => { opts.prefix = prefixOf(value); }],
|
|
875
|
+
["--dark-via", (opts, value) => { opts.darkVia = darkVia(value); }],
|
|
876
|
+
["--target", (opts, value) => { opts.target = positive("--target", value); }],
|
|
877
|
+
["--label-target", (opts, value) => {
|
|
878
|
+
opts.labelTarget = positive("--label-target", value);
|
|
879
|
+
}],
|
|
880
|
+
["--out", (opts, value) => { opts.out = value; }],
|
|
881
|
+
]);
|
|
882
|
+
|
|
883
|
+
const FLAG_OPTIONS = new Map([
|
|
884
|
+
["--no-correct", (opts) => { opts.correct = false; }],
|
|
885
|
+
["--json", (opts) => { opts.json = true; }],
|
|
886
|
+
["-h", (opts) => { opts.command = "help"; }],
|
|
887
|
+
["--help", (opts) => { opts.command = "help"; }],
|
|
888
|
+
]);
|
|
889
|
+
|
|
890
|
+
function applyArg(opts, arg, rest) {
|
|
891
|
+
if (VALUE_OPTIONS.has(arg)) VALUE_OPTIONS.get(arg)(opts, takeValue(arg, rest));
|
|
892
|
+
else if (FLAG_OPTIONS.has(arg)) FLAG_OPTIONS.get(arg)(opts);
|
|
893
|
+
else if (arg.startsWith("-")) throw new ArgError(`unknown option ${arg}`);
|
|
894
|
+
else if (opts.command === null) opts.command = arg;
|
|
895
|
+
else throw new ArgError(`unexpected argument ${arg}`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function checkCommand(opts) {
|
|
899
|
+
if (opts.command === "help") return;
|
|
900
|
+
if (opts.command === null) throw new ArgError("no command");
|
|
901
|
+
if (!COMMANDS.has(opts.command)) {
|
|
902
|
+
throw new ArgError(`unknown command ${opts.command}`);
|
|
903
|
+
}
|
|
904
|
+
if (opts.json && opts.command !== "check") {
|
|
905
|
+
throw new ArgError("--json is for check only");
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export function parseArgs(argv) {
|
|
910
|
+
const opts = { command: null, designMd: [], only: null, default: null,
|
|
911
|
+
prefix: "", darkVia: "attr", target: TARGET,
|
|
912
|
+
labelTarget: LABEL_TARGET, correct: true, out: null,
|
|
913
|
+
json: false };
|
|
914
|
+
const rest = [...argv];
|
|
915
|
+
while (rest.length) applyArg(opts, rest.shift(), rest);
|
|
916
|
+
checkCommand(opts);
|
|
917
|
+
return opts;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// 🔴 `--out` 이 심볼릭 링크면 쓰지 않습니다. 링크를 따라가면 프로젝트 밖의
|
|
921
|
+
// 파일을 덮거나, 끊긴 링크가 가리키는 자리에 새 파일을 만듭니다.
|
|
922
|
+
function refuseLink(path) {
|
|
923
|
+
let link = false;
|
|
924
|
+
try {
|
|
925
|
+
link = lstatSync(path).isSymbolicLink();
|
|
926
|
+
} catch (err) {
|
|
927
|
+
if (err.code !== "ENOENT") throw err;
|
|
928
|
+
}
|
|
929
|
+
if (link) {
|
|
930
|
+
throw new Error(`--out ${JSON.stringify(path)} is a symbolic link; not writing through it`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function emit(text, out) {
|
|
935
|
+
if (out === null) {
|
|
936
|
+
process.stdout.write(`${text}\n`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
// 🔴 `--out` 은 css 뿐 아니라 json · widget · list 에서도 돕니다. 산출물을
|
|
940
|
+
// 파일로 받는 쪽이 명령마다 다르면 쓰는 사람이 외울 것이 늘어납니다.
|
|
941
|
+
const path = resolve(out);
|
|
942
|
+
refuseLink(path);
|
|
943
|
+
writeFileSync(path, `${text}\n`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const RENDER = new Map([
|
|
947
|
+
["list", (resolved) => byLabel(resolved.themes)
|
|
948
|
+
.map(([slug, theme]) => `${slug}\t${theme.label}`).join("\n")],
|
|
949
|
+
["css", renderCss],
|
|
950
|
+
["json", renderJson],
|
|
951
|
+
["widget", renderWidget],
|
|
952
|
+
]);
|
|
953
|
+
|
|
954
|
+
function checkText(report) {
|
|
955
|
+
const lines = report.bad.map((row) =>
|
|
956
|
+
`FAIL ${row.slug}/${row.mode} ${row.spot} ${row.fg} on ${row.bg}`
|
|
957
|
+
+ ` ${row.got.toFixed(2)} < ${row.need.toFixed(2)}`);
|
|
958
|
+
lines.push(`check: themes ${report.themes} combos ${report.combos}`
|
|
959
|
+
+ ` checks ${report.checks}`
|
|
960
|
+
+ ` corrected ${report.corrected ? "on" : "off"}`
|
|
961
|
+
+ ` violations ${report.bad.length}`);
|
|
962
|
+
return lines.join("\n");
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function checkJson(report) {
|
|
966
|
+
return JSON.stringify({
|
|
967
|
+
themes: report.themes, combos: report.combos, checks: report.checks,
|
|
968
|
+
corrected: report.corrected, violations: report.bad.length,
|
|
969
|
+
bad: report.bad, detail: report.detail.map((row) => ({
|
|
970
|
+
slug: row.slug, mode: row.mode, body: row.body,
|
|
971
|
+
violations: row.bad.length })),
|
|
972
|
+
}, null, 2);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// [내보낼 글, 종료 코드]. 🔴 1 을 낼 수 있는 것은 `check` 뿐입니다.
|
|
976
|
+
function output(resolved, opts) {
|
|
977
|
+
if (opts.command !== "check") {
|
|
978
|
+
return [RENDER.get(opts.command)(resolved, opts), 0];
|
|
979
|
+
}
|
|
980
|
+
const report = audit(resolved, opts);
|
|
981
|
+
return [opts.json ? checkJson(report) : checkText(report),
|
|
982
|
+
report.bad.length ? 1 : 0];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function parseOrNull(argv) {
|
|
986
|
+
try {
|
|
987
|
+
return parseArgs(argv);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
if (!(err instanceof ArgError)) throw err;
|
|
990
|
+
process.stderr.write(`${err.message}\n\n${USAGE}\n`);
|
|
991
|
+
return null;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function resolveOrNull(opts) {
|
|
996
|
+
let resolved;
|
|
997
|
+
try {
|
|
998
|
+
resolved = resolveThemes(opts);
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
process.stderr.write(`${err.message}\n`);
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
if (resolved.default === null) {
|
|
1004
|
+
process.stderr.write("no themes left after --only\n");
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
return resolved;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function main(argv) {
|
|
1011
|
+
const opts = parseOrNull(argv);
|
|
1012
|
+
if (opts === null) return 2;
|
|
1013
|
+
if (opts.command === "help") {
|
|
1014
|
+
process.stdout.write(`${USAGE}\n`);
|
|
1015
|
+
return 0;
|
|
1016
|
+
}
|
|
1017
|
+
const resolved = resolveOrNull(opts);
|
|
1018
|
+
if (resolved === null) return 2;
|
|
1019
|
+
const [text, code] = output(resolved, opts);
|
|
1020
|
+
// 🔴 `--out` 을 못 쓰면 2 입니다. 던지게 두면 종료 코드가 1 이 되어 `check`
|
|
1021
|
+
// 의 "어긋남" 과 구별되지 않습니다.
|
|
1022
|
+
try {
|
|
1023
|
+
emit(text, opts.out);
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
process.stderr.write(`${err.message}\n`);
|
|
1026
|
+
return 2;
|
|
1027
|
+
}
|
|
1028
|
+
return code;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// 🔴 직접 실행일 때만 돕니다. import 쪽에서 최상위 실행이 일어나면 자기 시험이
|
|
1032
|
+
// 엔진을 부를 때마다 CLI 가 딸려 돌고 종료 코드까지 섞입니다.
|
|
1033
|
+
//
|
|
1034
|
+
// 🔴 두 경로를 모두 `realpathSync` 로 비교합니다. Node 는 `import.meta.url` 에
|
|
1035
|
+
// 링크를 푼 실제 경로를 주고, `process.argv[1]` 은 부른 경로 그대로 둡니다.
|
|
1036
|
+
// 플러그인 캐시, macOS 의 `/tmp`, Windows junction 처럼 링크가 낀 경로로 부르면
|
|
1037
|
+
// 한쪽만 푼 비교는 거짓이 되어 CLI 가 아무것도 내지 않고 종료 코드 0 으로
|
|
1038
|
+
// 끝납니다.
|
|
1039
|
+
function invokedDirectly() {
|
|
1040
|
+
try {
|
|
1041
|
+
return realpathSync(process.argv[1])
|
|
1042
|
+
=== realpathSync(fileURLToPath(import.meta.url));
|
|
1043
|
+
} catch {
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 🔴 읽는 쪽이 먼저 닫히면(`json | head`) 남은 쓰기가 EPIPE 로 끝납니다. 받지
|
|
1049
|
+
// 않으면 스택 추적과 함께 종료 코드 1(`check` 의 "어긋남" 과 같은 값)이 됩니다.
|
|
1050
|
+
// EPIPE 만 받아 이미 정한 종료 코드로 끝내고, 다른 오류는 그대로 던집니다.
|
|
1051
|
+
export function onStdoutError(err, exit = (code) => process.exit(code)) {
|
|
1052
|
+
if (err && err.code === "EPIPE") {
|
|
1053
|
+
exit(process.exitCode ?? 0);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
throw err;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// 🔴 `process.exit()` 가 아니라 `process.exitCode` 입니다. `process.exit()` 는
|
|
1060
|
+
// 표준출력에 남은 쓰기를 기다리지 않습니다. 표준출력이 파이프이면 64KiB 를
|
|
1061
|
+
// 넘는 출력이 잘립니다. 번들 전체의 `json`(약 70KB)이 65,536 바이트에서 잘려
|
|
1062
|
+
// 깨진 JSON 이 되는 것을 Linux 와 macOS 의 Node 22, 24 에서 확인했습니다.
|
|
1063
|
+
// 에이전트는 출력을 파이프로 받습니다.
|
|
1064
|
+
if (invokedDirectly()) {
|
|
1065
|
+
process.stdout.on("error", onStdoutError);
|
|
1066
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1067
|
+
}
|