@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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.de.md +46 -0
  4. package/README.fr.md +46 -0
  5. package/README.ja.md +46 -0
  6. package/README.ko.md +46 -0
  7. package/README.md +45 -2
  8. package/README.zh.md +46 -0
  9. package/demo/shop/v1/README.md +7 -0
  10. package/demo/shop/v1/api.js +78 -0
  11. package/demo/shop/v1/db.js +38 -0
  12. package/demo/shop/v1/gateway.js +50 -0
  13. package/demo/shop/v1/mock-external.js +17 -0
  14. package/demo/shop/v1/public/app.js +32 -0
  15. package/demo/shop/v1/public/format.js +2 -0
  16. package/demo/shop/v1/public/index.html +13 -0
  17. package/demo/shop/v1/public/styles.css +4 -0
  18. package/demo/shop/v2/README.md +7 -0
  19. package/demo/shop/v2/api.js +88 -0
  20. package/demo/shop/v2/db.js +38 -0
  21. package/demo/shop/v2/gateway.js +50 -0
  22. package/demo/shop/v2/mock-external.js +17 -0
  23. package/demo/shop/v2/public/app.js +33 -0
  24. package/demo/shop/v2/public/format.js +3 -0
  25. package/demo/shop/v2/public/index.html +13 -0
  26. package/demo/shop/v2/public/styles.css +5 -0
  27. package/docs/guide.ko.md +97 -0
  28. package/docs/guide.md +142 -0
  29. package/docs/model-schema.md +137 -0
  30. package/docs/review.md +78 -0
  31. package/examples/demo-shop.artifact.html +1535 -0
  32. package/examples/demo-shop.artifact.json +7 -0
  33. package/examples/demo-shop.html +1535 -0
  34. package/examples/external/README.md +12 -0
  35. package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
  36. package/examples/external/full-stack-fastapi-template/changes.json +56 -0
  37. package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
  38. package/models/demo-shop/changes.auto.json +121 -0
  39. package/models/demo-shop/changes.example.json +16 -0
  40. package/models/demo-shop/intent.example.json +75 -0
  41. package/models/demo-shop/model.json +3151 -0
  42. package/package.json +60 -4
  43. package/skills/nacholens/SKILL.md +235 -0
  44. package/src/cli.js +377 -0
  45. package/src/demo.js +42 -0
  46. package/src/diff.js +254 -0
  47. package/src/gitio.js +67 -0
  48. package/src/install.js +94 -0
  49. package/src/model.js +434 -0
  50. package/src/page.js +87 -0
  51. package/src/player/fonts/FONTLOG.md +30 -0
  52. package/src/player/fonts/OFL-Anton.txt +93 -0
  53. package/src/player/fonts/OFL-IBMPlex.txt +94 -0
  54. package/src/player/fonts/anton-400.woff2 +0 -0
  55. package/src/player/fonts/nacho-mono-500.woff2 +0 -0
  56. package/src/player/fonts/nacho-mono-600.woff2 +0 -0
  57. package/src/player/fonts/nacho-sans-400.woff2 +0 -0
  58. package/src/player/fonts/nacho-sans-500.woff2 +0 -0
  59. package/src/player/fonts/nacho-sans-600.woff2 +0 -0
  60. package/src/player/fonts/nacho-sans-700.woff2 +0 -0
  61. package/src/player/player.css +318 -0
  62. package/src/player/player.js +872 -0
  63. package/src/player/review.css +49 -0
  64. package/src/player/review.js +230 -0
  65. package/src/player/template.html +58 -0
  66. package/src/review.js +321 -0
  67. package/src/scan.js +255 -0
  68. package/src/template.js +36 -0
  69. 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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.