@qualisoft/ai-skills 1.1.0 → 1.4.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.
@@ -29,6 +29,7 @@ import {
29
29
  statSync,
30
30
  writeFileSync,
31
31
  } from "node:fs";
32
+ import { execFileSync } from "node:child_process";
32
33
  import { dirname, join, resolve } from "node:path";
33
34
  import { fileURLToPath } from "node:url";
34
35
 
@@ -168,6 +169,193 @@ function loadMockups(docsDir) {
168
169
  .map((f) => ({ file: f, href: DESIGN_DIR + "/" + MOCKUP_DIR + "/" + f }));
169
170
  }
170
171
 
172
+ /* ================================================================== *
173
+ * 연동 서비스
174
+ *
175
+ * 문서 바깥에 있는 것들(저장소·API 문서·협업 도구)이 어디에 붙어 있는지
176
+ * 한곳에서 본다. 목록이 어디서 오는지는 네 갈래다. **아래일수록 약하다.**
177
+ *
178
+ * 1. 설정 `docs.config.json.integrations` — 사람이 적은 것. 언제나 이긴다
179
+ * 2. 자동 깃 리모트 · 스펙 파일 — 빌드가 직접 본다
180
+ * 3. 기획 문서 본문에서 잡은 **연동 예정** — 기획에 적혀 있으면 목록에 오른다
181
+ * 4. 추천 1~3 이 **하나도 없을 때만** 카탈로그 기본값으로 채운다
182
+ *
183
+ * 3번이 이 체계의 요점이다. 연동은 기획에서 먼저 정해지고 나중에 붙는다.
184
+ * 기획서에 "GCP 에 배포한다"고 적어 두었으면 그건 이미 연동 예정이다 —
185
+ * 사람이 설정 파일에 한 번 더 적게 만들 이유가 없다.
186
+ * ================================================================== */
187
+
188
+ /**
189
+ * `detect` 는 문서 본문에서 찾을 표기다. 서비스 이름이 본문에 있으면 연동 예정으로 본다.
190
+ * `recommend` 는 아무것도 못 잡았을 때 보여줄 기본 후보다.
191
+ * 카탈로그에 없는 서비스도 설정에 적으면 그대로 나온다.
192
+ */
193
+ const LINK_CATALOG = [
194
+ { id: "github", name: "GitHub", slug: "github", color: "181717", what: "코드 저장소 · 이슈 · PR",
195
+ recommend: true, detect: ["github", "깃허브"] },
196
+ { id: "swagger", name: "Swagger", slug: "swagger", color: "85EA2D", what: "API 스펙 · 문서",
197
+ recommend: true, detect: ["swagger", "openapi", "스웨거"] },
198
+ { id: "notion", name: "Notion", slug: "notion", color: "000000", what: "회의록 · 업무 위키",
199
+ recommend: true, detect: ["notion", "노션"] },
200
+ { id: "figma", name: "Figma", slug: "figma", color: "F24E1E", what: "디자인 원본 · 시안",
201
+ recommend: true, detect: ["figma", "피그마"] },
202
+ { id: "slack", name: "Slack", slug: "slack", color: "4A154B", what: "알림 · 커뮤니케이션",
203
+ recommend: true, detect: ["slack", "슬랙"] },
204
+ { id: "jira", name: "Jira", slug: "jira", color: "0052CC", what: "이슈 · 스프린트",
205
+ recommend: true, detect: ["jira", "지라"] },
206
+
207
+ /* 아래는 추천 기본값이 아니다 — 기획에 적혀 있을 때만 올라온다 */
208
+ { id: "gcp", name: "Google Cloud", slug: "googlecloud", color: "4285F4", what: "배포 · 호스팅",
209
+ detect: ["gcp", "google cloud", "구글 클라우드", "cloud run", "app engine"] },
210
+ { id: "aws", name: "AWS", slug: "amazonwebservices", color: "232F3E", what: "배포 · 호스팅",
211
+ detect: ["aws", "amazon web services", "ec2", "s3 버킷"] },
212
+ { id: "vercel", name: "Vercel", slug: "vercel", color: "000000", what: "배포 · 호스팅",
213
+ detect: ["vercel", "버셀"] },
214
+ { id: "cloudflare", name: "Cloudflare", slug: "cloudflare", color: "F38020", what: "CDN · DNS",
215
+ detect: ["cloudflare", "클라우드플레어"] },
216
+ { id: "ga4", name: "Google Analytics", slug: "googleanalytics", color: "E37400", what: "측정 · 전환 추적",
217
+ detect: ["ga4", "google analytics", "구글 애널리틱스"] },
218
+ { id: "gtm", name: "Google Tag Manager", slug: "googletagmanager", color: "246FDB", what: "태그 운영",
219
+ detect: ["gtm", "google tag manager", "태그매니저", "태그 매니저"] },
220
+ { id: "sentry", name: "Sentry", slug: "sentry", color: "362D59", what: "에러 추적",
221
+ detect: ["sentry", "센트리"] },
222
+ { id: "kakao", name: "카카오", slug: "kakaotalk", color: "FFCD00", what: "채널 · 알림톡",
223
+ detect: ["카카오", "알림톡", "kakao"] },
224
+ { id: "naver", name: "네이버", slug: "naver", color: "03C75A", what: "검색 등록 · 서치어드바이저",
225
+ detect: ["서치어드바이저", "네이버 웹마스터", "naver search advisor"] },
226
+ { id: "stripe", name: "Stripe", slug: "stripe", color: "635BFF", what: "결제",
227
+ detect: ["stripe", "스트라이프"] },
228
+ { id: "tosspayments", name: "토스페이먼츠", slug: "tosspayments", color: "0064FF", what: "결제",
229
+ detect: ["토스페이먼츠", "tosspayments", "toss payments"] },
230
+ { id: "firebase", name: "Firebase", slug: "firebase", color: "DD2C00", what: "인증 · 실시간 DB",
231
+ detect: ["firebase", "파이어베이스"] },
232
+ { id: "supabase", name: "Supabase", slug: "supabase", color: "3FCF8E", what: "DB · 인증",
233
+ detect: ["supabase", "수파베이스"] },
234
+ { id: "sendgrid", name: "SendGrid", slug: "sendgrid", color: "1A82E2", what: "메일 발송",
235
+ detect: ["sendgrid", "센드그리드"] },
236
+ { id: "githubactions", name: "GitHub Actions", slug: "githubactions", color: "2088FF", what: "CI · CD",
237
+ detect: ["github actions", "깃허브 액션", "ci/cd"] },
238
+ ];
239
+
240
+ /** git@host:a/b.git → https://host/a/b */
241
+ function normalizeRemote(url) {
242
+ const ssh = url.match(/^git@([^:]+):(.+?)(\.git)?$/);
243
+ if (ssh) return "https://" + ssh[1] + "/" + ssh[2];
244
+ return url.replace(/\.git$/, "");
245
+ }
246
+
247
+ /** 프로젝트 루트에서 알아낼 수 있는 연동만 자동으로 집는다. */
248
+ function detectLinks(docsDir) {
249
+ const root = dirname(resolve(docsDir));
250
+ const found = {};
251
+
252
+ try {
253
+ const remote = execFileSync("git", ["-C", root, "remote", "get-url", "origin"], {
254
+ encoding: "utf8",
255
+ stdio: ["ignore", "pipe", "ignore"],
256
+ }).trim();
257
+ if (/github\.com/.test(remote)) found.github = { url: normalizeRemote(remote), note: "git origin" };
258
+ } catch {}
259
+
260
+ const specDirs = ["", "docs", "api", "openapi"];
261
+ for (const d of specDirs) {
262
+ const dir = d ? join(root, d) : root;
263
+ if (!existsSync(dir)) continue;
264
+ const hit = readdirSync(dir).find((f) => /^(openapi|swagger)\.(ya?ml|json)$/i.test(f));
265
+ if (hit) {
266
+ found.swagger = { url: (d ? "../" + d + "/" : "../") + hit, note: "스펙 파일 " + (d ? d + "/" : "") + hit };
267
+ break;
268
+ }
269
+ }
270
+ return found;
271
+ }
272
+
273
+ /**
274
+ * 기획 문서 본문에서 **연동 예정**을 잡는다.
275
+ *
276
+ * 연동은 설정 파일보다 기획서에 먼저 적힌다 — "GCP 에 배포한다", "카카오 알림톡을 쓴다".
277
+ * 그걸 사람이 설정에 한 번 더 옮겨 적게 만들 이유가 없으므로 본문에서 직접 읽는다.
278
+ * 근거(어느 문서에서 잡았는지)를 함께 돌려주어 오탐을 눈으로 판단할 수 있게 한다.
279
+ */
280
+ function detectPlannedLinks(docs) {
281
+ const found = {};
282
+ for (const c of LINK_CATALOG) {
283
+ if (!c.detect || !c.detect.length) continue;
284
+ for (const d of docs) {
285
+ const body = authoredText(d.body || "");
286
+ const hit = c.detect.find((k) => body.includes(k.toLowerCase()));
287
+ if (!hit) continue;
288
+ const title = (d.meta && d.meta.title) || d.id;
289
+ found[c.id] = { planned: true, note: "기획에 언급됨 — " + title };
290
+ break;
291
+ }
292
+ }
293
+ return found;
294
+ }
295
+
296
+ /**
297
+ * 사람이 **쓴** 본문만 남긴다.
298
+ *
299
+ * 빼는 것 세 가지. 전부 실제로 오탐을 냈다.
300
+ * - `> **작성 지침**` — 템플릿 지침에 서비스 이름이 예시로 들어 있다.
301
+ * 같이 읽으면 **빈 새 프로젝트에서도 연동이 잡힌다**
302
+ * - 코드 블록 — 설정 예시에 서비스 id 가 들어 있다
303
+ * - 인라인 코드 — `openapi` 처럼 **식별자**를 가리키는 표기다.
304
+ * "openapi 파일을 찾는다"는 설명이지 "openapi 를 쓴다"가 아니다
305
+ *
306
+ * 지침과 식별자는 "이런 걸 적어라"이지 "이걸 쓴다"가 아니다.
307
+ */
308
+ function authoredText(body) {
309
+ return body
310
+ .replace(/```[\s\S]*?```/g, "")
311
+ .split("\n")
312
+ .filter((line) => !/^\s*>/.test(line))
313
+ .join("\n")
314
+ .replace(/`[^`\n]*`/g, "")
315
+ .toLowerCase();
316
+ }
317
+
318
+ /**
319
+ * 설정 + 자동 감지 + 기획 감지를 합친다. **설정이 항상 이긴다.**
320
+ * 셋 다 비면 카탈로그의 추천 기본값으로 채운다 — 빈 페이지를 보여주지 않기 위해서다.
321
+ * 카탈로그에 없는 id도 설정에 적으면 그대로 나온다(name 은 직접 적는다).
322
+ */
323
+ function loadLinks(docsDir, config, docs = []) {
324
+ const conf = config.integrations || {};
325
+ const auto = detectLinks(docsDir);
326
+ const planned = detectPlannedLinks(docs);
327
+
328
+ let ids = [...new Set([...Object.keys(conf), ...Object.keys(auto), ...Object.keys(planned)])];
329
+ const recommended = ids.length === 0;
330
+ if (recommended) ids = LINK_CATALOG.filter((c) => c.recommend).map((c) => c.id);
331
+
332
+ return ids.map((id) => {
333
+ const base = LINK_CATALOG.find((c) => c.id === id) || { id, name: id, slug: id, color: "6b7280", what: "" };
334
+ const c = typeof conf[id] === "string" ? { url: conf[id] } : conf[id] || {};
335
+ const merged = { ...base, ...(planned[id] || {}), ...(auto[id] || {}), ...c };
336
+ merged.source = conf[id] ? "설정" : auto[id] ? "자동 감지" : planned[id] ? "기획" : "추천";
337
+ merged.connected = merged.status ? merged.status === "connected" : Boolean(merged.url);
338
+ merged.prompt = merged.connected
339
+ ? merged.name + " 연동(" + (merged.url || "") + ")이 지금 제대로 물려 있는지 점검해줘. " +
340
+ "끊겼거나 갱신이 필요한 부분을 알려주고, 바뀐 내용은 Docs/docs.config.json 의 integrations." + id +
341
+ " 에 반영한 뒤 project-init build 를 다시 돌려줘."
342
+ : "이 프로젝트를 " + merged.name + (merged.what ? "(" + merged.what + ")" : "") + "에 연동하고 싶어. " +
343
+ "지금 프로젝트 상태와 이미 붙어 있는 연동을 먼저 확인하고, " + merged.name +
344
+ " 연동에 필요한 준비물과 단계를 순서대로 알려줘. 연동이 끝나면 Docs/docs.config.json 의 integrations." + id +
345
+ ' 에 url 과 status:"connected" 를 기록하고 project-init build 를 다시 돌려서 문서에 반영해줘.';
346
+ return merged;
347
+ });
348
+ }
349
+
350
+ const LINK_ADD_PROMPT =
351
+ "연동하고 싶은 서비스가 하나 더 있어: (서비스 이름). 이 프로젝트에 어떻게 붙이면 되는지 알려주고, " +
352
+ "Docs/docs.config.json 의 integrations 에 항목을 추가한 뒤 project-init build 를 다시 돌려줘.";
353
+
354
+ /** 로고 타일. CDN이 막힌 환경에서는 이니셜로 떨어진다. */
355
+ const linkLogo = (l) =>
356
+ '<span class="logo" style="--brand:#' + l.color + '"><i>' + escapeHtml(l.name.charAt(0)) + "</i>" +
357
+ '<img src="https://cdn.simpleicons.org/' + l.slug + "/" + l.color + '" alt="" loading="lazy" onerror="this.remove()"></span>';
358
+
171
359
  /* ================================================================== *
172
360
  * 3단계 — 시안 스냅샷과 규칙 검증
173
361
  *
@@ -891,7 +1079,49 @@ function progressClass(percent) {
891
1079
  * 렌더
892
1080
  * ================================================================== */
893
1081
 
894
- function render(docs, config, trace, discovery = [], design = [], mockups = [], styleguide = null, docsDir = "", build = []) {
1082
+ /**
1083
+ * 이 문서가 프로젝트에서 실제로 쓰였는가.
1084
+ *
1085
+ * 기획안을 근거로 메뉴는 만들어졌지만 끝까지 손대지 않는 문서가 생긴다.
1086
+ * 그런 문서를 "작성 중"으로 두면 진행률이 영원히 낮게 깔리고, 무엇이 남은
1087
+ * 일이고 무엇이 애초에 필요 없던 항목인지 구분되지 않는다.
1088
+ *
1089
+ * 판정은 네 신호 중 하나라도 걸리면 '쓰임'이다. 실측하면 갓 만든 문서는
1090
+ * 37~265, 한 번이라도 채운 문서는 996 이상으로 갈라진다.
1091
+ */
1092
+ const USAGE_MIN_CHARS = 400;
1093
+
1094
+ function docUsed(doc) {
1095
+ // 주 신호는 **내용**이다. 상태나 링크는 템플릿이 만들어 낸 흔적일 수 있다:
1096
+ // log 템플릿은 status 가 approved 로 시작하고, 모든 spec 템플릿은 리스크
1097
+ // 대장으로 가는 링크를 자동으로 건다. 그걸 근거로 삼으면 갓 만든 대장이
1098
+ // 늘 '사용됨'이 된다.
1099
+ if (meaningfulChars(doc.body) >= USAGE_MIN_CHARS) return true;
1100
+
1101
+ // 내용이 적어도 사람이 손댄 흔적이 있으면 사용으로 본다
1102
+ if (doc.meta.template !== "log" && doc.meta.status && doc.meta.status !== "draft") {
1103
+ return true;
1104
+ }
1105
+ const outside = stripCode(doc.body || ""); // 템플릿 예시 ID 는 제외
1106
+ for (const id of doc.refs?.defined || []) {
1107
+ if (outside.includes(id)) return true;
1108
+ }
1109
+ return false;
1110
+ }
1111
+
1112
+ /** 템플릿 골격(제목·지침·빈 표·TODO)을 뺀 실질 글자 수. */
1113
+ function meaningfulChars(body = "") {
1114
+ return body
1115
+ .replace(/^---\n[\s\S]*?\n---\n/, "")
1116
+ .replace(/^#{1,4} .*$/gm, "")
1117
+ .replace(/^\|[\s|:-]*\|\s*$/gm, "")
1118
+ .replace(/TODO\([a-z]+\)/g, "")
1119
+ .replace(/^> .*$/gm, "")
1120
+ .replace(/\|\s*\|/g, "")
1121
+ .replace(/[\s|·—▪•\-*`>]+/g, "").length;
1122
+ }
1123
+
1124
+ function render(docs, config, trace, discovery = [], design = [], mockups = [], styleguide = null, docsDir = "", build = [], links = []) {
895
1125
  const byId = new Map([...discovery, ...design, ...docs].map((d) => [d.id, d]));
896
1126
  const resolveLink = (target) => {
897
1127
  const d = byId.get(target);
@@ -910,14 +1140,17 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
910
1140
  g.docs.push(d);
911
1141
  }
912
1142
 
913
- const navLink = (id, label, status) =>
914
- '<a class="nav-item" data-id="' +
1143
+ const navLink = (id, label, status, used) =>
1144
+ '<a class="nav-item' +
1145
+ (used === false ? " unused" : "") +
1146
+ '" data-id="' +
915
1147
  id +
1148
+ (used === undefined ? "" : '" data-used="' + (used ? "1" : "0")) +
916
1149
  '" href="#/' +
917
1150
  id +
918
1151
  '"><span class="nav-dot s-' +
919
1152
  status +
920
- '"></span><span>' +
1153
+ '"></span><span class="nav-label">' +
921
1154
  escapeHtml(label) +
922
1155
  "</span></a>";
923
1156
 
@@ -997,7 +1230,9 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
997
1230
  escapeHtml(p.name) +
998
1231
  "</div>" +
999
1232
  p.docs
1000
- .map((d) => navLink(d.id, d.meta.title || d.id, d.meta.status || "draft"))
1233
+ .map((d) =>
1234
+ navLink(d.id, d.meta.title || d.id, d.meta.status || "draft", docUsed(d)),
1235
+ )
1001
1236
  .join("") +
1002
1237
  "</div>",
1003
1238
  )
@@ -1006,7 +1241,164 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
1006
1241
  stageTitle(4, "구현") + '<div class="nav-empty">' +
1007
1242
  (progress[4] ? "구현 진행 중" : "아직 산출물 없음") + "</div></div>";
1008
1243
 
1009
- /* --- 문서 본문 (1단계 기록 + 2단계 문서) --- */
1244
+
1245
+ /* --- 연동 서비스 --- */
1246
+ /* 좌측에는 **관리 메뉴 하나만** 둔다.
1247
+ 서비스를 전부 나열하면 문서 메뉴보다 길어져 사이드바가 연동 목록판이 된다.
1248
+ 목록은 연동 서비스 페이지가 맡는다. 여기서는 몇 개가 붙었는지만 보여준다. */
1249
+ const linkedCount = links.filter((l) => l.connected).length;
1250
+ const linksNav =
1251
+ '<div class="nav-group nav-links">' +
1252
+ '<a class="nav-item" data-id="__links" href="#/__links">' +
1253
+ '<span class="nav-dot s-utility"></span><span>연동 서비스 관리</span><span class="nav-progress ' +
1254
+ progressClass(links.length ? Math.round((linkedCount / links.length) * 100) : 0) + '">' +
1255
+ linkedCount + "/" + links.length + "</span></a>" +
1256
+ "</div>";
1257
+
1258
+ const SRC_PILL = { "설정": "s-approved", "자동 감지": "s-review", "기획": "s-review", "추천": "s-draft" };
1259
+ const linkCard = (l) =>
1260
+ '<div class="link-card' + (l.connected ? " on" : "") + '">' +
1261
+ '<div class="link-head">' + linkLogo(l) +
1262
+ '<span class="link-name">' + escapeHtml(l.name) + "</span>" +
1263
+ '<span class="pill ' + (SRC_PILL[l.source] || "s-draft") + '">' + escapeHtml(l.source || "") + "</span>" +
1264
+ '<span class="pill ' + (l.connected ? "s-approved" : "s-draft") + '">' +
1265
+ (l.connected ? "연결됨" : "미연동") + "</span></div>" +
1266
+ '<p class="card-summary">' + escapeHtml(l.what || "") +
1267
+ (l.note ? " — " + escapeHtml(l.note) : "") + "</p>" +
1268
+ (l.url ? '<div class="link-url">' + escapeHtml(l.url) + "</div>" : "") +
1269
+ '<div class="link-actions">' +
1270
+ (l.connected && l.url
1271
+ ? '<a class="link-btn primary" href="' + escapeHtml(l.url) + '" target="_blank" rel="noreferrer">열기 ↗</a>'
1272
+ : "") +
1273
+ '<button class="link-btn" type="button" data-copy="' + escapeHtml(l.prompt) + '">' +
1274
+ (l.connected ? "점검 요청 복사" : "연동 방법 묻기 복사") + "</button></div></div>";
1275
+
1276
+ const linksArticle =
1277
+ '<article class="doc" data-id="__links" data-title="연동 서비스"><div class="doc-inner"><div class="doc-body">' +
1278
+ "<h1>연동 서비스</h1>" +
1279
+ '<p class="home-lead">이 프로젝트가 밖으로 물려 있는 곳들입니다. ' +
1280
+ "연결된 카드는 눌러서 바로 이동하고, 아직 안 붙은 카드는 <strong>연동 방법 묻기</strong>를 눌러 " +
1281
+ "문구를 복사한 뒤 AI에게 그대로 물어보면 됩니다.</p>" +
1282
+ (links.every((l) => l.source === "추천")
1283
+ ? '<p class="home-lead links-hint"><strong>아직 정해진 연동이 없어 추천 목록을 보여줍니다.</strong> ' +
1284
+ "기획 문서에 쓸 서비스를 적으면(예: “GCP 에 배포한다”) 다음 빌드부터 이 자리에 그것이 올라옵니다. " +
1285
+ "확정된 것은 <code>Docs/docs.config.json</code> 의 <code>integrations</code> 에 적습니다.</p>"
1286
+ : '<p class="home-lead links-hint">목록은 <strong>설정 → 자동 감지 → 기획 본문</strong> 순으로 모읍니다. ' +
1287
+ "<code>기획</code> 딱지가 붙은 카드는 문서에 언급되어 자동으로 올라온 것이라 " +
1288
+ "오탐일 수 있습니다 — 아니면 <code>integrations</code> 에서 빼면 됩니다.</p>") +
1289
+ '<div class="links-grid">' +
1290
+ links.map(linkCard).join("") +
1291
+ '<div class="link-card link-add"><div class="link-head"><span class="logo logo-add"><i>+</i></span>' +
1292
+ '<span class="link-name">새 서비스 추가</span></div>' +
1293
+ '<p class="card-summary">카탈로그에 없는 서비스도 붙일 수 있습니다.</p>' +
1294
+ '<div class="link-actions"><button class="link-btn" type="button" data-copy="' +
1295
+ escapeHtml(LINK_ADD_PROMPT) + '">추가 문의 문구 복사</button></div></div>' +
1296
+ "</div></div></div></article>";
1297
+
1298
+ /* 산출물 대본.
1299
+ *
1300
+ * 코드가 아니라 **AI 에게 주는 지시문**이다. 복사해서 에이전트에 붙여 넣으면
1301
+ * 그 형식으로 산출물을 만든다.
1302
+ *
1303
+ * 대본은 네 겹으로 쌓인다:
1304
+ * _shape 문서 종류별 구성 — 이 문서는 무엇을 앞에 세워야 하는가
1305
+ * _purpose 목적 블록 — 받는 사람이 첫 화면에서 무엇을 알아야 하는가
1306
+ * _scope 고객 송부용 정제 — 무엇을 빼는가
1307
+ * _style 시각 규격 — 색·글자·표. 값이 고정이라 매번 같은 모양이 나온다
1308
+ *
1309
+ * 대본은 원본 .md 경로만 가리킨다. 내용을 품지 않으므로 문서를 고친 뒤 같은
1310
+ * 대본을 다시 쓰면 최신 내용으로 만들어진다. HTML 크기도 문서 수와 무관하다. */
1311
+ const SCRIPT_FORMATS = [
1312
+ { id: "pdf", label: "PDF", ext: "pdf" },
1313
+ { id: "excel", label: "Excel (XLSX)", ext: "xlsx" },
1314
+ { id: "pptx", label: "PowerPoint (PPTX)", ext: "pptx" },
1315
+ { id: "docx", label: "Word (DOCX)", ext: "docx" },
1316
+ { id: "csv", label: "CSV", ext: "csv" },
1317
+ ];
1318
+
1319
+ /* 문서 종류별 구성 지침.
1320
+ *
1321
+ * 같은 서식이어도 산출물의 뼈대는 문서마다 다르다. 일정표는 기간이 보여야 하고
1322
+ * 요구사항 대장은 무엇이 어디로 이어지는지가 보여야 한다. 이 지침이 없으면
1323
+ * "원본 순서대로 옮긴 파일"이 나오고, 받는 사람은 무엇을 보라는 것인지 모른다.
1324
+ *
1325
+ * 키는 문서 name(모듈에 적힌 것)이다. 없으면 기본 지침을 쓴다. */
1326
+ const DOC_SHAPE = {
1327
+ "deliverable-register":
1328
+ "이 문서는 **납품 목록**입니다. 공정별로 묶고, 각 산출물의 `필수 여부`·" +
1329
+ "`고객 공유`·`상태`가 한눈에 보이게 하세요. 맨 앞 수치는 `전체 종수`, " +
1330
+ "`필수`, `작성 완료`, `미착수` 입니다. **제외한 산출물** 목록을 빠뜨리지 " +
1331
+ "마세요 — 고객이 '이건 왜 없냐'고 묻는 지점입니다. 버전 이력은 맨 뒤에 둡니다.",
1332
+ "wbs-schedule":
1333
+ "이 문서는 **일정표**입니다. 공정 → 과업 2단으로 묶고, 각 과업의 시작·종료·" +
1334
+ "담당·진척이 한 행에 보이게 하세요. 맨 앞 수치는 `전체 과업`, `종합 진척률`, " +
1335
+ "`진행 중`, `지연` 입니다. **마일스톤은 본표와 분리해 따로 세우세요** — " +
1336
+ "고객이 가장 먼저 보는 것이 보고·승인 시점입니다. 지연 과업은 굵게 표시합니다.",
1337
+ requirements:
1338
+ "이 문서는 **요구사항 추적표**입니다. 각 요구사항이 `어느 과업`으로 가고 " +
1339
+ "`어느 산출물`로 확인되는지가 같은 행에 보여야 합니다. 맨 앞 수치는 " +
1340
+ "`전체 요구사항`, `수용`, `추가 검토`, `미수용` 입니다. 부서별로 묶으면 " +
1341
+ "현업이 자기 것을 찾기 쉽습니다. **수용하지 않은 요구사항과 그 이유**를 " +
1342
+ "반드시 포함하세요.",
1343
+ "project-brief":
1344
+ "이 문서는 **사업 개요**입니다. 목표와 범위가 먼저이고, 특히 `범위 제외`를 " +
1345
+ "분명히 세우세요. 맨 앞 수치는 `목표 수`, `기간`, `이해관계자 수` 입니다. " +
1346
+ "이해관계자는 이름과 역할만 남기고 연락처는 뺍니다.",
1347
+ "screen-spec":
1348
+ "이 문서는 **화면 설계서**입니다. 화면 목록을 먼저 보여주고, 그 뒤에 화면별 " +
1349
+ "상세를 둡니다. 맨 앞 수치는 `전체 화면 수`와 구분별 개수입니다. " +
1350
+ "화면마다 **무엇을 하는 화면인지 한 줄**을 앞에 붙이세요.",
1351
+ "data-model":
1352
+ "이 문서는 **데이터 구조**입니다. 테이블 목록을 먼저, 그 뒤 테이블별 칼럼. " +
1353
+ "맨 앞 수치는 `테이블 수`, `관계 수` 입니다. 고객이 읽을 것이므로 " +
1354
+ "**한글 논리명을 물리명보다 앞에** 두세요.",
1355
+ "qa-test-plan":
1356
+ "이 문서는 **검증 계획**입니다. 무엇을 어떤 기준으로 통과시키는지가 핵심입니다. " +
1357
+ "맨 앞 수치는 `시나리오 수`, `필수 통과 항목`, `일정` 입니다. " +
1358
+ "고객이 직접 하는 확인(UAT)과 우리가 하는 확인을 **구분해서** 세우세요.",
1359
+ "release-ops":
1360
+ "이 문서는 **오픈·운영 안내**입니다. 오픈 절차와 이후 지원 체계가 핵심입니다. " +
1361
+ "맨 앞 수치는 `오픈 예정일`, `교육 대상 인원`, `지원 기간` 입니다. " +
1362
+ "고객이 해야 할 준비를 따로 묶어 세우세요.",
1363
+ };
1364
+
1365
+ const DOC_SHAPE_DEFAULT =
1366
+ "원본의 절 구성을 따르되, **결론과 전체 그림을 앞으로 올리세요.** " +
1367
+ "표가 여러 개면 가장 중요한 것 하나를 먼저 세우고 나머지를 뒤로 보냅니다. " +
1368
+ "무엇이 가장 중요한지 판단이 안 서면, 행 수가 가장 많은 표가 보통 본표입니다.";
1369
+
1370
+ function loadScripts(project) {
1371
+ const dir = join(SKILL_DIR, "templates", "script");
1372
+ const part = (name) => {
1373
+ const path = join(dir, name + ".md");
1374
+ return existsSync(path) ? readFileSync(path, "utf8").trim() : "";
1375
+ };
1376
+ const style = part("_style");
1377
+ if (!style) return null;
1378
+ const purpose = part("_purpose");
1379
+ const scope = part("_scope");
1380
+ const out = {};
1381
+ for (const f of SCRIPT_FORMATS) {
1382
+ const path = join(dir, f.id + ".md");
1383
+ if (!existsSync(path)) continue;
1384
+ out[f.id] = readFileSync(path, "utf8")
1385
+ .replace("__STYLE__", style)
1386
+ .replace("__PURPOSE__", purpose)
1387
+ .replace("__SCOPE__", scope)
1388
+ .split("__PROJECT__").join(project || "이");
1389
+ }
1390
+ return Object.keys(out).length ? out : null;
1391
+ }
1392
+
1393
+ /** 문서 id(`07-screen-spec`)에서 구성 지침을 찾는다. */
1394
+ function docShape(id = "") {
1395
+ const name = id.replace(/^\d+-/, "");
1396
+ return "## 이 문서의 구성\n\n" + (DOC_SHAPE[name] || DOC_SHAPE_DEFAULT);
1397
+ }
1398
+
1399
+
1400
+ /* --- 문서 본문 (1단계 기록 + 2단계 문서) --- */
1401
+ const scripts = loadScripts(config.project);
1010
1402
  const searchIndex = [];
1011
1403
  const articles = [...discovery, ...design, ...docs]
1012
1404
  .map((d) => {
@@ -1087,7 +1479,17 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
1087
1479
  escapeHtml(d.meta.updated || d.meta.date || "—") +
1088
1480
  '</span><span class="meta-sep"></span><span class="mono">' +
1089
1481
  escapeHtml(d.file) +
1090
- "</span></div>" +
1482
+ "</span>" +
1483
+ (stage1
1484
+ ? ""
1485
+ : '<button class="doc-export" type="button" data-export-file="' +
1486
+ escapeHtml(d.file) +
1487
+ '" data-export-title="' +
1488
+ escapeHtml(d.meta.title || d.id) +
1489
+ '" data-export-id="' +
1490
+ escapeHtml(d.id) +
1491
+ '">파일 스크립트</button>') +
1492
+ "</div>" +
1091
1493
  html +
1092
1494
  definedHtml +
1093
1495
  backHtml +
@@ -1317,9 +1719,15 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
1317
1719
  '<span class="search-icon">' + ICON.search + "</span>" +
1318
1720
  '<input id="search" type="search" placeholder="문서 검색" autocomplete="off" spellcheck="false">' +
1319
1721
  '<kbd class="search-kbd">/</kbd></div></div>' +
1722
+ '<div class="nav-tools">' +
1723
+ '<label class="nav-toggle"><input type="checkbox" id="inactiveMode">' +
1724
+ '<span>비활성 관리</span></label>' +
1725
+ '<span class="nav-count" id="inactiveCount"></span>' +
1726
+ "</div>" +
1320
1727
  '<div class="nav" id="nav">' +
1321
1728
  guideNav +
1322
1729
  navLink("__home", "홈", "home") +
1730
+ linksNav +
1323
1731
  navLink("__trace", "추적성 매트릭스", "utility") +
1324
1732
  discoveryNav +
1325
1733
  sidebar +
@@ -1341,10 +1749,44 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
1341
1749
  '<div class="doc-body"><h1>검색</h1><div id="results"></div></div></div></article>' +
1342
1750
  guideArticle +
1343
1751
  home +
1752
+ linksArticle +
1344
1753
  mockupArticle +
1345
1754
  traceArticle +
1346
1755
  articles +
1347
1756
  "</main>" +
1757
+ (scripts
1758
+ ? '<div class="modal" id="exportModal" hidden>' +
1759
+ '<div class="modal-back" data-close-export></div>' +
1760
+ '<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="exportTitle">' +
1761
+ '<div class="modal-head">' +
1762
+ '<div><div class="modal-title" id="exportTitle">파일 스크립트 — 산출물 생성 대본</div>' +
1763
+ '<div class="modal-sub" id="exportSub"></div></div>' +
1764
+ '<button class="icon-btn" type="button" data-close-export aria-label="닫기">\u2715</button>' +
1765
+ "</div>" +
1766
+ '<div class="modal-bar">' +
1767
+ '<label class="modal-label" for="exportFormat">형식</label>' +
1768
+ '<select class="modal-select" id="exportFormat">' +
1769
+ SCRIPT_FORMATS.filter((f) => scripts[f.id])
1770
+ .map((f) => '<option value="' + f.id + '">' + f.label + "</option>")
1771
+ .join("") +
1772
+ "</select>" +
1773
+ '<span class="modal-install" id="exportInstall"></span>' +
1774
+ '<button class="modal-copy" type="button" id="exportCopy">대본 복사</button>' +
1775
+ "</div>" +
1776
+ '<div class="modal-body"><pre class="modal-code" id="exportCode"></pre></div>' +
1777
+ '<div class="modal-foot">복사해서 AI 에게 그대로 붙여 넣으면 됩니다. ' +
1778
+ '대본은 원본 <span class="mono" id="exportFile"></span> 을 가리킬 뿐 내용을 품지 않으므로, ' +
1779
+ "문서를 고친 뒤 같은 대본을 다시 써도 최신 내용으로 만들어집니다." +
1780
+ "</div></div></div>" +
1781
+ '<script id="exportScripts" type="application/json">' +
1782
+ JSON.stringify({
1783
+ scripts: scripts,
1784
+ formats: SCRIPT_FORMATS,
1785
+ shapes: Object.fromEntries(docs.map((d) => [d.id, docShape(d.id)])),
1786
+ })
1787
+ .replace(/</g, "\\u003c") +
1788
+ "<\/script>"
1789
+ : "") +
1348
1790
  '<script id="searchIndex" type="application/json">' +
1349
1791
  JSON.stringify(searchIndex).replace(/</g, "\\u003c") +
1350
1792
  "<\/script>" +
@@ -1380,6 +1822,82 @@ const CSS = `
1380
1822
  --s-draft:#979a9b; --s-review:#d9730d; --s-approved:#4dab9a;
1381
1823
  --alert-bg:#2d1f1c; --alert-line:#5c332a; --alert-tx:#ff9b8a;
1382
1824
  }
1825
+
1826
+ /* ── 파일 스크립트 버튼 · 모달 ─────────────────────────────── */
1827
+ .doc-export{margin-left:auto; font:inherit; font-size:12px; font-weight:500;
1828
+ color:var(--tx-dim); background:transparent; border:1px solid var(--line-strong);
1829
+ border-radius:6px; padding:3px 10px; cursor:pointer; white-space:nowrap;
1830
+ transition:background .12s, color .12s, border-color .12s}
1831
+ .doc-export:hover{background:var(--hover); color:var(--tx); border-color:var(--tx-faint)}
1832
+ .modal[hidden]{display:none}
1833
+ .modal{position:fixed; inset:0; z-index:90; display:flex; align-items:center;
1834
+ justify-content:center; padding:24px}
1835
+ .modal-back{position:absolute; inset:0; background:rgba(15,15,15,.45);
1836
+ backdrop-filter:blur(2px); animation:mfade .16s var(--ease)}
1837
+ .modal-card{position:relative; display:flex; flex-direction:column;
1838
+ width:min(880px,100%); max-height:min(86vh,760px); background:var(--bg);
1839
+ border:1px solid var(--line-strong); border-radius:14px; overflow:hidden;
1840
+ box-shadow:0 24px 64px rgba(15,15,15,.22); animation:mrise .2s var(--ease)}
1841
+ @keyframes mfade{from{opacity:0}}
1842
+ @keyframes mrise{from{opacity:0; transform:translateY(10px) scale(.985)}}
1843
+ .modal-head{display:flex; align-items:flex-start; gap:16px; padding:18px 20px 14px;
1844
+ border-bottom:1px solid var(--line)}
1845
+ .modal-title{font-size:16px; font-weight:600; letter-spacing:-.01em}
1846
+ .modal-sub{font-size:12.5px; color:var(--tx-dim); margin-top:2px}
1847
+ .modal-head .icon-btn{margin-left:auto; flex:none}
1848
+ .modal-bar{display:flex; align-items:center; gap:10px; flex-wrap:wrap;
1849
+ padding:12px 20px; background:var(--bg-side); border-bottom:1px solid var(--line)}
1850
+ .modal-label{font-size:12px; color:var(--tx-dim); font-weight:500}
1851
+ .modal-select{font:inherit; font-size:13px; color:var(--tx); background:var(--bg);
1852
+ border:1px solid var(--line-strong); border-radius:7px; padding:5px 10px; cursor:pointer}
1853
+ .modal-install{font-family:var(--mono,ui-monospace,SFMono-Regular,Menlo,monospace);
1854
+ font-size:11.5px; color:var(--tx-dim); background:var(--bg-code);
1855
+ border-radius:5px; padding:3px 8px}
1856
+ .modal-copy{margin-left:auto; font:inherit; font-size:12.5px; font-weight:600;
1857
+ color:#fff; background:var(--accent); border:0; border-radius:7px;
1858
+ padding:6px 14px; cursor:pointer}
1859
+ .modal-copy:hover{filter:brightness(1.06)}
1860
+ .modal-body{overflow:auto; background:var(--bg-code)}
1861
+ .modal-code{margin:0; padding:18px 20px; font-size:12px; line-height:1.65;
1862
+ white-space:pre; color:var(--tx);
1863
+ font-family:var(--mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
1864
+ .modal-foot{padding:11px 20px; font-size:12px; color:var(--tx-dim);
1865
+ border-top:1px solid var(--line)}
1866
+ @media(max-width:700px){
1867
+ .modal{padding:10px}
1868
+ .modal-copy{margin-left:0}
1869
+ .modal-code{font-size:11px}
1870
+ }
1871
+
1872
+ /* ── 비활성 메뉴 ─────────────────────────────────────────── */
1873
+ .nav-tools{display:flex; align-items:center; gap:8px; padding:2px 14px 8px}
1874
+ .nav-toggle{display:inline-flex; align-items:center; gap:6px; cursor:pointer;
1875
+ font-size:11.5px; color:var(--tx-faint); user-select:none}
1876
+ .nav-toggle input{width:12px; height:12px; margin:0; cursor:pointer; accent-color:var(--accent)}
1877
+ .nav-toggle:hover{color:var(--tx-dim)}
1878
+ .nav-count{margin-left:auto; font-size:10.5px; color:var(--tx-faint); font-variant-numeric:tabular-nums}
1879
+ /* 한 번도 쓰이지 않은 메뉴 — 노란 점이 아니라 검정 점으로 죽여 둔다 */
1880
+ .nav-item.unused .nav-dot,
1881
+ .nav-group.p-yellow .nav-item.unused .nav-dot,
1882
+ .nav-group.p-red .nav-item.unused .nav-dot,
1883
+ .nav-group.p-orange .nav-item.unused .nav-dot,
1884
+ .nav-group.p-green .nav-item.unused .nav-dot,
1885
+ .nav-group.p-gray .nav-item.unused .nav-dot{
1886
+ background:#000; box-shadow:0 0 0 1px var(--line-strong)}
1887
+ [data-theme=dark] .nav-item.unused .nav-dot,
1888
+ [data-theme=dark] .nav-group .nav-item.unused .nav-dot{
1889
+ background:#000; box-shadow:0 0 0 1px rgba(255,255,255,.22)}
1890
+ .nav-item.unused .nav-label{color:var(--tx-faint)}
1891
+ .nav-item.unused:hover .nav-label{color:var(--tx-dim)}
1892
+ /* 관리 모드 */
1893
+ body.inactive-mode .nav-item{position:relative; padding-left:30px}
1894
+ body.inactive-mode .nav-item .nav-dot{position:absolute; left:18px}
1895
+ .nav-check{position:absolute; left:7px; top:50%; transform:translateY(-50%);
1896
+ width:12px; height:12px; margin:0; cursor:pointer; accent-color:var(--accent);
1897
+ display:none}
1898
+ body.inactive-mode .nav-check{display:block}
1899
+ body.inactive-mode .nav-tools{background:var(--hover); border-radius:7px;
1900
+ margin:0 6px 6px; padding:6px 8px}
1383
1901
  html{-webkit-text-size-adjust:100%}
1384
1902
  body{
1385
1903
  margin:0; background:var(--bg); color:var(--tx);
@@ -1460,6 +1978,29 @@ a{color:inherit}
1460
1978
  .nav-group.nav-stage3{padding-bottom:12px; border-bottom:1px solid var(--line)}
1461
1979
  .panel-stage3{border-color:var(--stage3)}
1462
1980
  .panel-stage3 .panel-title{color:var(--stage3)}
1981
+ .logo{position:relative; width:18px; height:18px; flex:none; border-radius:4px; background:#fff;
1982
+ border:1px solid var(--line-strong); overflow:hidden}
1983
+ .logo i{position:absolute; inset:0; display:grid; place-items:center; font-style:normal;
1984
+ font-size:10px; font-weight:700; color:var(--brand,#555)}
1985
+ .logo img{position:relative; display:block; width:100%; height:100%; padding:3px; object-fit:contain}
1986
+ .logo-add i{color:var(--tx-faint); font-size:13px}
1987
+ .links-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:10px; margin-top:22px}
1988
+ .link-card{display:flex; flex-direction:column; gap:6px; padding:14px 16px;
1989
+ border:1px solid var(--line-strong); border-radius:8px; background:var(--bg)}
1990
+ .link-card.on{border-color:var(--green)}
1991
+ .link-card.link-add{border-style:dashed}
1992
+ .link-head{display:flex; align-items:center; gap:8px}
1993
+ .link-head .logo{width:22px; height:22px}
1994
+ .link-name{font-weight:600; font-size:14px}
1995
+ .link-head .pill{margin-left:auto}
1996
+ .link-head .pill ~ .pill{margin-left:0}
1997
+ .link-url{font-size:11.5px; color:var(--tx-faint); word-break:break-all}
1998
+ .link-actions{display:flex; gap:6px; margin-top:auto; padding-top:6px}
1999
+ .link-btn{display:inline-flex; align-items:center; justify-content:center; padding:5px 10px;
2000
+ border:1px solid var(--line-strong); border-radius:6px; background:transparent; cursor:pointer;
2001
+ font:inherit; font-size:12px; color:var(--tx-dim); text-decoration:none; white-space:nowrap}
2002
+ .link-btn:hover{background:var(--hover); color:var(--tx)}
2003
+ .link-btn.primary{border-color:var(--accent); color:var(--accent)}
1463
2004
  .mockups{display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:10px; margin-top:20px}
1464
2005
  .mockup{display:flex; align-items:center; justify-content:space-between; gap:10px;
1465
2006
  padding:14px 16px; border:1px solid var(--line-strong); border-radius:8px;
@@ -1643,6 +2184,7 @@ a.panel-row:hover{color:var(--tx)}
1643
2184
  .doc-body h1{font-size:30px}
1644
2185
  .doc-body h2{font-size:21px}
1645
2186
  .cards{grid-template-columns:1fr}
2187
+ .links-grid{grid-template-columns:1fr}
1646
2188
  }
1647
2189
  @media (prefers-reduced-motion:reduce){ *{transition-duration:.01ms !important; animation-duration:.01ms !important} }
1648
2190
  `;
@@ -1709,6 +2251,201 @@ const JS = `
1709
2251
  if(el){ e.preventDefault(); el.scrollIntoView({behavior:'smooth', block:'start'}); }
1710
2252
  });
1711
2253
 
2254
+
2255
+
2256
+ /* ── 비활성 메뉴 관리 ────────────────────────────
2257
+ * 자동 판정(data-used=0)이 기본이고, 사용자가 체크로 덮어쓴다.
2258
+ * index.html 은 빌드마다 새로 만들어지므로 덮어쓴 값은 문서 id 로
2259
+ * localStorage 에 둔다 — 다시 빌드해도 유지된다. */
2260
+ (function(){
2261
+ var box = document.getElementById('inactiveMode');
2262
+ var nav = document.getElementById('nav');
2263
+ var count = document.getElementById('inactiveCount');
2264
+ if(!box || !nav) return;
2265
+ var KEY = 'inactiveDocs';
2266
+
2267
+ function load(){
2268
+ try { return JSON.parse(localStorage.getItem(KEY)) || {}; }
2269
+ catch(e){ return {}; }
2270
+ }
2271
+ function save(map){
2272
+ try { localStorage.setItem(KEY, JSON.stringify(map)); } catch(e){}
2273
+ }
2274
+
2275
+ var overrides = load();
2276
+ var items = [].slice.call(nav.querySelectorAll('.nav-item[data-used]'));
2277
+
2278
+ function isInactive(item){
2279
+ var id = item.getAttribute('data-id');
2280
+ if(Object.prototype.hasOwnProperty.call(overrides, id)) return !!overrides[id];
2281
+ return item.getAttribute('data-used') === '0';
2282
+ }
2283
+
2284
+ function paint(){
2285
+ var n = 0;
2286
+ items.forEach(function(item){
2287
+ var off = isInactive(item);
2288
+ item.classList.toggle('unused', off);
2289
+ var cb = item.querySelector('.nav-check');
2290
+ if(cb) cb.checked = off;
2291
+ if(off) n++;
2292
+ });
2293
+ count.textContent = n ? ('비활성 ' + n + ' / ' + items.length) : '';
2294
+ }
2295
+
2296
+ // 관리 모드에서 쓸 체크박스를 미리 넣어 둔다 (CSS 로 보였다 숨긴다)
2297
+ items.forEach(function(item){
2298
+ var cb = document.createElement('input');
2299
+ cb.type = 'checkbox';
2300
+ cb.className = 'nav-check';
2301
+ cb.title = '비활성으로 표시';
2302
+ item.insertBefore(cb, item.firstChild);
2303
+ });
2304
+
2305
+ /* 체크박스가 <a> 안에 있어 클릭이 그대로 두면 문서 이동까지 간다.
2306
+ * 문서 캡처 단계에서 먼저 가로채 라우터보다 앞서 끊는다. */
2307
+ document.addEventListener('click', function(e){
2308
+ var cb = e.target;
2309
+ if(!cb.classList || !cb.classList.contains('nav-check')) return;
2310
+ e.preventDefault();
2311
+ e.stopPropagation();
2312
+ if(e.stopImmediatePropagation) e.stopImmediatePropagation();
2313
+ var item = cb.closest('.nav-item');
2314
+ if(!item) return;
2315
+ var id = item.getAttribute('data-id');
2316
+ var next = !isInactive(item);
2317
+ var auto = item.getAttribute('data-used') === '0';
2318
+ // 자동 판정과 같아지면 덮어쓰기를 지운다 — 쓸데없는 저장을 남기지 않는다
2319
+ if(next === auto) delete overrides[id];
2320
+ else overrides[id] = next;
2321
+ save(overrides);
2322
+ paint();
2323
+ }, true);
2324
+
2325
+ box.addEventListener('change', function(){
2326
+ document.body.classList.toggle('inactive-mode', box.checked);
2327
+ try { localStorage.setItem('inactiveMode', box.checked ? '1' : ''); } catch(e){}
2328
+ });
2329
+ try {
2330
+ if(localStorage.getItem('inactiveMode')){
2331
+ box.checked = true;
2332
+ document.body.classList.add('inactive-mode');
2333
+ }
2334
+ } catch(e){}
2335
+
2336
+ paint();
2337
+ })();
2338
+
2339
+ /* ── 파일 스크립트 모달 ──────────────────────────── */
2340
+ (function(){
2341
+ var node = document.getElementById('exportScripts');
2342
+ var modal = document.getElementById('exportModal');
2343
+ if(!node || !modal) return;
2344
+ var data = JSON.parse(node.textContent);
2345
+ var sel = document.getElementById('exportFormat');
2346
+ var code = document.getElementById('exportCode');
2347
+ var install = document.getElementById('exportInstall');
2348
+ var sub = document.getElementById('exportSub');
2349
+ var fileEl = document.getElementById('exportFile');
2350
+ var copyBtn = document.getElementById('exportCopy');
2351
+ var cur = { file: '', title: '' };
2352
+ var lastFocus = null;
2353
+
2354
+ function fmt(id){
2355
+ for(var i=0;i<data.formats.length;i++){ if(data.formats[i].id===id) return data.formats[i]; }
2356
+ return data.formats[0];
2357
+ }
2358
+ function stem(file){ return file.replace(/\.[^.]+$/, ''); }
2359
+
2360
+ function render(){
2361
+ var f = fmt(sel.value);
2362
+ var src = data.scripts[f.id] || '';
2363
+ code.textContent = src
2364
+ .split('__SHAPE__').join((data.shapes && data.shapes[cur.id]) || '')
2365
+ .split('__DOC_FILE__').join(cur.file)
2366
+ .split('__DOC_TITLE__').join(cur.title)
2367
+ .split('__DOC_STEM__').join(stem(cur.file));
2368
+ install.textContent = stem(cur.file) + '.' + f.ext;
2369
+ try { localStorage.setItem('exportFormat', f.id); } catch(e){}
2370
+ }
2371
+
2372
+ function open(btn){
2373
+ cur.file = btn.getAttribute('data-export-file') || '';
2374
+ cur.title = btn.getAttribute('data-export-title') || '';
2375
+ cur.id = btn.getAttribute('data-export-id') || '';
2376
+ sub.textContent = cur.title;
2377
+ fileEl.textContent = cur.file;
2378
+ try {
2379
+ var saved = localStorage.getItem('exportFormat');
2380
+ if(saved && data.scripts[saved]) sel.value = saved;
2381
+ } catch(e){}
2382
+ render();
2383
+ lastFocus = btn;
2384
+ modal.hidden = false;
2385
+ document.body.style.overflow = 'hidden';
2386
+ sel.focus();
2387
+ }
2388
+ function close(){
2389
+ modal.hidden = true;
2390
+ document.body.style.overflow = '';
2391
+ if(lastFocus) lastFocus.focus();
2392
+ }
2393
+
2394
+ document.addEventListener('click', function(e){
2395
+ var btn = e.target.closest && e.target.closest('[data-export-file]');
2396
+ if(btn){ e.preventDefault(); open(btn); return; }
2397
+ if(e.target.closest && e.target.closest('[data-close-export]')) close();
2398
+ });
2399
+ document.addEventListener('keydown', function(e){
2400
+ if(e.key === 'Escape' && !modal.hidden) close();
2401
+ });
2402
+ sel.addEventListener('change', render);
2403
+
2404
+ copyBtn.addEventListener('click', function(){
2405
+ var text = code.textContent;
2406
+ var label = copyBtn.textContent;
2407
+ function done(){
2408
+ copyBtn.textContent = '복사됨 ✓';
2409
+ setTimeout(function(){ copyBtn.textContent = label; }, 1600);
2410
+ }
2411
+ function fallback(){
2412
+ var ta = document.createElement('textarea');
2413
+ ta.value = text; ta.setAttribute('readonly','');
2414
+ ta.style.position='fixed'; ta.style.opacity='0';
2415
+ document.body.appendChild(ta); ta.select();
2416
+ try { document.execCommand('copy'); done(); }
2417
+ catch(err){ copyBtn.textContent = '복사 실패'; }
2418
+ ta.remove();
2419
+ }
2420
+ if(navigator.clipboard && navigator.clipboard.writeText){
2421
+ navigator.clipboard.writeText(text).then(done, fallback);
2422
+ } else { fallback(); }
2423
+ });
2424
+ })();
2425
+
2426
+ /* ── 연동 카드: 문구 복사 ──────────────────────── */
2427
+ document.addEventListener('click', function(e){
2428
+ var b = e.target.closest && e.target.closest('[data-copy]');
2429
+ if(!b) return;
2430
+ var text = b.getAttribute('data-copy');
2431
+ var label = b.textContent;
2432
+ function done(){
2433
+ b.textContent = '복사됨 ✓';
2434
+ setTimeout(function(){ b.textContent = label; }, 1600);
2435
+ }
2436
+ function fallback(){
2437
+ var ta = document.createElement('textarea');
2438
+ ta.value = text; ta.setAttribute('readonly','');
2439
+ ta.style.position = 'fixed'; ta.style.opacity = '0';
2440
+ document.body.appendChild(ta); ta.select();
2441
+ try { document.execCommand('copy'); done(); } catch(err) { b.textContent = '복사 실패'; }
2442
+ ta.remove();
2443
+ }
2444
+ if(navigator.clipboard && navigator.clipboard.writeText){
2445
+ navigator.clipboard.writeText(text).then(done, fallback);
2446
+ } else { fallback(); }
2447
+ });
2448
+
1712
2449
  /* ── 전문 검색 ───────────────────────────────────── */
1713
2450
  var search = document.getElementById('search');
1714
2451
  var results = document.getElementById('results');
@@ -2034,9 +2771,10 @@ function cmdBuild(docsDir) {
2034
2771
  const config = loadConfig(docsDir);
2035
2772
  const mockups = loadMockups(docsDir);
2036
2773
  const styleguide = findStyleguide(docsDir);
2774
+ const links = loadLinks(docsDir, config, docs);
2037
2775
  const trace = buildTraceability([...discovery, ...design, ...docs]);
2038
2776
  const out = join(docsDir, "index.html");
2039
- writeFileSync(out, render(docs, config, trace, discovery, design, mockups, styleguide, docsDir, build), "utf8");
2777
+ writeFileSync(out, render(docs, config, trace, discovery, design, mockups, styleguide, docsDir, build, links), "utf8");
2040
2778
  console.log(
2041
2779
  "생성됨: " +
2042
2780
  out +