@qualisoft/ai-skills 1.0.0 → 1.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/CHANGELOG.md +183 -0
- package/README.md +64 -10
- package/bin/cli.mjs +171 -38
- package/package.json +2 -1
- package/skills/project-init/SKILL.md +92 -0
- package/skills/project-init/build.mjs +668 -8
- package/skills/project-init/templates/script/_style.md +47 -0
- package/skills/project-init/templates/script/csv.md +49 -0
- package/skills/project-init/templates/script/docx.md +35 -0
- package/skills/project-init/templates/script/excel.md +38 -0
- package/skills/project-init/templates/script/pdf.md +35 -0
- package/skills/project-init/templates/script/pptx.md +37 -0
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
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,94 @@ 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
|
-
|
|
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
|
+
* 그 형식으로 산출물을 만든다. 서식 규격(`_style.md`)이 대본 안에 박혀 있으므로
|
|
1302
|
+
* 착수 때 만들든 중간에 만들든, 누가 어떤 도구로 만들든 같은 모양이 나온다.
|
|
1303
|
+
*
|
|
1304
|
+
* 대본은 원본 .md 경로만 가리킨다. 내용을 품지 않으므로 문서를 고친 뒤 같은
|
|
1305
|
+
* 대본을 다시 쓰면 최신 내용으로 만들어진다. HTML 크기도 문서 수와 무관하다. */
|
|
1306
|
+
const SCRIPT_FORMATS = [
|
|
1307
|
+
{ id: "pdf", label: "PDF", ext: "pdf" },
|
|
1308
|
+
{ id: "excel", label: "Excel (XLSX)", ext: "xlsx" },
|
|
1309
|
+
{ id: "pptx", label: "PowerPoint (PPTX)", ext: "pptx" },
|
|
1310
|
+
{ id: "docx", label: "Word (DOCX)", ext: "docx" },
|
|
1311
|
+
{ id: "csv", label: "CSV", ext: "csv" },
|
|
1312
|
+
];
|
|
1313
|
+
|
|
1314
|
+
function loadScripts(project) {
|
|
1315
|
+
const dir = join(SKILL_DIR, "templates", "script");
|
|
1316
|
+
const stylePath = join(dir, "_style.md");
|
|
1317
|
+
if (!existsSync(stylePath)) return null;
|
|
1318
|
+
const style = readFileSync(stylePath, "utf8").trim();
|
|
1319
|
+
const out = {};
|
|
1320
|
+
for (const f of SCRIPT_FORMATS) {
|
|
1321
|
+
const path = join(dir, f.id + ".md");
|
|
1322
|
+
if (!existsSync(path)) continue;
|
|
1323
|
+
out[f.id] = readFileSync(path, "utf8")
|
|
1324
|
+
.replace("__STYLE__", style)
|
|
1325
|
+
.split("__PROJECT__").join(project || "이");
|
|
1326
|
+
}
|
|
1327
|
+
return Object.keys(out).length ? out : null;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/* --- 문서 본문 (1단계 기록 + 2단계 문서) --- */
|
|
1331
|
+
const scripts = loadScripts(config.project);
|
|
1010
1332
|
const searchIndex = [];
|
|
1011
1333
|
const articles = [...discovery, ...design, ...docs]
|
|
1012
1334
|
.map((d) => {
|
|
@@ -1087,7 +1409,15 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
|
|
|
1087
1409
|
escapeHtml(d.meta.updated || d.meta.date || "—") +
|
|
1088
1410
|
'</span><span class="meta-sep"></span><span class="mono">' +
|
|
1089
1411
|
escapeHtml(d.file) +
|
|
1090
|
-
"</span
|
|
1412
|
+
"</span>" +
|
|
1413
|
+
(stage1
|
|
1414
|
+
? ""
|
|
1415
|
+
: '<button class="doc-export" type="button" data-export-file="' +
|
|
1416
|
+
escapeHtml(d.file) +
|
|
1417
|
+
'" data-export-title="' +
|
|
1418
|
+
escapeHtml(d.meta.title || d.id) +
|
|
1419
|
+
'">파일 스크립트</button>') +
|
|
1420
|
+
"</div>" +
|
|
1091
1421
|
html +
|
|
1092
1422
|
definedHtml +
|
|
1093
1423
|
backHtml +
|
|
@@ -1317,9 +1647,15 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
|
|
|
1317
1647
|
'<span class="search-icon">' + ICON.search + "</span>" +
|
|
1318
1648
|
'<input id="search" type="search" placeholder="문서 검색" autocomplete="off" spellcheck="false">' +
|
|
1319
1649
|
'<kbd class="search-kbd">/</kbd></div></div>' +
|
|
1650
|
+
'<div class="nav-tools">' +
|
|
1651
|
+
'<label class="nav-toggle"><input type="checkbox" id="inactiveMode">' +
|
|
1652
|
+
'<span>비활성 관리</span></label>' +
|
|
1653
|
+
'<span class="nav-count" id="inactiveCount"></span>' +
|
|
1654
|
+
"</div>" +
|
|
1320
1655
|
'<div class="nav" id="nav">' +
|
|
1321
1656
|
guideNav +
|
|
1322
1657
|
navLink("__home", "홈", "home") +
|
|
1658
|
+
linksNav +
|
|
1323
1659
|
navLink("__trace", "추적성 매트릭스", "utility") +
|
|
1324
1660
|
discoveryNav +
|
|
1325
1661
|
sidebar +
|
|
@@ -1341,10 +1677,40 @@ function render(docs, config, trace, discovery = [], design = [], mockups = [],
|
|
|
1341
1677
|
'<div class="doc-body"><h1>검색</h1><div id="results"></div></div></div></article>' +
|
|
1342
1678
|
guideArticle +
|
|
1343
1679
|
home +
|
|
1680
|
+
linksArticle +
|
|
1344
1681
|
mockupArticle +
|
|
1345
1682
|
traceArticle +
|
|
1346
1683
|
articles +
|
|
1347
1684
|
"</main>" +
|
|
1685
|
+
(scripts
|
|
1686
|
+
? '<div class="modal" id="exportModal" hidden>' +
|
|
1687
|
+
'<div class="modal-back" data-close-export></div>' +
|
|
1688
|
+
'<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="exportTitle">' +
|
|
1689
|
+
'<div class="modal-head">' +
|
|
1690
|
+
'<div><div class="modal-title" id="exportTitle">파일 스크립트 — 산출물 생성 대본</div>' +
|
|
1691
|
+
'<div class="modal-sub" id="exportSub"></div></div>' +
|
|
1692
|
+
'<button class="icon-btn" type="button" data-close-export aria-label="닫기">\u2715</button>' +
|
|
1693
|
+
"</div>" +
|
|
1694
|
+
'<div class="modal-bar">' +
|
|
1695
|
+
'<label class="modal-label" for="exportFormat">형식</label>' +
|
|
1696
|
+
'<select class="modal-select" id="exportFormat">' +
|
|
1697
|
+
SCRIPT_FORMATS.filter((f) => scripts[f.id])
|
|
1698
|
+
.map((f) => '<option value="' + f.id + '">' + f.label + "</option>")
|
|
1699
|
+
.join("") +
|
|
1700
|
+
"</select>" +
|
|
1701
|
+
'<span class="modal-install" id="exportInstall"></span>' +
|
|
1702
|
+
'<button class="modal-copy" type="button" id="exportCopy">대본 복사</button>' +
|
|
1703
|
+
"</div>" +
|
|
1704
|
+
'<div class="modal-body"><pre class="modal-code" id="exportCode"></pre></div>' +
|
|
1705
|
+
'<div class="modal-foot">복사해서 AI 에게 그대로 붙여 넣으면 됩니다. ' +
|
|
1706
|
+
'대본은 원본 <span class="mono" id="exportFile"></span> 을 가리킬 뿐 내용을 품지 않으므로, ' +
|
|
1707
|
+
"문서를 고친 뒤 같은 대본을 다시 써도 최신 내용으로 만들어집니다." +
|
|
1708
|
+
"</div></div></div>" +
|
|
1709
|
+
'<script id="exportScripts" type="application/json">' +
|
|
1710
|
+
JSON.stringify({ scripts: scripts, formats: SCRIPT_FORMATS })
|
|
1711
|
+
.replace(/</g, "\\u003c") +
|
|
1712
|
+
"<\/script>"
|
|
1713
|
+
: "") +
|
|
1348
1714
|
'<script id="searchIndex" type="application/json">' +
|
|
1349
1715
|
JSON.stringify(searchIndex).replace(/</g, "\\u003c") +
|
|
1350
1716
|
"<\/script>" +
|
|
@@ -1380,6 +1746,82 @@ const CSS = `
|
|
|
1380
1746
|
--s-draft:#979a9b; --s-review:#d9730d; --s-approved:#4dab9a;
|
|
1381
1747
|
--alert-bg:#2d1f1c; --alert-line:#5c332a; --alert-tx:#ff9b8a;
|
|
1382
1748
|
}
|
|
1749
|
+
|
|
1750
|
+
/* ── 파일 스크립트 버튼 · 모달 ─────────────────────────────── */
|
|
1751
|
+
.doc-export{margin-left:auto; font:inherit; font-size:12px; font-weight:500;
|
|
1752
|
+
color:var(--tx-dim); background:transparent; border:1px solid var(--line-strong);
|
|
1753
|
+
border-radius:6px; padding:3px 10px; cursor:pointer; white-space:nowrap;
|
|
1754
|
+
transition:background .12s, color .12s, border-color .12s}
|
|
1755
|
+
.doc-export:hover{background:var(--hover); color:var(--tx); border-color:var(--tx-faint)}
|
|
1756
|
+
.modal[hidden]{display:none}
|
|
1757
|
+
.modal{position:fixed; inset:0; z-index:90; display:flex; align-items:center;
|
|
1758
|
+
justify-content:center; padding:24px}
|
|
1759
|
+
.modal-back{position:absolute; inset:0; background:rgba(15,15,15,.45);
|
|
1760
|
+
backdrop-filter:blur(2px); animation:mfade .16s var(--ease)}
|
|
1761
|
+
.modal-card{position:relative; display:flex; flex-direction:column;
|
|
1762
|
+
width:min(880px,100%); max-height:min(86vh,760px); background:var(--bg);
|
|
1763
|
+
border:1px solid var(--line-strong); border-radius:14px; overflow:hidden;
|
|
1764
|
+
box-shadow:0 24px 64px rgba(15,15,15,.22); animation:mrise .2s var(--ease)}
|
|
1765
|
+
@keyframes mfade{from{opacity:0}}
|
|
1766
|
+
@keyframes mrise{from{opacity:0; transform:translateY(10px) scale(.985)}}
|
|
1767
|
+
.modal-head{display:flex; align-items:flex-start; gap:16px; padding:18px 20px 14px;
|
|
1768
|
+
border-bottom:1px solid var(--line)}
|
|
1769
|
+
.modal-title{font-size:16px; font-weight:600; letter-spacing:-.01em}
|
|
1770
|
+
.modal-sub{font-size:12.5px; color:var(--tx-dim); margin-top:2px}
|
|
1771
|
+
.modal-head .icon-btn{margin-left:auto; flex:none}
|
|
1772
|
+
.modal-bar{display:flex; align-items:center; gap:10px; flex-wrap:wrap;
|
|
1773
|
+
padding:12px 20px; background:var(--bg-side); border-bottom:1px solid var(--line)}
|
|
1774
|
+
.modal-label{font-size:12px; color:var(--tx-dim); font-weight:500}
|
|
1775
|
+
.modal-select{font:inherit; font-size:13px; color:var(--tx); background:var(--bg);
|
|
1776
|
+
border:1px solid var(--line-strong); border-radius:7px; padding:5px 10px; cursor:pointer}
|
|
1777
|
+
.modal-install{font-family:var(--mono,ui-monospace,SFMono-Regular,Menlo,monospace);
|
|
1778
|
+
font-size:11.5px; color:var(--tx-dim); background:var(--bg-code);
|
|
1779
|
+
border-radius:5px; padding:3px 8px}
|
|
1780
|
+
.modal-copy{margin-left:auto; font:inherit; font-size:12.5px; font-weight:600;
|
|
1781
|
+
color:#fff; background:var(--accent); border:0; border-radius:7px;
|
|
1782
|
+
padding:6px 14px; cursor:pointer}
|
|
1783
|
+
.modal-copy:hover{filter:brightness(1.06)}
|
|
1784
|
+
.modal-body{overflow:auto; background:var(--bg-code)}
|
|
1785
|
+
.modal-code{margin:0; padding:18px 20px; font-size:12px; line-height:1.65;
|
|
1786
|
+
white-space:pre; color:var(--tx);
|
|
1787
|
+
font-family:var(--mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
|
|
1788
|
+
.modal-foot{padding:11px 20px; font-size:12px; color:var(--tx-dim);
|
|
1789
|
+
border-top:1px solid var(--line)}
|
|
1790
|
+
@media(max-width:700px){
|
|
1791
|
+
.modal{padding:10px}
|
|
1792
|
+
.modal-copy{margin-left:0}
|
|
1793
|
+
.modal-code{font-size:11px}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/* ── 비활성 메뉴 ─────────────────────────────────────────── */
|
|
1797
|
+
.nav-tools{display:flex; align-items:center; gap:8px; padding:2px 14px 8px}
|
|
1798
|
+
.nav-toggle{display:inline-flex; align-items:center; gap:6px; cursor:pointer;
|
|
1799
|
+
font-size:11.5px; color:var(--tx-faint); user-select:none}
|
|
1800
|
+
.nav-toggle input{width:12px; height:12px; margin:0; cursor:pointer; accent-color:var(--accent)}
|
|
1801
|
+
.nav-toggle:hover{color:var(--tx-dim)}
|
|
1802
|
+
.nav-count{margin-left:auto; font-size:10.5px; color:var(--tx-faint); font-variant-numeric:tabular-nums}
|
|
1803
|
+
/* 한 번도 쓰이지 않은 메뉴 — 노란 점이 아니라 검정 점으로 죽여 둔다 */
|
|
1804
|
+
.nav-item.unused .nav-dot,
|
|
1805
|
+
.nav-group.p-yellow .nav-item.unused .nav-dot,
|
|
1806
|
+
.nav-group.p-red .nav-item.unused .nav-dot,
|
|
1807
|
+
.nav-group.p-orange .nav-item.unused .nav-dot,
|
|
1808
|
+
.nav-group.p-green .nav-item.unused .nav-dot,
|
|
1809
|
+
.nav-group.p-gray .nav-item.unused .nav-dot{
|
|
1810
|
+
background:#000; box-shadow:0 0 0 1px var(--line-strong)}
|
|
1811
|
+
[data-theme=dark] .nav-item.unused .nav-dot,
|
|
1812
|
+
[data-theme=dark] .nav-group .nav-item.unused .nav-dot{
|
|
1813
|
+
background:#000; box-shadow:0 0 0 1px rgba(255,255,255,.22)}
|
|
1814
|
+
.nav-item.unused .nav-label{color:var(--tx-faint)}
|
|
1815
|
+
.nav-item.unused:hover .nav-label{color:var(--tx-dim)}
|
|
1816
|
+
/* 관리 모드 */
|
|
1817
|
+
body.inactive-mode .nav-item{position:relative; padding-left:30px}
|
|
1818
|
+
body.inactive-mode .nav-item .nav-dot{position:absolute; left:18px}
|
|
1819
|
+
.nav-check{position:absolute; left:7px; top:50%; transform:translateY(-50%);
|
|
1820
|
+
width:12px; height:12px; margin:0; cursor:pointer; accent-color:var(--accent);
|
|
1821
|
+
display:none}
|
|
1822
|
+
body.inactive-mode .nav-check{display:block}
|
|
1823
|
+
body.inactive-mode .nav-tools{background:var(--hover); border-radius:7px;
|
|
1824
|
+
margin:0 6px 6px; padding:6px 8px}
|
|
1383
1825
|
html{-webkit-text-size-adjust:100%}
|
|
1384
1826
|
body{
|
|
1385
1827
|
margin:0; background:var(--bg); color:var(--tx);
|
|
@@ -1460,6 +1902,29 @@ a{color:inherit}
|
|
|
1460
1902
|
.nav-group.nav-stage3{padding-bottom:12px; border-bottom:1px solid var(--line)}
|
|
1461
1903
|
.panel-stage3{border-color:var(--stage3)}
|
|
1462
1904
|
.panel-stage3 .panel-title{color:var(--stage3)}
|
|
1905
|
+
.logo{position:relative; width:18px; height:18px; flex:none; border-radius:4px; background:#fff;
|
|
1906
|
+
border:1px solid var(--line-strong); overflow:hidden}
|
|
1907
|
+
.logo i{position:absolute; inset:0; display:grid; place-items:center; font-style:normal;
|
|
1908
|
+
font-size:10px; font-weight:700; color:var(--brand,#555)}
|
|
1909
|
+
.logo img{position:relative; display:block; width:100%; height:100%; padding:3px; object-fit:contain}
|
|
1910
|
+
.logo-add i{color:var(--tx-faint); font-size:13px}
|
|
1911
|
+
.links-grid{display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:10px; margin-top:22px}
|
|
1912
|
+
.link-card{display:flex; flex-direction:column; gap:6px; padding:14px 16px;
|
|
1913
|
+
border:1px solid var(--line-strong); border-radius:8px; background:var(--bg)}
|
|
1914
|
+
.link-card.on{border-color:var(--green)}
|
|
1915
|
+
.link-card.link-add{border-style:dashed}
|
|
1916
|
+
.link-head{display:flex; align-items:center; gap:8px}
|
|
1917
|
+
.link-head .logo{width:22px; height:22px}
|
|
1918
|
+
.link-name{font-weight:600; font-size:14px}
|
|
1919
|
+
.link-head .pill{margin-left:auto}
|
|
1920
|
+
.link-head .pill ~ .pill{margin-left:0}
|
|
1921
|
+
.link-url{font-size:11.5px; color:var(--tx-faint); word-break:break-all}
|
|
1922
|
+
.link-actions{display:flex; gap:6px; margin-top:auto; padding-top:6px}
|
|
1923
|
+
.link-btn{display:inline-flex; align-items:center; justify-content:center; padding:5px 10px;
|
|
1924
|
+
border:1px solid var(--line-strong); border-radius:6px; background:transparent; cursor:pointer;
|
|
1925
|
+
font:inherit; font-size:12px; color:var(--tx-dim); text-decoration:none; white-space:nowrap}
|
|
1926
|
+
.link-btn:hover{background:var(--hover); color:var(--tx)}
|
|
1927
|
+
.link-btn.primary{border-color:var(--accent); color:var(--accent)}
|
|
1463
1928
|
.mockups{display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:10px; margin-top:20px}
|
|
1464
1929
|
.mockup{display:flex; align-items:center; justify-content:space-between; gap:10px;
|
|
1465
1930
|
padding:14px 16px; border:1px solid var(--line-strong); border-radius:8px;
|
|
@@ -1643,6 +2108,7 @@ a.panel-row:hover{color:var(--tx)}
|
|
|
1643
2108
|
.doc-body h1{font-size:30px}
|
|
1644
2109
|
.doc-body h2{font-size:21px}
|
|
1645
2110
|
.cards{grid-template-columns:1fr}
|
|
2111
|
+
.links-grid{grid-template-columns:1fr}
|
|
1646
2112
|
}
|
|
1647
2113
|
@media (prefers-reduced-motion:reduce){ *{transition-duration:.01ms !important; animation-duration:.01ms !important} }
|
|
1648
2114
|
`;
|
|
@@ -1709,6 +2175,199 @@ const JS = `
|
|
|
1709
2175
|
if(el){ e.preventDefault(); el.scrollIntoView({behavior:'smooth', block:'start'}); }
|
|
1710
2176
|
});
|
|
1711
2177
|
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
/* ── 비활성 메뉴 관리 ────────────────────────────
|
|
2181
|
+
* 자동 판정(data-used=0)이 기본이고, 사용자가 체크로 덮어쓴다.
|
|
2182
|
+
* index.html 은 빌드마다 새로 만들어지므로 덮어쓴 값은 문서 id 로
|
|
2183
|
+
* localStorage 에 둔다 — 다시 빌드해도 유지된다. */
|
|
2184
|
+
(function(){
|
|
2185
|
+
var box = document.getElementById('inactiveMode');
|
|
2186
|
+
var nav = document.getElementById('nav');
|
|
2187
|
+
var count = document.getElementById('inactiveCount');
|
|
2188
|
+
if(!box || !nav) return;
|
|
2189
|
+
var KEY = 'inactiveDocs';
|
|
2190
|
+
|
|
2191
|
+
function load(){
|
|
2192
|
+
try { return JSON.parse(localStorage.getItem(KEY)) || {}; }
|
|
2193
|
+
catch(e){ return {}; }
|
|
2194
|
+
}
|
|
2195
|
+
function save(map){
|
|
2196
|
+
try { localStorage.setItem(KEY, JSON.stringify(map)); } catch(e){}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
var overrides = load();
|
|
2200
|
+
var items = [].slice.call(nav.querySelectorAll('.nav-item[data-used]'));
|
|
2201
|
+
|
|
2202
|
+
function isInactive(item){
|
|
2203
|
+
var id = item.getAttribute('data-id');
|
|
2204
|
+
if(Object.prototype.hasOwnProperty.call(overrides, id)) return !!overrides[id];
|
|
2205
|
+
return item.getAttribute('data-used') === '0';
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function paint(){
|
|
2209
|
+
var n = 0;
|
|
2210
|
+
items.forEach(function(item){
|
|
2211
|
+
var off = isInactive(item);
|
|
2212
|
+
item.classList.toggle('unused', off);
|
|
2213
|
+
var cb = item.querySelector('.nav-check');
|
|
2214
|
+
if(cb) cb.checked = off;
|
|
2215
|
+
if(off) n++;
|
|
2216
|
+
});
|
|
2217
|
+
count.textContent = n ? ('비활성 ' + n + ' / ' + items.length) : '';
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// 관리 모드에서 쓸 체크박스를 미리 넣어 둔다 (CSS 로 보였다 숨긴다)
|
|
2221
|
+
items.forEach(function(item){
|
|
2222
|
+
var cb = document.createElement('input');
|
|
2223
|
+
cb.type = 'checkbox';
|
|
2224
|
+
cb.className = 'nav-check';
|
|
2225
|
+
cb.title = '비활성으로 표시';
|
|
2226
|
+
item.insertBefore(cb, item.firstChild);
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
/* 체크박스가 <a> 안에 있어 클릭이 그대로 두면 문서 이동까지 간다.
|
|
2230
|
+
* 문서 캡처 단계에서 먼저 가로채 라우터보다 앞서 끊는다. */
|
|
2231
|
+
document.addEventListener('click', function(e){
|
|
2232
|
+
var cb = e.target;
|
|
2233
|
+
if(!cb.classList || !cb.classList.contains('nav-check')) return;
|
|
2234
|
+
e.preventDefault();
|
|
2235
|
+
e.stopPropagation();
|
|
2236
|
+
if(e.stopImmediatePropagation) e.stopImmediatePropagation();
|
|
2237
|
+
var item = cb.closest('.nav-item');
|
|
2238
|
+
if(!item) return;
|
|
2239
|
+
var id = item.getAttribute('data-id');
|
|
2240
|
+
var next = !isInactive(item);
|
|
2241
|
+
var auto = item.getAttribute('data-used') === '0';
|
|
2242
|
+
// 자동 판정과 같아지면 덮어쓰기를 지운다 — 쓸데없는 저장을 남기지 않는다
|
|
2243
|
+
if(next === auto) delete overrides[id];
|
|
2244
|
+
else overrides[id] = next;
|
|
2245
|
+
save(overrides);
|
|
2246
|
+
paint();
|
|
2247
|
+
}, true);
|
|
2248
|
+
|
|
2249
|
+
box.addEventListener('change', function(){
|
|
2250
|
+
document.body.classList.toggle('inactive-mode', box.checked);
|
|
2251
|
+
try { localStorage.setItem('inactiveMode', box.checked ? '1' : ''); } catch(e){}
|
|
2252
|
+
});
|
|
2253
|
+
try {
|
|
2254
|
+
if(localStorage.getItem('inactiveMode')){
|
|
2255
|
+
box.checked = true;
|
|
2256
|
+
document.body.classList.add('inactive-mode');
|
|
2257
|
+
}
|
|
2258
|
+
} catch(e){}
|
|
2259
|
+
|
|
2260
|
+
paint();
|
|
2261
|
+
})();
|
|
2262
|
+
|
|
2263
|
+
/* ── 파일 스크립트 모달 ──────────────────────────── */
|
|
2264
|
+
(function(){
|
|
2265
|
+
var node = document.getElementById('exportScripts');
|
|
2266
|
+
var modal = document.getElementById('exportModal');
|
|
2267
|
+
if(!node || !modal) return;
|
|
2268
|
+
var data = JSON.parse(node.textContent);
|
|
2269
|
+
var sel = document.getElementById('exportFormat');
|
|
2270
|
+
var code = document.getElementById('exportCode');
|
|
2271
|
+
var install = document.getElementById('exportInstall');
|
|
2272
|
+
var sub = document.getElementById('exportSub');
|
|
2273
|
+
var fileEl = document.getElementById('exportFile');
|
|
2274
|
+
var copyBtn = document.getElementById('exportCopy');
|
|
2275
|
+
var cur = { file: '', title: '' };
|
|
2276
|
+
var lastFocus = null;
|
|
2277
|
+
|
|
2278
|
+
function fmt(id){
|
|
2279
|
+
for(var i=0;i<data.formats.length;i++){ if(data.formats[i].id===id) return data.formats[i]; }
|
|
2280
|
+
return data.formats[0];
|
|
2281
|
+
}
|
|
2282
|
+
function stem(file){ return file.replace(/\.[^.]+$/, ''); }
|
|
2283
|
+
|
|
2284
|
+
function render(){
|
|
2285
|
+
var f = fmt(sel.value);
|
|
2286
|
+
var src = data.scripts[f.id] || '';
|
|
2287
|
+
code.textContent = src
|
|
2288
|
+
.split('__DOC_FILE__').join(cur.file)
|
|
2289
|
+
.split('__DOC_TITLE__').join(cur.title)
|
|
2290
|
+
.split('__DOC_STEM__').join(stem(cur.file));
|
|
2291
|
+
install.textContent = stem(cur.file) + '.' + f.ext;
|
|
2292
|
+
try { localStorage.setItem('exportFormat', f.id); } catch(e){}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function open(btn){
|
|
2296
|
+
cur.file = btn.getAttribute('data-export-file') || '';
|
|
2297
|
+
cur.title = btn.getAttribute('data-export-title') || '';
|
|
2298
|
+
sub.textContent = cur.title;
|
|
2299
|
+
fileEl.textContent = cur.file;
|
|
2300
|
+
try {
|
|
2301
|
+
var saved = localStorage.getItem('exportFormat');
|
|
2302
|
+
if(saved && data.scripts[saved]) sel.value = saved;
|
|
2303
|
+
} catch(e){}
|
|
2304
|
+
render();
|
|
2305
|
+
lastFocus = btn;
|
|
2306
|
+
modal.hidden = false;
|
|
2307
|
+
document.body.style.overflow = 'hidden';
|
|
2308
|
+
sel.focus();
|
|
2309
|
+
}
|
|
2310
|
+
function close(){
|
|
2311
|
+
modal.hidden = true;
|
|
2312
|
+
document.body.style.overflow = '';
|
|
2313
|
+
if(lastFocus) lastFocus.focus();
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
document.addEventListener('click', function(e){
|
|
2317
|
+
var btn = e.target.closest && e.target.closest('[data-export-file]');
|
|
2318
|
+
if(btn){ e.preventDefault(); open(btn); return; }
|
|
2319
|
+
if(e.target.closest && e.target.closest('[data-close-export]')) close();
|
|
2320
|
+
});
|
|
2321
|
+
document.addEventListener('keydown', function(e){
|
|
2322
|
+
if(e.key === 'Escape' && !modal.hidden) close();
|
|
2323
|
+
});
|
|
2324
|
+
sel.addEventListener('change', render);
|
|
2325
|
+
|
|
2326
|
+
copyBtn.addEventListener('click', function(){
|
|
2327
|
+
var text = code.textContent;
|
|
2328
|
+
var label = copyBtn.textContent;
|
|
2329
|
+
function done(){
|
|
2330
|
+
copyBtn.textContent = '복사됨 ✓';
|
|
2331
|
+
setTimeout(function(){ copyBtn.textContent = label; }, 1600);
|
|
2332
|
+
}
|
|
2333
|
+
function fallback(){
|
|
2334
|
+
var ta = document.createElement('textarea');
|
|
2335
|
+
ta.value = text; ta.setAttribute('readonly','');
|
|
2336
|
+
ta.style.position='fixed'; ta.style.opacity='0';
|
|
2337
|
+
document.body.appendChild(ta); ta.select();
|
|
2338
|
+
try { document.execCommand('copy'); done(); }
|
|
2339
|
+
catch(err){ copyBtn.textContent = '복사 실패'; }
|
|
2340
|
+
ta.remove();
|
|
2341
|
+
}
|
|
2342
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
2343
|
+
navigator.clipboard.writeText(text).then(done, fallback);
|
|
2344
|
+
} else { fallback(); }
|
|
2345
|
+
});
|
|
2346
|
+
})();
|
|
2347
|
+
|
|
2348
|
+
/* ── 연동 카드: 문구 복사 ──────────────────────── */
|
|
2349
|
+
document.addEventListener('click', function(e){
|
|
2350
|
+
var b = e.target.closest && e.target.closest('[data-copy]');
|
|
2351
|
+
if(!b) return;
|
|
2352
|
+
var text = b.getAttribute('data-copy');
|
|
2353
|
+
var label = b.textContent;
|
|
2354
|
+
function done(){
|
|
2355
|
+
b.textContent = '복사됨 ✓';
|
|
2356
|
+
setTimeout(function(){ b.textContent = label; }, 1600);
|
|
2357
|
+
}
|
|
2358
|
+
function fallback(){
|
|
2359
|
+
var ta = document.createElement('textarea');
|
|
2360
|
+
ta.value = text; ta.setAttribute('readonly','');
|
|
2361
|
+
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
2362
|
+
document.body.appendChild(ta); ta.select();
|
|
2363
|
+
try { document.execCommand('copy'); done(); } catch(err) { b.textContent = '복사 실패'; }
|
|
2364
|
+
ta.remove();
|
|
2365
|
+
}
|
|
2366
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
2367
|
+
navigator.clipboard.writeText(text).then(done, fallback);
|
|
2368
|
+
} else { fallback(); }
|
|
2369
|
+
});
|
|
2370
|
+
|
|
1712
2371
|
/* ── 전문 검색 ───────────────────────────────────── */
|
|
1713
2372
|
var search = document.getElementById('search');
|
|
1714
2373
|
var results = document.getElementById('results');
|
|
@@ -2034,9 +2693,10 @@ function cmdBuild(docsDir) {
|
|
|
2034
2693
|
const config = loadConfig(docsDir);
|
|
2035
2694
|
const mockups = loadMockups(docsDir);
|
|
2036
2695
|
const styleguide = findStyleguide(docsDir);
|
|
2696
|
+
const links = loadLinks(docsDir, config, docs);
|
|
2037
2697
|
const trace = buildTraceability([...discovery, ...design, ...docs]);
|
|
2038
2698
|
const out = join(docsDir, "index.html");
|
|
2039
|
-
writeFileSync(out, render(docs, config, trace, discovery, design, mockups, styleguide, docsDir, build), "utf8");
|
|
2699
|
+
writeFileSync(out, render(docs, config, trace, discovery, design, mockups, styleguide, docsDir, build, links), "utf8");
|
|
2040
2700
|
console.log(
|
|
2041
2701
|
"생성됨: " +
|
|
2042
2702
|
out +
|