@mafianacho/nacholens 0.0.0-stage → 0.3.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 +202 -0
- package/NOTICE +13 -0
- package/README.de.md +46 -0
- package/README.fr.md +46 -0
- package/README.ja.md +46 -0
- package/README.ko.md +46 -0
- package/README.md +45 -2
- package/README.zh.md +46 -0
- package/demo/shop/v1/README.md +7 -0
- package/demo/shop/v1/api.js +78 -0
- package/demo/shop/v1/db.js +38 -0
- package/demo/shop/v1/gateway.js +50 -0
- package/demo/shop/v1/mock-external.js +17 -0
- package/demo/shop/v1/public/app.js +32 -0
- package/demo/shop/v1/public/format.js +2 -0
- package/demo/shop/v1/public/index.html +13 -0
- package/demo/shop/v1/public/styles.css +4 -0
- package/demo/shop/v2/README.md +7 -0
- package/demo/shop/v2/api.js +88 -0
- package/demo/shop/v2/db.js +38 -0
- package/demo/shop/v2/gateway.js +50 -0
- package/demo/shop/v2/mock-external.js +17 -0
- package/demo/shop/v2/public/app.js +33 -0
- package/demo/shop/v2/public/format.js +3 -0
- package/demo/shop/v2/public/index.html +13 -0
- package/demo/shop/v2/public/styles.css +5 -0
- package/docs/guide.ko.md +97 -0
- package/docs/guide.md +142 -0
- package/docs/model-schema.md +137 -0
- package/docs/review.md +78 -0
- package/examples/demo-shop.artifact.html +1535 -0
- package/examples/demo-shop.artifact.json +7 -0
- package/examples/demo-shop.html +1535 -0
- package/examples/external/README.md +12 -0
- package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
- package/examples/external/full-stack-fastapi-template/changes.json +56 -0
- package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
- package/models/demo-shop/changes.auto.json +121 -0
- package/models/demo-shop/changes.example.json +16 -0
- package/models/demo-shop/intent.example.json +75 -0
- package/models/demo-shop/model.json +3151 -0
- package/package.json +60 -4
- package/skills/nacholens/SKILL.md +235 -0
- package/src/cli.js +377 -0
- package/src/demo.js +42 -0
- package/src/diff.js +254 -0
- package/src/gitio.js +67 -0
- package/src/install.js +94 -0
- package/src/model.js +434 -0
- package/src/page.js +87 -0
- package/src/player/fonts/FONTLOG.md +30 -0
- package/src/player/fonts/OFL-Anton.txt +93 -0
- package/src/player/fonts/OFL-IBMPlex.txt +94 -0
- package/src/player/fonts/anton-400.woff2 +0 -0
- package/src/player/fonts/nacho-mono-500.woff2 +0 -0
- package/src/player/fonts/nacho-mono-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-400.woff2 +0 -0
- package/src/player/fonts/nacho-sans-500.woff2 +0 -0
- package/src/player/fonts/nacho-sans-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-700.woff2 +0 -0
- package/src/player/player.css +318 -0
- package/src/player/player.js +872 -0
- package/src/player/review.css +49 -0
- package/src/player/review.js +230 -0
- package/src/player/template.html +58 -0
- package/src/review.js +321 -0
- package/src/scan.js +255 -0
- package/src/template.js +36 -0
- package/src/verify.js +90 -0
package/src/model.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// Model + change-overlay validation. This is the single source of truth for the schema
|
|
2
|
+
// (README documents it). Pure functions, no I/O, no dependencies.
|
|
3
|
+
|
|
4
|
+
export const KINDS = ["client", "gateway", "service", "datastore", "external"];
|
|
5
|
+
export const STEP_KINDS = ["request", "response", "call", "internal"];
|
|
6
|
+
export const CHANGE_KINDS = ["added", "modified", "removed"];
|
|
7
|
+
// optional drill-down detail (all fields optional; a model without them renders as before)
|
|
8
|
+
export const PART_ROLES = ["route", "handler", "middleware", "service", "port", "adapter", "job", "module"];
|
|
9
|
+
export const DATA_OPS = ["read", "insert", "update", "upsert", "delete"];
|
|
10
|
+
export const DETAIL_KINDS = ["part", "wire", "table", "endpoint"];
|
|
11
|
+
export const PAYLOAD_FORMATS = ["json", "http", "sql", "row", "text", "vector", "sse", "ws"];
|
|
12
|
+
// payload values are shapes and placeholders (<uuid>, <text>), never real people's data
|
|
13
|
+
const PERSONAL = [
|
|
14
|
+
[/[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b)[A-Za-z0-9-]+\.[A-Za-z]{2,}/, "이메일 주소"],
|
|
15
|
+
[/\b0\d{1,2}[- .]?\d{3,4}[- .]?\d{4}\b|\+\d{1,3}[- .]?\d{2,4}[- .]?\d{3,4}[- .]?\d{3,4}/, "전화번호"],
|
|
16
|
+
[/\b\d{6}[- ]?[1-4]\d{6}\b/, "주민등록번호 형식"],
|
|
17
|
+
[/\d{10,}/, "10자리 이상 숫자"],
|
|
18
|
+
];
|
|
19
|
+
const TABLE = /^[A-Za-z_][A-Za-z0-9_.]{0,62}$/;
|
|
20
|
+
const COLUMN = /^[A-Za-z_][A-Za-z0-9_.\[\]]{0,62}$/;
|
|
21
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "WS", "RPC", "SSE"];
|
|
22
|
+
// secret-looking strings are refused anywhere in a model: detail views name an auth scheme, never a value
|
|
23
|
+
const SECRET = /(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}|-----BEGIN|xox[abprs]-[A-Za-z0-9-]{8,}|(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]{16,})/;
|
|
24
|
+
|
|
25
|
+
const ID = /^[a-z][a-z0-9-]{0,47}$/;
|
|
26
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
27
|
+
const isStr = (v, max = 400) =>
|
|
28
|
+
typeof v === "string" && v.trim().length > 0 && v.length <= max;
|
|
29
|
+
|
|
30
|
+
export function linkKey(a, b) {
|
|
31
|
+
return [a, b].sort().join("|");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ordered component ids a step travels through (from, via..., to). */
|
|
35
|
+
export function stepPath(step) {
|
|
36
|
+
return [step.from, ...(step.via ?? []), step.to];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function checkEvidence(list, where, errors) {
|
|
40
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
41
|
+
errors.push(`${where}.evidence: 근거(evidence)가 1개 이상 필요합니다 (추측 금지)`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
list.forEach((e, i) => {
|
|
45
|
+
const w = `${where}.evidence[${i}]`;
|
|
46
|
+
if (!isObj(e)) return errors.push(`${w}: 객체여야 합니다`);
|
|
47
|
+
if (
|
|
48
|
+
!isStr(e.file, 300) ||
|
|
49
|
+
e.file.startsWith("/") ||
|
|
50
|
+
e.file.split("/").includes("..")
|
|
51
|
+
)
|
|
52
|
+
errors.push(`${w}.file: 저장소 기준 상대 경로여야 합니다`);
|
|
53
|
+
if (
|
|
54
|
+
!Array.isArray(e.lines) ||
|
|
55
|
+
e.lines.length !== 2 ||
|
|
56
|
+
!e.lines.every((n) => Number.isInteger(n) && n >= 1) ||
|
|
57
|
+
e.lines[0] > e.lines[1]
|
|
58
|
+
)
|
|
59
|
+
errors.push(`${w}.lines: [시작,끝] 양의 정수 (시작<=끝)`);
|
|
60
|
+
if (e.contains !== undefined && !isStr(e.contains, 200))
|
|
61
|
+
errors.push(`${w}.contains: 1~200자 문자열`);
|
|
62
|
+
for (const k of Object.keys(e))
|
|
63
|
+
if (!["file", "lines", "contains"].includes(k))
|
|
64
|
+
errors.push(`${w}: 알 수 없는 키 ${k}`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateModel(model) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
if (!isObj(model)) return ["model: 객체여야 합니다"];
|
|
71
|
+
if (model.schemaVersion !== 1) errors.push("schemaVersion: 1 이어야 합니다");
|
|
72
|
+
|
|
73
|
+
const p = model.product;
|
|
74
|
+
if (!isObj(p) || !ID.test(p.id ?? "") || !isStr(p.name, 80) || !isStr(p.description, 400))
|
|
75
|
+
errors.push("product: {id(소문자-하이픈), name, description} 필요");
|
|
76
|
+
|
|
77
|
+
const s = model.source;
|
|
78
|
+
if (!isObj(s) || !isStr(s.repo, 100) || !isStr(s.commit, 64))
|
|
79
|
+
errors.push("source: {repo, commit} 필요 (근거를 확인한 원본 커밋)");
|
|
80
|
+
|
|
81
|
+
const comps = new Map();
|
|
82
|
+
if (!Array.isArray(model.components) || model.components.length === 0)
|
|
83
|
+
errors.push("components: 1개 이상 필요");
|
|
84
|
+
else
|
|
85
|
+
model.components.forEach((c, i) => {
|
|
86
|
+
const w = `components[${i}]`;
|
|
87
|
+
if (!isObj(c)) return errors.push(`${w}: 객체여야 합니다`);
|
|
88
|
+
if (!ID.test(c.id ?? "")) errors.push(`${w}.id: 소문자-하이픈 id`);
|
|
89
|
+
else if (comps.has(c.id)) errors.push(`${w}.id: 중복 ${c.id}`);
|
|
90
|
+
else comps.set(c.id, c);
|
|
91
|
+
if (!KINDS.includes(c.kind)) errors.push(`${w}.kind: ${KINDS.join("|")}`);
|
|
92
|
+
if (!isStr(c.label, 60)) errors.push(`${w}.label: 1~60자`);
|
|
93
|
+
if (!isStr(c.detail, 400)) errors.push(`${w}.detail: 1~400자`);
|
|
94
|
+
for (const k of ["lane", "row"])
|
|
95
|
+
if (c[k] !== undefined && !(Number.isInteger(c[k]) && c[k] >= 0 && c[k] <= 9))
|
|
96
|
+
errors.push(`${w}.${k}: 0~9 정수`);
|
|
97
|
+
checkEvidence(c.evidence, w, errors);
|
|
98
|
+
checkComponentDetail(c, w, errors, comps);
|
|
99
|
+
});
|
|
100
|
+
// `connects` may point at a component declared later: check after all are known
|
|
101
|
+
(model.components ?? []).forEach((c, i) => (isObj(c) && Array.isArray(c.parts) ? c.parts : []).forEach((pt, j) => {
|
|
102
|
+
if (isObj(pt) && pt.connects !== undefined && (!comps.has(pt.connects) || pt.connects === c.id))
|
|
103
|
+
errors.push(`components[${i}].parts[${j}].connects: 다른 선언된 컴포넌트 id`);
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const links = new Set();
|
|
107
|
+
if (!Array.isArray(model.links) || model.links.length === 0)
|
|
108
|
+
errors.push("links: 1개 이상 필요");
|
|
109
|
+
else
|
|
110
|
+
model.links.forEach((l, i) => {
|
|
111
|
+
const w = `links[${i}]`;
|
|
112
|
+
if (!isObj(l)) return errors.push(`${w}: 객체여야 합니다`);
|
|
113
|
+
if (!comps.has(l.a) || !comps.has(l.b) || l.a === l.b)
|
|
114
|
+
return errors.push(`${w}: a/b 는 서로 다른 선언된 컴포넌트여야 합니다`);
|
|
115
|
+
const k = linkKey(l.a, l.b);
|
|
116
|
+
if (links.has(k)) errors.push(`${w}: 같은 쌍의 연결이 중복됩니다 (${k})`);
|
|
117
|
+
links.add(k);
|
|
118
|
+
if (!isStr(l.label, 80)) errors.push(`${w}.label: 1~80자`);
|
|
119
|
+
checkEvidence(l.evidence, w, errors);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const ucIds = new Set();
|
|
123
|
+
if (!Array.isArray(model.useCases) || model.useCases.length === 0)
|
|
124
|
+
errors.push("useCases: 1개 이상 필요");
|
|
125
|
+
else
|
|
126
|
+
model.useCases.forEach((u, i) => {
|
|
127
|
+
const w = `useCases[${i}]`;
|
|
128
|
+
if (!isObj(u)) return errors.push(`${w}: 객체여야 합니다`);
|
|
129
|
+
if (!ID.test(u.id ?? "")) errors.push(`${w}.id: 소문자-하이픈 id`);
|
|
130
|
+
else if (ucIds.has(u.id)) errors.push(`${w}.id: 중복 ${u.id}`);
|
|
131
|
+
else ucIds.add(u.id);
|
|
132
|
+
if (!isStr(u.title, 80)) errors.push(`${w}.title`);
|
|
133
|
+
if (!isStr(u.summary, 400)) errors.push(`${w}.summary`);
|
|
134
|
+
if (!isStr(u.trigger, 200)) errors.push(`${w}.trigger (예: GET /api/...)`);
|
|
135
|
+
if (!Array.isArray(u.steps) || u.steps.length === 0)
|
|
136
|
+
return errors.push(`${w}.steps: 1개 이상 필요`);
|
|
137
|
+
const stepIds = new Set();
|
|
138
|
+
u.steps.forEach((st, j) => {
|
|
139
|
+
const sw = `${w}.steps[${j}]`;
|
|
140
|
+
if (!isObj(st)) return errors.push(`${sw}: 객체여야 합니다`);
|
|
141
|
+
if (!ID.test(st.id ?? "")) errors.push(`${sw}.id: 소문자-하이픈 id`);
|
|
142
|
+
else if (stepIds.has(st.id)) errors.push(`${sw}.id: 중복 ${st.id}`);
|
|
143
|
+
else stepIds.add(st.id);
|
|
144
|
+
if (!STEP_KINDS.includes(st.kind))
|
|
145
|
+
errors.push(`${sw}.kind: ${STEP_KINDS.join("|")}`);
|
|
146
|
+
if (!isStr(st.label, 60)) errors.push(`${sw}.label: 1~60자`);
|
|
147
|
+
if (!isStr(st.narration, 500)) errors.push(`${sw}.narration: 사람이 읽는 문장 1~500자`);
|
|
148
|
+
if (st.kind === "request" || st.kind === "call")
|
|
149
|
+
if (!isStr(st.request, 300)) errors.push(`${sw}.request: 요청 요약 필요`);
|
|
150
|
+
if (st.kind === "response" || st.kind === "call")
|
|
151
|
+
if (!isStr(st.response, 300)) errors.push(`${sw}.response: 응답 요약 필요`);
|
|
152
|
+
if (!comps.has(st.from) || !comps.has(st.to)) {
|
|
153
|
+
errors.push(`${sw}: from/to 는 선언된 컴포넌트여야 합니다`);
|
|
154
|
+
} else if (st.kind === "internal") {
|
|
155
|
+
if (st.from !== st.to || st.via !== undefined)
|
|
156
|
+
errors.push(`${sw}: internal 단계는 from==to, via 없음`);
|
|
157
|
+
} else {
|
|
158
|
+
if (st.via !== undefined && !(Array.isArray(st.via) && st.via.every((v) => comps.has(v))))
|
|
159
|
+
errors.push(`${sw}.via: 선언된 컴포넌트 id 배열`);
|
|
160
|
+
else {
|
|
161
|
+
const path = stepPath(st);
|
|
162
|
+
for (let k = 0; k + 1 < path.length; k++)
|
|
163
|
+
if (!links.has(linkKey(path[k], path[k + 1])))
|
|
164
|
+
errors.push(`${sw}: ${path[k]}→${path[k + 1]} 연결(links)이 선언되지 않았습니다`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
checkEvidence(st.evidence, sw, errors);
|
|
168
|
+
checkStepDetail(st, sw, errors, comps, links);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
// unfilled template placeholders: say exactly where to write
|
|
172
|
+
(function walk(v, path) {
|
|
173
|
+
if (typeof v === "string") {
|
|
174
|
+
if (/^TODO\b/.test(v)) errors.push(`${path}: 채워 주세요 → ${v.split("\n")[0]}`);
|
|
175
|
+
if (SECRET.test(v)) errors.push(`${path}: 비밀값처럼 보이는 문자열 — 키·토큰·비밀번호는 어떤 형태로도 모델에 넣지 마세요 (인증은 방식 이름만)`);
|
|
176
|
+
}
|
|
177
|
+
else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`));
|
|
178
|
+
else if (isObj(v)) for (const k of Object.keys(v)) walk(v[k], `${path}.${k}`);
|
|
179
|
+
})(model, "model");
|
|
180
|
+
return errors;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function checkNamed(list, where, errors, max, item) {
|
|
184
|
+
if (list === undefined) return [];
|
|
185
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > max) { errors.push(`${where}: 1~${max}개 배열`); return []; }
|
|
186
|
+
return list.map((x, i) => { if (!isObj(x)) { errors.push(`${where}[${i}]: 객체여야 합니다`); return null; } item(x, `${where}[${i}]`); return x; }).filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
function noExtra(o, keys, w, errors) { for (const k of Object.keys(o)) if (!keys.includes(k)) errors.push(`${w}: 알 수 없는 키 ${k}`); }
|
|
189
|
+
|
|
190
|
+
/** parts + wires (inside a client/gateway/service), tables (datastore), endpoints (external) */
|
|
191
|
+
function checkComponentDetail(c, w, errors) {
|
|
192
|
+
const parts = new Set();
|
|
193
|
+
if (c.parts !== undefined && !["client", "gateway", "service"].includes(c.kind)) errors.push(`${w}.parts: client|gateway|service 에만 둡니다`);
|
|
194
|
+
checkNamed(c.parts, `${w}.parts`, errors, 40, (pt, pw) => {
|
|
195
|
+
noExtra(pt, ["id", "label", "role", "detail", "connects", "evidence"], pw, errors);
|
|
196
|
+
if (!ID.test(pt.id ?? "")) errors.push(`${pw}.id: 소문자-하이픈 id`);
|
|
197
|
+
else if (parts.has(pt.id)) errors.push(`${pw}.id: 중복 ${pt.id}`);
|
|
198
|
+
else parts.add(pt.id);
|
|
199
|
+
if (!isStr(pt.label, 60)) errors.push(`${pw}.label: 1~60자`);
|
|
200
|
+
if (!PART_ROLES.includes(pt.role)) errors.push(`${pw}.role: ${PART_ROLES.join("|")}`);
|
|
201
|
+
if (!isStr(pt.detail, 300)) errors.push(`${pw}.detail: 1~300자`);
|
|
202
|
+
checkEvidence(pt.evidence, pw, errors);
|
|
203
|
+
});
|
|
204
|
+
const wires = new Set();
|
|
205
|
+
if (c.wires !== undefined && c.parts === undefined) errors.push(`${w}.wires: parts 가 먼저 필요합니다`);
|
|
206
|
+
checkNamed(c.wires, `${w}.wires`, errors, 80, (wi, ww) => {
|
|
207
|
+
noExtra(wi, ["from", "to", "label", "evidence"], ww, errors);
|
|
208
|
+
if (!parts.has(wi.from) || !parts.has(wi.to) || wi.from === wi.to) errors.push(`${ww}: from/to 는 이 컴포넌트의 서로 다른 parts id`);
|
|
209
|
+
const k = linkKey(wi.from ?? "", wi.to ?? "");
|
|
210
|
+
if (wires.has(k)) errors.push(`${ww}: 같은 쌍의 배선이 중복됩니다 (${k})`);
|
|
211
|
+
wires.add(k);
|
|
212
|
+
if (!isStr(wi.label, 80)) errors.push(`${ww}.label: 1~80자 (무엇이 오가는지)`);
|
|
213
|
+
checkEvidence(wi.evidence, ww, errors);
|
|
214
|
+
});
|
|
215
|
+
if (c.tables !== undefined && c.kind !== "datastore") errors.push(`${w}.tables: datastore 에만 둡니다`);
|
|
216
|
+
const tnames = new Set();
|
|
217
|
+
checkNamed(c.tables, `${w}.tables`, errors, 60, (t, tw) => {
|
|
218
|
+
noExtra(t, ["name", "detail", "columns", "evidence"], tw, errors);
|
|
219
|
+
if (!TABLE.test(t.name ?? "")) errors.push(`${tw}.name: 테이블 이름 (영문·숫자·_ .)`);
|
|
220
|
+
else if (tnames.has(t.name)) errors.push(`${tw}.name: 중복 ${t.name}`);
|
|
221
|
+
else tnames.add(t.name);
|
|
222
|
+
if (!isStr(t.detail, 300)) errors.push(`${tw}.detail: 1~300자`);
|
|
223
|
+
const cols = new Set();
|
|
224
|
+
checkNamed(t.columns, `${tw}.columns`, errors, 80, (col, cw) => {
|
|
225
|
+
noExtra(col, ["name", "type", "note"], cw, errors);
|
|
226
|
+
if (!COLUMN.test(col.name ?? "")) errors.push(`${cw}.name: 컬럼 이름`);
|
|
227
|
+
else if (cols.has(col.name)) errors.push(`${cw}.name: 중복 ${col.name}`);
|
|
228
|
+
else cols.add(col.name);
|
|
229
|
+
if (col.type !== undefined && !isStr(col.type, 40)) errors.push(`${cw}.type: 1~40자`);
|
|
230
|
+
if (col.note !== undefined && !isStr(col.note, 120)) errors.push(`${cw}.note: 1~120자`);
|
|
231
|
+
});
|
|
232
|
+
if (t.columns === undefined) errors.push(`${tw}.columns: 1개 이상 필요`);
|
|
233
|
+
checkEvidence(t.evidence, tw, errors);
|
|
234
|
+
});
|
|
235
|
+
if (c.endpoints !== undefined && c.kind !== "external") errors.push(`${w}.endpoints: external 에만 둡니다`);
|
|
236
|
+
const eids = new Set();
|
|
237
|
+
checkNamed(c.endpoints, `${w}.endpoints`, errors, 40, (ep, ew) => {
|
|
238
|
+
noExtra(ep, ["id", "method", "path", "auth", "detail", "request", "response", "evidence"], ew, errors);
|
|
239
|
+
if (!ID.test(ep.id ?? "")) errors.push(`${ew}.id: 소문자-하이픈 id`);
|
|
240
|
+
else if (eids.has(ep.id)) errors.push(`${ew}.id: 중복 ${ep.id}`);
|
|
241
|
+
else eids.add(ep.id);
|
|
242
|
+
if (!METHODS.includes(ep.method)) errors.push(`${ew}.method: ${METHODS.join("|")}`);
|
|
243
|
+
if (!isStr(ep.path, 200)) errors.push(`${ew}.path: 1~200자 (예: /v1/embeddings)`);
|
|
244
|
+
if (ep.auth !== undefined && !isStr(ep.auth, 60)) errors.push(`${ew}.auth: 인증 방식 이름 1~60자 (값은 쓰지 않음)`);
|
|
245
|
+
if (!isStr(ep.detail, 300)) errors.push(`${ew}.detail: 1~300자`);
|
|
246
|
+
for (const side of ["request", "response"])
|
|
247
|
+
checkNamed(ep[side], `${ew}.${side}`, errors, 30, (f, fw) => {
|
|
248
|
+
noExtra(f, ["name", "note"], fw, errors);
|
|
249
|
+
if (!isStr(f.name, 60)) errors.push(`${fw}.name: 필드 이름 1~60자`);
|
|
250
|
+
if (f.note !== undefined && !isStr(f.note, 120)) errors.push(`${fw}.note: 1~120자`);
|
|
251
|
+
});
|
|
252
|
+
checkEvidence(ep.evidence, ew, errors);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkPersonal(v, w, errors) {
|
|
257
|
+
for (const [re, what] of PERSONAL) if (re.test(v)) errors.push(`${w}: ${what}처럼 보이는 값 — 실제 데이터 대신 형식 자리표시(<uuid>, <text>, <email>)를 쓰세요`);
|
|
258
|
+
}
|
|
259
|
+
/** step.payload: the concrete shape that travels (JSON, HTTP, SQL + bindings, a row), with placeholders */
|
|
260
|
+
function checkPayload(st, sw, errors) {
|
|
261
|
+
if (st.payload === undefined) return;
|
|
262
|
+
const legs = st.kind === "call" ? ["request", "response"] : st.kind === "request" ? ["request"] : st.kind === "response" ? ["response"] : [];
|
|
263
|
+
if (!legs.length) return errors.push(`${sw}.payload: internal 단계에는 둘 수 없습니다 (오가는 데이터가 없음)`);
|
|
264
|
+
checkNamed(st.payload, `${sw}.payload`, errors, 6, (x, w) => {
|
|
265
|
+
noExtra(x, ["leg", "format", "title", "body", "bind", "columns", "evidence"], w, errors);
|
|
266
|
+
if (!legs.includes(x.leg)) errors.push(`${w}.leg: ${legs.join("|")} (이 단계의 종류 ${st.kind})`);
|
|
267
|
+
if (!PAYLOAD_FORMATS.includes(x.format)) errors.push(`${w}.format: ${PAYLOAD_FORMATS.join("|")}`);
|
|
268
|
+
if (x.title !== undefined && !isStr(x.title, 80)) errors.push(`${w}.title: 1~80자`);
|
|
269
|
+
if (x.body !== undefined) {
|
|
270
|
+
if (!isStr(x.body, 2000)) errors.push(`${w}.body: 1~2000자`);
|
|
271
|
+
else { if (x.body.split("\n").length > 40) errors.push(`${w}.body: 40줄 이하`); checkPersonal(x.body, `${w}.body`, errors); }
|
|
272
|
+
}
|
|
273
|
+
for (const k of ["bind", "columns"]) checkNamed(x[k], `${w}.${k}`, errors, 30, (b, bw) => {
|
|
274
|
+
noExtra(b, ["name", "value"], bw, errors);
|
|
275
|
+
if (!isStr(b.name, 60)) errors.push(`${bw}.name: 1~60자`);
|
|
276
|
+
if (!isStr(b.value, 120)) errors.push(`${bw}.value: 1~120자 (형식 자리표시)`);
|
|
277
|
+
else checkPersonal(b.value, `${bw}.value`, errors);
|
|
278
|
+
});
|
|
279
|
+
if (x.body === undefined && x.bind === undefined && x.columns === undefined) errors.push(`${w}: body, bind, columns 중 하나는 필요합니다`);
|
|
280
|
+
checkEvidence(x.evidence, w, errors);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
/** step.transforms: how the data changes at a part on this step's trace (input shape -> output shape, field map) */
|
|
284
|
+
function checkTransforms(st, sw, errors) {
|
|
285
|
+
if (st.transforms === undefined) return;
|
|
286
|
+
if (!Array.isArray(st.trace)) return errors.push(`${sw}.transforms: trace 가 먼저 필요합니다`);
|
|
287
|
+
const shape = (o, w) => {
|
|
288
|
+
if (!isObj(o)) return errors.push(`${w}: {type, fields?} 객체`);
|
|
289
|
+
noExtra(o, ["type", "fields"], w, errors);
|
|
290
|
+
if (!isStr(o.type, 60)) errors.push(`${w}.type: 1~60자 (구조체·형식 이름)`);
|
|
291
|
+
if (o.fields !== undefined) {
|
|
292
|
+
if (!Array.isArray(o.fields) || o.fields.length === 0 || o.fields.length > 16 || o.fields.some((f) => !isStr(f, 80))) errors.push(`${w}.fields: 1~16개, 각 1~80자`);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
checkNamed(st.transforms, `${sw}.transforms`, errors, 12, (t, w) => {
|
|
296
|
+
noExtra(t, ["at", "in", "out", "map", "evidence"], w, errors);
|
|
297
|
+
if (!st.trace.includes(t.at)) errors.push(`${w}.at: 이 단계의 trace 에 있는 "컴포넌트/파트" (${t.at})`);
|
|
298
|
+
shape(t.in, `${w}.in`); shape(t.out, `${w}.out`);
|
|
299
|
+
checkNamed(t.map, `${w}.map`, errors, 16, (m, mw) => {
|
|
300
|
+
noExtra(m, ["from", "to", "how"], mw, errors);
|
|
301
|
+
if (!isStr(m.from, 60) || !isStr(m.to, 60)) errors.push(`${mw}: from/to 1~60자`);
|
|
302
|
+
if (m.how !== undefined && !isStr(m.how, 80)) errors.push(`${mw}.how: 1~80자`);
|
|
303
|
+
});
|
|
304
|
+
checkEvidence(t.evidence, w, errors);
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** step.trace (parts it passes), step.data (table access), step.endpoints (external endpoints it calls) */
|
|
309
|
+
function checkStepDetail(st, sw, errors, comps, links) {
|
|
310
|
+
checkPayload(st, sw, errors);
|
|
311
|
+
checkTransforms(st, sw, errors);
|
|
312
|
+
const path = new Set(stepPath(st));
|
|
313
|
+
const part = (ref) => {
|
|
314
|
+
const [cid, pid] = String(ref).split("/");
|
|
315
|
+
const c = comps.get(cid);
|
|
316
|
+
return c && Array.isArray(c.parts) ? { c, p: c.parts.find((x) => isObj(x) && x.id === pid) } : null;
|
|
317
|
+
};
|
|
318
|
+
if (st.trace !== undefined) {
|
|
319
|
+
if (!Array.isArray(st.trace) || st.trace.length === 0 || st.trace.length > 20) errors.push(`${sw}.trace: 1~20개 "컴포넌트/파트" 배열`);
|
|
320
|
+
else {
|
|
321
|
+
const refs = st.trace.map((r, i) => {
|
|
322
|
+
const x = part(r);
|
|
323
|
+
if (!x || !x.p) { errors.push(`${sw}.trace[${i}]: 선언된 "컴포넌트/파트" 가 아닙니다 (${r})`); return null; }
|
|
324
|
+
if (!path.has(x.c.id)) errors.push(`${sw}.trace[${i}]: ${x.c.id} 는 이 단계가 지나는 컴포넌트가 아닙니다`);
|
|
325
|
+
return { c: x.c, p: x.p };
|
|
326
|
+
});
|
|
327
|
+
for (let i = 0; i + 1 < refs.length; i++) {
|
|
328
|
+
const a = refs[i], b = refs[i + 1];
|
|
329
|
+
if (!a || !b) continue;
|
|
330
|
+
if (a.c.id === b.c.id) {
|
|
331
|
+
const ok = (a.c.wires ?? []).some((w) => isObj(w) && linkKey(w.from, w.to) === linkKey(a.p.id, b.p.id));
|
|
332
|
+
if (!ok && a.p.id !== b.p.id) errors.push(`${sw}.trace: ${a.c.id}/${a.p.id}→${b.p.id} 배선(wires)이 선언되지 않았습니다`);
|
|
333
|
+
} else if (!links.has(linkKey(a.c.id, b.c.id))) errors.push(`${sw}.trace: ${a.c.id}→${b.c.id} 연결이 없습니다`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (st.data !== undefined) {
|
|
338
|
+
checkNamed(st.data, `${sw}.data`, errors, 12, (d, dw) => {
|
|
339
|
+
noExtra(d, ["store", "table", "op", "columns", "evidence"], dw, errors);
|
|
340
|
+
const c = comps.get(d.store);
|
|
341
|
+
if (!c || c.kind !== "datastore") return errors.push(`${dw}.store: datastore 컴포넌트 id`);
|
|
342
|
+
if (!path.has(d.store)) errors.push(`${dw}.store: 이 단계가 지나는 저장소가 아닙니다`);
|
|
343
|
+
const t = (c.tables ?? []).find((x) => isObj(x) && x.name === d.table);
|
|
344
|
+
if (!t) return errors.push(`${dw}.table: ${d.store}.tables 에 선언된 테이블 (${d.table})`);
|
|
345
|
+
if (!DATA_OPS.includes(d.op)) errors.push(`${dw}.op: ${DATA_OPS.join("|")}`);
|
|
346
|
+
if (d.columns !== undefined) {
|
|
347
|
+
if (!Array.isArray(d.columns) || d.columns.length === 0) errors.push(`${dw}.columns: 컬럼 이름 배열`);
|
|
348
|
+
else for (const col of d.columns) if (!(t.columns ?? []).some((x) => isObj(x) && x.name === col)) errors.push(`${dw}.columns: ${d.table} 에 선언되지 않은 컬럼 ${col}`);
|
|
349
|
+
}
|
|
350
|
+
checkEvidence(d.evidence, dw, errors);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
if (st.endpoints !== undefined) {
|
|
354
|
+
if (!Array.isArray(st.endpoints) || st.endpoints.length === 0 || st.endpoints.length > 6) errors.push(`${sw}.endpoints: 1~6개 "컴포넌트/엔드포인트" 배열`);
|
|
355
|
+
else st.endpoints.forEach((r, i) => {
|
|
356
|
+
const [cid, eid] = String(r).split("/");
|
|
357
|
+
const c = comps.get(cid);
|
|
358
|
+
if (!c || !(c.endpoints ?? []).some((x) => isObj(x) && x.id === eid)) errors.push(`${sw}.endpoints[${i}]: 선언된 "컴포넌트/엔드포인트" 가 아닙니다 (${r})`);
|
|
359
|
+
else if (!path.has(cid)) errors.push(`${sw}.endpoints[${i}]: ${cid} 는 이 단계가 지나는 컴포넌트가 아닙니다`);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function validateChanges(changes, model) {
|
|
365
|
+
const errors = [];
|
|
366
|
+
if (changes === null || changes === undefined) return errors;
|
|
367
|
+
if (!isObj(changes)) return ["changes: 객체여야 합니다"];
|
|
368
|
+
if (!isStr(changes.title, 120)) errors.push("changes.title: 1~120자");
|
|
369
|
+
if (!isStr(changes.summary, 600)) errors.push("changes.summary: 1~600자");
|
|
370
|
+
if (!isStr(changes.ref, 200)) errors.push("changes.ref: 변경 출처 (PR/커밋)");
|
|
371
|
+
const comps = new Set((model.components ?? []).map((c) => c.id));
|
|
372
|
+
const steps = new Map();
|
|
373
|
+
for (const u of model.useCases ?? [])
|
|
374
|
+
for (const s of u.steps ?? []) steps.set(`${u.id}/${s.id}`, true);
|
|
375
|
+
const seenC = new Set();
|
|
376
|
+
if (!Array.isArray(changes.components)) errors.push("changes.components: 배열 필요");
|
|
377
|
+
else
|
|
378
|
+
changes.components.forEach((c, i) => {
|
|
379
|
+
const w = `changes.components[${i}]`;
|
|
380
|
+
if (!isObj(c) || !comps.has(c.id)) return errors.push(`${w}.id: 모델에 없는 컴포넌트`);
|
|
381
|
+
if (seenC.has(c.id)) errors.push(`${w}.id: 중복 ${c.id}`);
|
|
382
|
+
seenC.add(c.id);
|
|
383
|
+
if (!CHANGE_KINDS.includes(c.change)) errors.push(`${w}.change: ${CHANGE_KINDS.join("|")}`);
|
|
384
|
+
if (!isStr(c.note, 300)) errors.push(`${w}.note: 1~300자`);
|
|
385
|
+
});
|
|
386
|
+
const seenS = new Set();
|
|
387
|
+
if (changes.steps !== undefined) {
|
|
388
|
+
if (!Array.isArray(changes.steps)) errors.push("changes.steps: 배열이어야 합니다");
|
|
389
|
+
else
|
|
390
|
+
changes.steps.forEach((c, i) => {
|
|
391
|
+
const w = `changes.steps[${i}]`;
|
|
392
|
+
const key = isObj(c) ? `${c.useCase}/${c.step}` : "";
|
|
393
|
+
if (!steps.has(key)) return errors.push(`${w}: 모델에 없는 useCase/step (${key})`);
|
|
394
|
+
if (seenS.has(key)) errors.push(`${w}: 중복 ${key}`);
|
|
395
|
+
seenS.add(key);
|
|
396
|
+
if (!CHANGE_KINDS.includes(c.change)) errors.push(`${w}.change: ${CHANGE_KINDS.join("|")}`);
|
|
397
|
+
if (!isStr(c.note, 300)) errors.push(`${w}.note: 1~300자`);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
// optional, produced by `nacholens diff`
|
|
401
|
+
if (changes.unmapped !== undefined) {
|
|
402
|
+
if (!Array.isArray(changes.unmapped) || changes.unmapped.length > 500) errors.push("changes.unmapped: 배열(최대 500)");
|
|
403
|
+
else
|
|
404
|
+
changes.unmapped.forEach((u, i) => {
|
|
405
|
+
if (!isObj(u) || !isStr(u.file, 300) || !isStr(u.status, 20) || !isStr(u.lines, 300) || !isStr(u.hint, 400) ||
|
|
406
|
+
(u.category !== undefined && !["code", "style", "config"].includes(u.category)))
|
|
407
|
+
errors.push(`changes.unmapped[${i}]: {file,status,lines,hint,category?(code|style|config)} 필요`);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (changes.details !== undefined) {
|
|
411
|
+
const known = new Set();
|
|
412
|
+
for (const c of model.components ?? []) {
|
|
413
|
+
for (const p of c.parts ?? []) known.add(`${c.id}|part|${p.id}`);
|
|
414
|
+
for (const w of c.wires ?? []) known.add(`${c.id}|wire|${w.from}>${w.to}`);
|
|
415
|
+
for (const t of c.tables ?? []) known.add(`${c.id}|table|${t.name}`);
|
|
416
|
+
for (const e of c.endpoints ?? []) known.add(`${c.id}|endpoint|${e.id}`);
|
|
417
|
+
}
|
|
418
|
+
if (!Array.isArray(changes.details) || changes.details.length > 500) errors.push("changes.details: 배열(최대 500)");
|
|
419
|
+
else changes.details.forEach((d, i) => {
|
|
420
|
+
const w = `changes.details[${i}]`;
|
|
421
|
+
if (!isObj(d) || !known.has(`${d.component}|${d.kind}|${d.id}`)) return errors.push(`${w}: 모델에 없는 상세 항목 (${isObj(d) ? `${d.component}/${d.kind}/${d.id}` : "?"})`);
|
|
422
|
+
if (!CHANGE_KINDS.includes(d.change)) errors.push(`${w}.change: ${CHANGE_KINDS.join("|")}`);
|
|
423
|
+
if (!isStr(d.note, 300)) errors.push(`${w}.note: 1~300자`);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (changes.ignored !== undefined) {
|
|
427
|
+
if (!Array.isArray(changes.ignored) || changes.ignored.length > 2000) errors.push("changes.ignored: 배열(최대 2000)");
|
|
428
|
+
else
|
|
429
|
+
changes.ignored.forEach((u, i) => {
|
|
430
|
+
if (!isObj(u) || !isStr(u.file, 300) || !isStr(u.reason, 40)) errors.push(`changes.ignored[${i}]: {file,reason} 필요`);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return errors;
|
|
434
|
+
}
|
package/src/page.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One model -> two output shapes. Both embed the same player; only the `target` differs:
|
|
2
|
+
// html : single-file HTML (deep-links via URL hash)
|
|
3
|
+
// artifact : Claude-artifact-ready HTML (no history/URL side effects, sandbox-safe) + manifest
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { validateModel, validateChanges } from "./model.js";
|
|
8
|
+
|
|
9
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const asset = (n) => readFileSync(join(here, "player", n), "utf8");
|
|
11
|
+
|
|
12
|
+
// Embedded fonts (SIL OFL 1.1, see player/fonts/FONTLOG.md): one @font-face per file, WOFF2 as data.
|
|
13
|
+
const FONT_FILES = [
|
|
14
|
+
["Nacho Sans", 400, "nacho-sans-400"], ["Nacho Sans", 500, "nacho-sans-500"], ["Nacho Sans", 600, "nacho-sans-600"],
|
|
15
|
+
["Nacho Sans", 700, "nacho-sans-700"], ["Nacho Mono", 500, "nacho-mono-500"], ["Nacho Mono", 600, "nacho-mono-600"],
|
|
16
|
+
["Anton", 400, "anton-400"],
|
|
17
|
+
];
|
|
18
|
+
let fontCss = null;
|
|
19
|
+
export function fontFaces() {
|
|
20
|
+
if (fontCss) return fontCss;
|
|
21
|
+
const head = "/* Nacho Sans and Nacho Mono: IBM Plex Sans / Mono, Copyright 2017 IBM Corp., subset and renamed (Reserved Font Name \"Plex\").\n" +
|
|
22
|
+
" Anton: Copyright 2020 The Anton Project Authors. Both: SIL Open Font License 1.1, https://openfontlicense.org */\n";
|
|
23
|
+
fontCss = head + FONT_FILES.map(([family, weight, file]) =>
|
|
24
|
+
`@font-face { font-family: "${family}"; font-style: normal; font-weight: ${weight}; font-display: swap; ` +
|
|
25
|
+
`src: url(data:font/woff2;base64,${readFileSync(join(here, "player", "fonts", file + ".woff2")).toString("base64")}) format("woff2"); }`).join("\n");
|
|
26
|
+
return fontCss;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// JSON embedded in <script type="application/json">: neutralise anything that could close
|
|
30
|
+
// the script element or start an HTML comment.
|
|
31
|
+
export function safeJson(value) {
|
|
32
|
+
return JSON.stringify(value)
|
|
33
|
+
.replace(/</g, "\\u003c")
|
|
34
|
+
.replace(/>/g, "\\u003e")
|
|
35
|
+
.replace(/&/g, "\\u0026")
|
|
36
|
+
.replace(/\u2028/g, "\\u2028")
|
|
37
|
+
.replace(/\u2029/g, "\\u2029");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const esc = (s) =>
|
|
41
|
+
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
42
|
+
|
|
43
|
+
export const TARGETS = ["html", "artifact"];
|
|
44
|
+
/** Page languages for the player's own words (model text is shown as written). English is the default. */
|
|
45
|
+
export const LANGS = ["en", "ko"];
|
|
46
|
+
const UI = {
|
|
47
|
+
en: { UOVERLAY: "Highlight changes", UNAV: "Flows and steps", USEARCH: "Search flows", USTAGE: "Data flow animation", UCANVAS: "Architecture and request flow", UTRANSPORT: "Playback", UREVIEW: "Review", UINSP: "About the current step" },
|
|
48
|
+
ko: { UOVERLAY: "변경 강조", UNAV: "흐름과 단계", USEARCH: "흐름 검색", USTAGE: "데이터 흐름 애니메이션", UCANVAS: "아키텍처와 요청 흐름", UTRANSPORT: "재생 제어", UREVIEW: "검토", UINSP: "현재 단계 설명" },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function buildPage(model, changes = null, { target = "html", review = null, lang = "en" } = {}) {
|
|
52
|
+
if (!TARGETS.includes(target)) throw new Error(`unknown target: ${target}`);
|
|
53
|
+
if (!LANGS.includes(lang)) throw new Error(`unknown lang: ${lang} (${LANGS.join("|")})`);
|
|
54
|
+
const errors = [...validateModel(model), ...validateChanges(changes, model)];
|
|
55
|
+
if (errors.length) throw new Error("model validation failed:\n" + errors.map((e) => " - " + e).join("\n"));
|
|
56
|
+
const values = {
|
|
57
|
+
TARGET: target,
|
|
58
|
+
LANG: lang,
|
|
59
|
+
...UI[lang],
|
|
60
|
+
TITLE: esc(`${model.product.name} · Nacholens`),
|
|
61
|
+
FONTS: fontFaces(),
|
|
62
|
+
CSS: asset("player.css"),
|
|
63
|
+
JS: asset("player.js"),
|
|
64
|
+
MODEL: safeJson(model),
|
|
65
|
+
CHANGES: safeJson(changes ?? null),
|
|
66
|
+
REVIEWCSS: asset("review.css"),
|
|
67
|
+
REVIEWJS: asset("review.js"),
|
|
68
|
+
REVIEW: safeJson(review ?? null),
|
|
69
|
+
};
|
|
70
|
+
// function replacer: model text may contain `$&` etc. and must be inserted literally
|
|
71
|
+
return asset("template.html").replace(/\{\{([A-Z]+)\}\}/g, (_, k) => values[k]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function artifactManifest(model, file) {
|
|
75
|
+
return {
|
|
76
|
+
identifier: `nacholens-${model.product.id}`,
|
|
77
|
+
title: `${model.product.name} · Nacholens`,
|
|
78
|
+
type: "text/html",
|
|
79
|
+
file,
|
|
80
|
+
note: "Paste or upload as a Claude artifact (HTML) as is. No external resources.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which output an executing agent should produce. Claude -> artifact, anything else -> single-file HTML. */
|
|
85
|
+
export function formatForAgent(agent) {
|
|
86
|
+
return String(agent ?? "").trim().toLowerCase().startsWith("claude") ? "artifact" : "html";
|
|
87
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Embedded fonts
|
|
2
|
+
|
|
3
|
+
The player page embeds these fonts (as WOFF2 data in a `<style>` block). They are the type roles of the
|
|
4
|
+
[Nacho Combo design system](https://github.com/mafianacho/nachocombo): IBM Plex Sans for text, IBM Plex Mono
|
|
5
|
+
for code and labels, Anton for the one loud line.
|
|
6
|
+
|
|
7
|
+
All of them are licensed under the SIL Open Font License, Version 1.1 (`OFL-IBMPlex.txt`, `OFL-Anton.txt`).
|
|
8
|
+
They are not covered by this project's Apache-2.0 license.
|
|
9
|
+
|
|
10
|
+
| File | Family in the page | Source (unmodified, from Nacho Combo `Resources/Fonts`) | Source SHA-256 |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| `nacho-sans-400.woff2` | Nacho Sans 400 | IBMPlexSans-Regular.ttf | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` |
|
|
13
|
+
| `nacho-sans-500.woff2` | Nacho Sans 500 | IBMPlexSans-Medium.ttf | `331c8639d7598b2cde62a911a71db195e30cb655cd6bdf2e324a7e984955f907` |
|
|
14
|
+
| `nacho-sans-600.woff2` | Nacho Sans 600 | IBMPlexSans-SemiBold.ttf | `a20caf8286023a6a7a85e40b1d2a4ae9fc3e3b1f9eda8f4c542dd4986af67bb1` |
|
|
15
|
+
| `nacho-sans-700.woff2` | Nacho Sans 700 | IBMPlexSans-Bold.ttf | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` |
|
|
16
|
+
| `nacho-mono-500.woff2` | Nacho Mono 500 | IBMPlexMono-Medium.ttf | `a9b4c49bb299e05b5f6c481e7fb5e78943d2793249a0c8874ab574a2d1ea6755` |
|
|
17
|
+
| `nacho-mono-600.woff2` | Nacho Mono 600 | IBMPlexMono-SemiBold.ttf | `d3c38e55c78f5b0f28009fddba4834ec503278936a5986032424c9bd2d23aa46` |
|
|
18
|
+
| `anton-400.woff2` | Anton 400 | Anton-Regular.ttf | `a4ba3a92350ebb031da0cb47630ac49eb265082ca1bc0450442f4a83ab947cab` |
|
|
19
|
+
|
|
20
|
+
## Changes (2026, Mafianacho)
|
|
21
|
+
|
|
22
|
+
- **Subset** to Latin (U+0020–007E, U+00A0–00FF), general punctuation (U+2010–2027, U+2030–203A) and a few
|
|
23
|
+
symbols and arrows (→ ← ↑ ↓ ↔ − ≠ ≤ ≥ ✓ € ₩ ™); Anton to U+0020–007E. All OpenType layout features of the kept
|
|
24
|
+
glyphs are kept. Characters outside the subset (Hangul included) are drawn by the system font stack.
|
|
25
|
+
- **Converted** to WOFF2.
|
|
26
|
+
- **Renamed** IBM Plex Sans / Mono to "Nacho Sans" / "Nacho Mono": a subset is a Modified Version, and IBM Plex
|
|
27
|
+
declares the Reserved Font Name "Plex". Anton declares no Reserved Font Name and keeps its name.
|
|
28
|
+
- Copyright, trademark, designer and license records are kept; a modification note is added to the copyright record.
|
|
29
|
+
|
|
30
|
+
Rebuild: `python3 scripts/build-fonts.py <dir with the source .ttf files>` (needs `fonttools` and `brotli`).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|