@milcho0604/velog-mcp 0.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.
- package/LICENSE +21 -0
- package/README.ko.md +366 -0
- package/README.md +381 -0
- package/dist/auth.d.ts +57 -0
- package/dist/auth.js +124 -0
- package/dist/auth.js.map +1 -0
- package/dist/capabilities.d.ts +50 -0
- package/dist/capabilities.js +60 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/client.d.ts +113 -0
- package/dist/client.js +322 -0
- package/dist/client.js.map +1 -0
- package/dist/format.d.ts +31 -0
- package/dist/format.js +66 -0
- package/dist/format.js.map +1 -0
- package/dist/graphql.d.ts +29 -0
- package/dist/graphql.js +82 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/me.d.ts +23 -0
- package/dist/me.js +35 -0
- package/dist/me.js.map +1 -0
- package/dist/ownership.d.ts +42 -0
- package/dist/ownership.js +62 -0
- package/dist/ownership.js.map +1 -0
- package/dist/plugin-env.d.ts +67 -0
- package/dist/plugin-env.js +102 -0
- package/dist/plugin-env.js.map +1 -0
- package/dist/ratelimit.d.ts +48 -0
- package/dist/ratelimit.js +79 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/render/chrome.d.ts +77 -0
- package/dist/render/chrome.js +287 -0
- package/dist/render/chrome.js.map +1 -0
- package/dist/render/cover.d.ts +29 -0
- package/dist/render/cover.js +195 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/icons.d.ts +22 -0
- package/dist/render/icons.js +158 -0
- package/dist/render/icons.js.map +1 -0
- package/dist/render/index.d.ts +32 -0
- package/dist/render/index.js +137 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/page.d.ts +89 -0
- package/dist/render/page.js +761 -0
- package/dist/render/page.js.map +1 -0
- package/dist/render/tones.d.ts +30 -0
- package/dist/render/tones.js +46 -0
- package/dist/render/tones.js.map +1 -0
- package/dist/slug.d.ts +42 -0
- package/dist/slug.js +79 -0
- package/dist/slug.js.map +1 -0
- package/dist/tools/discover.d.ts +6 -0
- package/dist/tools/discover.js +106 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/drafts.d.ts +23 -0
- package/dist/tools/drafts.js +227 -0
- package/dist/tools/drafts.js.map +1 -0
- package/dist/tools/export.d.ts +21 -0
- package/dist/tools/export.js +132 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/images.d.ts +34 -0
- package/dist/tools/images.js +556 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/posts.d.ts +14 -0
- package/dist/tools/posts.js +82 -0
- package/dist/tools/posts.js.map +1 -0
- package/dist/tools/profile-edit.d.ts +15 -0
- package/dist/tools/profile-edit.js +216 -0
- package/dist/tools/profile-edit.js.map +1 -0
- package/dist/tools/profile.d.ts +9 -0
- package/dist/tools/profile.js +133 -0
- package/dist/tools/profile.js.map +1 -0
- package/dist/tools/publish.d.ts +16 -0
- package/dist/tools/publish.js +424 -0
- package/dist/tools/publish.js.map +1 -0
- package/dist/tools/stats.d.ts +32 -0
- package/dist/tools/stats.js +154 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/docs/PRD.md +146 -0
- package/docs/api-reference.md +329 -0
- package/docs/architecture.md +112 -0
- package/docs/decisions/0001-why-build-our-own.md +89 -0
- package/docs/decisions/0002-draft-only-write.md +84 -0
- package/docs/decisions/0003-token-env-only.md +109 -0
- package/docs/decisions/0004-capability-model.md +123 -0
- package/docs/decisions/0005-render-in-server.md +117 -0
- package/docs/decisions/0006-ship-as-plugin.md +532 -0
- package/docs/security.md +384 -0
- package/docs/tools.md +404 -0
- package/npm-shrinkwrap.json +2345 -0
- package/package.json +61 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 공개 발행 속도 제한.
|
|
3
|
+
*
|
|
4
|
+
* ★ 적용 대상은 **공개 발행뿐**이다. 초안과 비공개 발행은 카운터를 올리지 않아
|
|
5
|
+
* 상한을 걸지 않는다 (다만 카운터를 올리지 않는 것과 파괴 동작을 유발하지 않는
|
|
6
|
+
* 것은 다르다 — src/capabilities.ts 주석 참고).
|
|
7
|
+
*
|
|
8
|
+
* ★ 이건 예의가 아니라 안전장치다. 벨로그는 최근 5분의 `is_private:false` 글을
|
|
9
|
+
* `is_temp` 구분 없이 세고, 10개를 넘으면 **최근 5분의 글을 전부**
|
|
10
|
+
* `is_private:true` 로 바꾼다 (apps/server/src/services/PostApiService/index.mts).
|
|
11
|
+
* 글은 Prisma 기본값으로 `released_at = now()` 를 받으므로 발행 즉시 계수에 들어간다.
|
|
12
|
+
*
|
|
13
|
+
* 결과: 공개 글을 몰아 올리면 **방금 올린 글들이 한꺼번에 비공개가 된다.**
|
|
14
|
+
* 되돌리려면 사용자가 글마다 공개 설정을 다시 손대야 한다.
|
|
15
|
+
*
|
|
16
|
+
* 그래서 벨로그 한계(10)보다 낮은 선에서 우리가 먼저 멈춘다. 여유를 두는 이유는
|
|
17
|
+
* 사용자가 같은 시간대에 벨로그 웹에서 직접 글을 쓸 수도 있어서다 — 우리 카운터는
|
|
18
|
+
* 그걸 못 본다.
|
|
19
|
+
*/
|
|
20
|
+
/** 벨로그가 파괴적 조치를 취하는 임계. 넘기면 안 된다. */
|
|
21
|
+
export declare const VELOG_DESTRUCTIVE_THRESHOLD = 10;
|
|
22
|
+
/** 우리 상한. 사용자가 웹에서 직접 쓴 글 몫을 남겨둔다. */
|
|
23
|
+
export declare const PUBLIC_PUBLISH_LIMIT = 5;
|
|
24
|
+
/** 벨로그가 보는 창과 같다. */
|
|
25
|
+
export declare const WINDOW_MS: number;
|
|
26
|
+
export declare class PublishRateLimitError extends Error {
|
|
27
|
+
readonly retryAfterMs: number;
|
|
28
|
+
constructor(retryAfterMs: number);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 슬라이딩 윈도우 카운터. 프로세스 메모리에만 있다.
|
|
32
|
+
*
|
|
33
|
+
* 한계를 분명히 해둔다 — 이 카운터는 **이 서버가 발행한 것만** 센다.
|
|
34
|
+
* 사용자가 벨로그 웹에서 직접 쓴 글은 안 보인다. 그래서 상한을 10이 아니라
|
|
35
|
+
* 5로 잡아 여유를 남긴다.
|
|
36
|
+
*/
|
|
37
|
+
export declare class PublishRateLimiter {
|
|
38
|
+
#private;
|
|
39
|
+
constructor(options?: {
|
|
40
|
+
limit?: number;
|
|
41
|
+
windowMs?: number;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
});
|
|
44
|
+
/** 한도를 넘었으면 던진다. 통과하면 이번 생성을 기록한다. */
|
|
45
|
+
check(): void;
|
|
46
|
+
/** 창 안의 현재 건수. */
|
|
47
|
+
get count(): number;
|
|
48
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 공개 발행 속도 제한.
|
|
3
|
+
*
|
|
4
|
+
* ★ 적용 대상은 **공개 발행뿐**이다. 초안과 비공개 발행은 카운터를 올리지 않아
|
|
5
|
+
* 상한을 걸지 않는다 (다만 카운터를 올리지 않는 것과 파괴 동작을 유발하지 않는
|
|
6
|
+
* 것은 다르다 — src/capabilities.ts 주석 참고).
|
|
7
|
+
*
|
|
8
|
+
* ★ 이건 예의가 아니라 안전장치다. 벨로그는 최근 5분의 `is_private:false` 글을
|
|
9
|
+
* `is_temp` 구분 없이 세고, 10개를 넘으면 **최근 5분의 글을 전부**
|
|
10
|
+
* `is_private:true` 로 바꾼다 (apps/server/src/services/PostApiService/index.mts).
|
|
11
|
+
* 글은 Prisma 기본값으로 `released_at = now()` 를 받으므로 발행 즉시 계수에 들어간다.
|
|
12
|
+
*
|
|
13
|
+
* 결과: 공개 글을 몰아 올리면 **방금 올린 글들이 한꺼번에 비공개가 된다.**
|
|
14
|
+
* 되돌리려면 사용자가 글마다 공개 설정을 다시 손대야 한다.
|
|
15
|
+
*
|
|
16
|
+
* 그래서 벨로그 한계(10)보다 낮은 선에서 우리가 먼저 멈춘다. 여유를 두는 이유는
|
|
17
|
+
* 사용자가 같은 시간대에 벨로그 웹에서 직접 글을 쓸 수도 있어서다 — 우리 카운터는
|
|
18
|
+
* 그걸 못 본다.
|
|
19
|
+
*/
|
|
20
|
+
/** 벨로그가 파괴적 조치를 취하는 임계. 넘기면 안 된다. */
|
|
21
|
+
export const VELOG_DESTRUCTIVE_THRESHOLD = 10;
|
|
22
|
+
/** 우리 상한. 사용자가 웹에서 직접 쓴 글 몫을 남겨둔다. */
|
|
23
|
+
export const PUBLIC_PUBLISH_LIMIT = 5;
|
|
24
|
+
/** 벨로그가 보는 창과 같다. */
|
|
25
|
+
export const WINDOW_MS = 5 * 60 * 1000;
|
|
26
|
+
export class PublishRateLimitError extends Error {
|
|
27
|
+
retryAfterMs;
|
|
28
|
+
constructor(retryAfterMs) {
|
|
29
|
+
const seconds = Math.ceil(retryAfterMs / 1000);
|
|
30
|
+
super(`공개 발행을 잠시 멈춥니다. 최근 5분에 이미 ${PUBLIC_PUBLISH_LIMIT}건을 발행했습니다.\n\n` +
|
|
31
|
+
`이유: 벨로그는 최근 5분의 공개 글이 ${VELOG_DESTRUCTIVE_THRESHOLD}건을 넘으면 ` +
|
|
32
|
+
'그 시간대 글을 **전부 비공개로 바꿉니다**. 계속하면 방금 올리신 글들이 ' +
|
|
33
|
+
'한꺼번에 비공개가 될 수 있습니다.\n\n' +
|
|
34
|
+
`약 ${seconds}초 뒤에 다시 시도하세요. ` +
|
|
35
|
+
'급하지 않다면 비공개로 발행해두고 나중에 공개로 바꾸는 편이 안전합니다.');
|
|
36
|
+
this.name = 'PublishRateLimitError';
|
|
37
|
+
this.retryAfterMs = retryAfterMs;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 슬라이딩 윈도우 카운터. 프로세스 메모리에만 있다.
|
|
42
|
+
*
|
|
43
|
+
* 한계를 분명히 해둔다 — 이 카운터는 **이 서버가 발행한 것만** 센다.
|
|
44
|
+
* 사용자가 벨로그 웹에서 직접 쓴 글은 안 보인다. 그래서 상한을 10이 아니라
|
|
45
|
+
* 5로 잡아 여유를 남긴다.
|
|
46
|
+
*/
|
|
47
|
+
export class PublishRateLimiter {
|
|
48
|
+
#timestamps = [];
|
|
49
|
+
#limit;
|
|
50
|
+
#windowMs;
|
|
51
|
+
#now;
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
this.#limit = options.limit ?? PUBLIC_PUBLISH_LIMIT;
|
|
54
|
+
this.#windowMs = options.windowMs ?? WINDOW_MS;
|
|
55
|
+
this.#now = options.now ?? (() => Date.now());
|
|
56
|
+
}
|
|
57
|
+
/** 한도를 넘었으면 던진다. 통과하면 이번 생성을 기록한다. */
|
|
58
|
+
check() {
|
|
59
|
+
const now = this.#now();
|
|
60
|
+
const cutoff = now - this.#windowMs;
|
|
61
|
+
// 창 밖으로 나간 기록을 버린다. shift() 의 반환으로 판정하면
|
|
62
|
+
// non-null 단언 없이 첫 원소를 다룰 수 있다.
|
|
63
|
+
for (let head = this.#timestamps[0]; head !== undefined && head <= cutoff;) {
|
|
64
|
+
this.#timestamps.shift();
|
|
65
|
+
head = this.#timestamps[0];
|
|
66
|
+
}
|
|
67
|
+
const oldest = this.#timestamps[0];
|
|
68
|
+
if (oldest !== undefined && this.#timestamps.length >= this.#limit) {
|
|
69
|
+
throw new PublishRateLimitError(oldest + this.#windowMs - now);
|
|
70
|
+
}
|
|
71
|
+
this.#timestamps.push(now);
|
|
72
|
+
}
|
|
73
|
+
/** 창 안의 현재 건수. */
|
|
74
|
+
get count() {
|
|
75
|
+
const cutoff = this.#now() - this.#windowMs;
|
|
76
|
+
return this.#timestamps.filter((t) => t > cutoff).length;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=ratelimit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ratelimit.js","sourceRoot":"","sources":["../src/ratelimit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,qCAAqC;AACrC,MAAM,CAAC,MAAM,2BAA2B,GAAG,EAAE,CAAC;AAE9C,sCAAsC;AACtC,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC;AAEtC,qBAAqB;AACrB,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvC,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IACtC,YAAY,CAAS;IAE9B,YAAY,YAAoB;QAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;QAC/C,KAAK,CACJ,6BAA6B,oBAAoB,gBAAgB;YAChE,yBAAyB,2BAA2B,SAAS;YAC7D,6CAA6C;YAC7C,yBAAyB;YACzB,KAAK,OAAO,iBAAiB;YAC7B,0CAA0C,CAC3C,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;IAClC,CAAC;CACD;AAED;;;;;;GAMG;AACH,MAAM,OAAO,kBAAkB;IACrB,WAAW,GAAa,EAAE,CAAC;IAC3B,MAAM,CAAS;IACf,SAAS,CAAS;IAClB,IAAI,CAAe;IAE5B,YACC,UAAqE,EAAE;QAEvE,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,KAAK,IAAI,oBAAoB,CAAC;QACpD,IAAI,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,IAAI,SAAS,CAAC;QAC/C,IAAI,CAAC,IAAI,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,sCAAsC;IACtC,KAAK;QACJ,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;QACxB,MAAM,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC;QACpC,wCAAwC;QACxC,gCAAgC;QAChC,KAAK,IAAI,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,IAAI,KAAK,SAAS,IAAI,IAAI,IAAI,MAAM,GAAI,CAAC;YAC7E,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,CAAC;YACzB,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QAC5B,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;QACnC,IAAI,MAAM,KAAK,SAAS,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACpE,MAAM,IAAI,qBAAqB,CAAC,MAAM,GAAG,IAAI,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC;QAChE,CAAC;QACD,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC5B,CAAC;IAED,kBAAkB;IAClB,IAAI,KAAK;QACR,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC;QAC5C,OAAO,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,CAAC;IAC1D,CAAC;CACD"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 헤드리스 크롬 실행기.
|
|
3
|
+
*
|
|
4
|
+
* ★ 왜 puppeteer 를 안 쓰나
|
|
5
|
+
* 이 저장소는 런타임 의존성을 2개(MCP SDK, zod)로 묶어두고 그걸 테스트(A6)로
|
|
6
|
+
* 강제한다. puppeteer 는 크로미움을 통째로 내려받아 설치 용량이 수백 MB 늘고,
|
|
7
|
+
* MCP 서버는 사용자 기기에서 도는 물건이라 그 비용이 그대로 사용자에게 간다.
|
|
8
|
+
* 우리가 필요한 건 '스크린샷 한 장'뿐이라 이미 깔린 크롬을 CLI 로 부른다.
|
|
9
|
+
*
|
|
10
|
+
* ★ 렌더 페이지가 밖으로 나갈 수 없는 이유 — 순서가 중요하다
|
|
11
|
+
* **1차 방어는 '나갈 구멍이 없다'는 것이다.** 페이지에는 URL 을 받는 자리가 하나도
|
|
12
|
+
* 없다 — 아이콘은 내장 도형이고, `href`·`src`·`fetch` 를 쓰지 않으며, `url(...)` 은
|
|
13
|
+
* 문서 안 marker 참조(`url(#arr-…)`) 뿐이다. 이건 테스트가 강제한다.
|
|
14
|
+
* **2차 방어가 `--host-resolver-rules=MAP * ~NOTFOUND` 다.** 나중에 누가 실수로
|
|
15
|
+
* 원격 리소스를 넣어도 막힌다.
|
|
16
|
+
* 실측(2026-07-31): 로컬 서버를 표적으로 두고 재보니 플래그 없이는 요청이 서버에
|
|
17
|
+
* 도달(1건)하고 플래그를 주면 도달하지 않는다(0건). 표적이 `127.0.0.1` 이므로
|
|
18
|
+
* **IP 를 직접 적은 주소도 막힌다** — 한때 "이름 풀이만 막으니 IP 는 통과한다"고
|
|
19
|
+
* 적었는데 그건 틀렸다. 다만 `file:`·`data:`·`blob:` 처럼 이름 풀이를 안 쓰는
|
|
20
|
+
* 스킴은 이 플래그의 대상이 아니다. 그러니 순서는 그대로다 — 1차가 본질이다.
|
|
21
|
+
*
|
|
22
|
+
* ★ 사용자의 크롬 프로필을 건드리지 않는다
|
|
23
|
+
* `--user-data-dir` 을 임시 폴더로 지정한다. 지정하지 않으면 기본 프로필을
|
|
24
|
+
* 열려다 이미 떠 있는 크롬과 충돌하고, 로그인 세션이 있는 프로필에서 렌더하게 된다.
|
|
25
|
+
*
|
|
26
|
+
* ★★ 서버가 죽으면 크롬도 같이 죽인다
|
|
27
|
+
* 실제로 고아를 만들었다. MCP 클라이언트가 60초 타임아웃으로 연결을 끊자 서버
|
|
28
|
+
* 프로세스가 죽었는데, 그때 렌더 중이던 크롬이 PPID=1 로 살아남아 23분을 돌고 있었다
|
|
29
|
+
* (`ps` 로 확인). POSIX 에서 부모가 죽어도 자식은 안 죽는다 — 아무도 안 죽인다.
|
|
30
|
+
* 그래서 띄운 크롬을 들고 있다가 프로세스 종료 시 정리한다.
|
|
31
|
+
* ⚠️ 서버 자신이 SIGKILL 당하면 이 정리도 못 돈다. 그 경우는 남은 프로필 폴더를
|
|
32
|
+
* 다음 렌더의 청소(render/index.ts)가 하루 뒤 거둬간다.
|
|
33
|
+
*
|
|
34
|
+
* ★★ 크롬이 끝나기를 기다리지 않는다 — 안 끝나기 때문이다
|
|
35
|
+
* Chrome 150 의 새 헤드리스는 `--dump-dom` 결과를 **1.7초 만에 stdout 으로 내놓고도
|
|
36
|
+
* 프로세스를 유지한다** (실측: 30초를 더 기다려도 종료 안 함). 종료를 기다리는
|
|
37
|
+
* 구현이었을 때 그림 한 장에 90초가 걸렸다 — 실제 작업은 2초인데 나머지는 전부
|
|
38
|
+
* 기다림이었다.
|
|
39
|
+
* 그래서 **산출물이 완성됐는지 직접 보고** 확인되면 프로세스를 끝낸다.
|
|
40
|
+
* PNG 는 크기가 아니라 마지막 IEND 청크로 판정한다. 크기 비교는 쓰다 만 파일을
|
|
41
|
+
* 완성으로 오인할 수 있다.
|
|
42
|
+
*/
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
export declare class ChromeNotFoundError extends Error {
|
|
45
|
+
constructor();
|
|
46
|
+
}
|
|
47
|
+
/** 크롬 실행 파일을 찾는다. 결과는 프로세스 수명 동안 재사용한다. */
|
|
48
|
+
export declare function findChrome(): Promise<string>;
|
|
49
|
+
/** 테스트에서 탐색 결과를 되돌리기 위한 것. 운영 경로에서는 부르지 않는다. */
|
|
50
|
+
export declare function resetChromeCache(): void;
|
|
51
|
+
interface RunOptions {
|
|
52
|
+
readonly profileDir: string;
|
|
53
|
+
readonly timeoutMs?: number;
|
|
54
|
+
/**
|
|
55
|
+
* 프로세스 생성기. **테스트에서만** 바꾼다.
|
|
56
|
+
*
|
|
57
|
+
* 실제 크롬으로는 '결과가 늦게 도착하는 종료' 같은 타이밍을 안정적으로 만들 수
|
|
58
|
+
* 없어서, 그 불변식(‘exit’ 이 아니라 ‘close’ 를 본다)이 오래 안 묶여 있었다.
|
|
59
|
+
* 가짜 자식 프로세스를 넣으면 벽시계 대기 없이 정확히 재현된다.
|
|
60
|
+
*/
|
|
61
|
+
readonly spawnImpl?: typeof spawn;
|
|
62
|
+
/** 테스트에서 작은 값으로 낮춰 stdout 상한 동작을 확인한다. */
|
|
63
|
+
readonly maxStdoutBytes?: number;
|
|
64
|
+
}
|
|
65
|
+
/** 산출물이 다 나왔는지 판정한다. true 가 되는 순간 크롬을 끝낸다. */
|
|
66
|
+
type Ready = (stdout: string) => boolean | Promise<boolean>;
|
|
67
|
+
export declare function runForTest(chrome: string, args: readonly string[], options: RunOptions, ready: Ready): Promise<string>;
|
|
68
|
+
/** 스크립트가 다 돈 뒤의 DOM 을 문자열로 받는다. 자가감사 결과를 읽는 데 쓴다. */
|
|
69
|
+
export declare function dumpDom(fileUrl: string, options: RunOptions): Promise<string>;
|
|
70
|
+
export interface ShotOptions extends RunOptions {
|
|
71
|
+
readonly width: number;
|
|
72
|
+
readonly height: number;
|
|
73
|
+
/** 2 = 레티나 배율. 블로그에 올릴 그림은 2배로 뽑아야 글자가 안 흐리다. */
|
|
74
|
+
readonly scale?: number;
|
|
75
|
+
}
|
|
76
|
+
export declare function screenshot(fileUrl: string, outPath: string, options: ShotOptions): Promise<void>;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 헤드리스 크롬 실행기.
|
|
3
|
+
*
|
|
4
|
+
* ★ 왜 puppeteer 를 안 쓰나
|
|
5
|
+
* 이 저장소는 런타임 의존성을 2개(MCP SDK, zod)로 묶어두고 그걸 테스트(A6)로
|
|
6
|
+
* 강제한다. puppeteer 는 크로미움을 통째로 내려받아 설치 용량이 수백 MB 늘고,
|
|
7
|
+
* MCP 서버는 사용자 기기에서 도는 물건이라 그 비용이 그대로 사용자에게 간다.
|
|
8
|
+
* 우리가 필요한 건 '스크린샷 한 장'뿐이라 이미 깔린 크롬을 CLI 로 부른다.
|
|
9
|
+
*
|
|
10
|
+
* ★ 렌더 페이지가 밖으로 나갈 수 없는 이유 — 순서가 중요하다
|
|
11
|
+
* **1차 방어는 '나갈 구멍이 없다'는 것이다.** 페이지에는 URL 을 받는 자리가 하나도
|
|
12
|
+
* 없다 — 아이콘은 내장 도형이고, `href`·`src`·`fetch` 를 쓰지 않으며, `url(...)` 은
|
|
13
|
+
* 문서 안 marker 참조(`url(#arr-…)`) 뿐이다. 이건 테스트가 강제한다.
|
|
14
|
+
* **2차 방어가 `--host-resolver-rules=MAP * ~NOTFOUND` 다.** 나중에 누가 실수로
|
|
15
|
+
* 원격 리소스를 넣어도 막힌다.
|
|
16
|
+
* 실측(2026-07-31): 로컬 서버를 표적으로 두고 재보니 플래그 없이는 요청이 서버에
|
|
17
|
+
* 도달(1건)하고 플래그를 주면 도달하지 않는다(0건). 표적이 `127.0.0.1` 이므로
|
|
18
|
+
* **IP 를 직접 적은 주소도 막힌다** — 한때 "이름 풀이만 막으니 IP 는 통과한다"고
|
|
19
|
+
* 적었는데 그건 틀렸다. 다만 `file:`·`data:`·`blob:` 처럼 이름 풀이를 안 쓰는
|
|
20
|
+
* 스킴은 이 플래그의 대상이 아니다. 그러니 순서는 그대로다 — 1차가 본질이다.
|
|
21
|
+
*
|
|
22
|
+
* ★ 사용자의 크롬 프로필을 건드리지 않는다
|
|
23
|
+
* `--user-data-dir` 을 임시 폴더로 지정한다. 지정하지 않으면 기본 프로필을
|
|
24
|
+
* 열려다 이미 떠 있는 크롬과 충돌하고, 로그인 세션이 있는 프로필에서 렌더하게 된다.
|
|
25
|
+
*
|
|
26
|
+
* ★★ 서버가 죽으면 크롬도 같이 죽인다
|
|
27
|
+
* 실제로 고아를 만들었다. MCP 클라이언트가 60초 타임아웃으로 연결을 끊자 서버
|
|
28
|
+
* 프로세스가 죽었는데, 그때 렌더 중이던 크롬이 PPID=1 로 살아남아 23분을 돌고 있었다
|
|
29
|
+
* (`ps` 로 확인). POSIX 에서 부모가 죽어도 자식은 안 죽는다 — 아무도 안 죽인다.
|
|
30
|
+
* 그래서 띄운 크롬을 들고 있다가 프로세스 종료 시 정리한다.
|
|
31
|
+
* ⚠️ 서버 자신이 SIGKILL 당하면 이 정리도 못 돈다. 그 경우는 남은 프로필 폴더를
|
|
32
|
+
* 다음 렌더의 청소(render/index.ts)가 하루 뒤 거둬간다.
|
|
33
|
+
*
|
|
34
|
+
* ★★ 크롬이 끝나기를 기다리지 않는다 — 안 끝나기 때문이다
|
|
35
|
+
* Chrome 150 의 새 헤드리스는 `--dump-dom` 결과를 **1.7초 만에 stdout 으로 내놓고도
|
|
36
|
+
* 프로세스를 유지한다** (실측: 30초를 더 기다려도 종료 안 함). 종료를 기다리는
|
|
37
|
+
* 구현이었을 때 그림 한 장에 90초가 걸렸다 — 실제 작업은 2초인데 나머지는 전부
|
|
38
|
+
* 기다림이었다.
|
|
39
|
+
* 그래서 **산출물이 완성됐는지 직접 보고** 확인되면 프로세스를 끝낸다.
|
|
40
|
+
* PNG 는 크기가 아니라 마지막 IEND 청크로 판정한다. 크기 비교는 쓰다 만 파일을
|
|
41
|
+
* 완성으로 오인할 수 있다.
|
|
42
|
+
*/
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
import { access, constants, readFile, stat } from 'node:fs/promises';
|
|
45
|
+
import { platform } from 'node:process';
|
|
46
|
+
const CANDIDATES = {
|
|
47
|
+
darwin: [
|
|
48
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
49
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
50
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
51
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
52
|
+
],
|
|
53
|
+
linux: [
|
|
54
|
+
'/usr/bin/google-chrome',
|
|
55
|
+
'/usr/bin/google-chrome-stable',
|
|
56
|
+
'/usr/bin/chromium',
|
|
57
|
+
'/usr/bin/chromium-browser',
|
|
58
|
+
'/usr/bin/microsoft-edge',
|
|
59
|
+
'/snap/bin/chromium',
|
|
60
|
+
],
|
|
61
|
+
win32: [
|
|
62
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
63
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
64
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
export class ChromeNotFoundError extends Error {
|
|
68
|
+
constructor() {
|
|
69
|
+
super('그림을 그리려면 크롬(또는 크로미움 계열 브라우저)이 필요한데 찾지 못했습니다.\n' +
|
|
70
|
+
'설치돼 있다면 VELOG_CHROME_PATH 환경변수에 실행 파일 경로를 지정하세요.\n' +
|
|
71
|
+
'예) VELOG_CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"');
|
|
72
|
+
this.name = 'ChromeNotFoundError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let cached;
|
|
76
|
+
/**
|
|
77
|
+
* 실행할 수 있는 **일반 파일**인지 본다.
|
|
78
|
+
*
|
|
79
|
+
* ★ `access(X_OK)` 만으로는 부족하다 — 디렉터리도 통과한다
|
|
80
|
+
* POSIX 에서 디렉터리의 `X_OK` 는 '실행 가능'이 아니라 '탐색 가능'이라는 뜻이다.
|
|
81
|
+
* 실측: `/Applications/Google Chrome.app` 은 `X_OK` 를 통과하지만 `isFile()` 은
|
|
82
|
+
* false 다. 플러그인 설정의 크롬 경로가 `file` 타입이라 macOS 파일 선택기가
|
|
83
|
+
* 앱 번들 자체를 돌려주면 딱 이 경우가 된다 — 기동 로그는 "사용 가능"이라
|
|
84
|
+
* 말해놓고 정작 그리려 할 때 `spawn` 이 실패한다.
|
|
85
|
+
*/
|
|
86
|
+
async function isExecutableFile(path) {
|
|
87
|
+
const info = await stat(path).catch(() => null);
|
|
88
|
+
if (!info?.isFile())
|
|
89
|
+
return false;
|
|
90
|
+
return await access(path, constants.X_OK).then(() => true, () => false);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* macOS 앱 번들을 지정했으면 안쪽 실행 파일로 바꿔준다.
|
|
94
|
+
*
|
|
95
|
+
* 번들을 고르는 건 사용자 실수가 아니라 파일 선택기의 자연스러운 결과다.
|
|
96
|
+
* 고칠 수 있는 실수는 고쳐주고, 못 고치면 그때 분명히 말한다.
|
|
97
|
+
*/
|
|
98
|
+
async function resolveAppBundle(path) {
|
|
99
|
+
if (!path.endsWith('.app'))
|
|
100
|
+
return undefined;
|
|
101
|
+
const name = path.slice(path.lastIndexOf('/') + 1, -'.app'.length);
|
|
102
|
+
const inner = `${path}/Contents/MacOS/${name}`;
|
|
103
|
+
return (await isExecutableFile(inner)) ? inner : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** 크롬 실행 파일을 찾는다. 결과는 프로세스 수명 동안 재사용한다. */
|
|
106
|
+
export async function findChrome() {
|
|
107
|
+
if (cached)
|
|
108
|
+
return cached;
|
|
109
|
+
const override = process.env['VELOG_CHROME_PATH'];
|
|
110
|
+
if (override) {
|
|
111
|
+
// 지정했는데 못 쓰면 조용히 다른 걸 쓰지 않는다 — 의도한 브라우저로 그려야 한다.
|
|
112
|
+
const resolved = (await isExecutableFile(override))
|
|
113
|
+
? override
|
|
114
|
+
: await resolveAppBundle(override);
|
|
115
|
+
if (!resolved) {
|
|
116
|
+
throw new Error(`VELOG_CHROME_PATH 로 지정한 파일을 실행할 수 없습니다: ${override}\n` +
|
|
117
|
+
'실행 파일 자체를 지정해야 합니다. macOS 앱 번들(.app)을 골랐다면 그 안쪽 경로입니다.\n' +
|
|
118
|
+
'예) /Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
|
119
|
+
}
|
|
120
|
+
cached = resolved;
|
|
121
|
+
return resolved;
|
|
122
|
+
}
|
|
123
|
+
for (const path of CANDIDATES[platform] ?? []) {
|
|
124
|
+
if (await isExecutableFile(path)) {
|
|
125
|
+
cached = path;
|
|
126
|
+
return path;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new ChromeNotFoundError();
|
|
130
|
+
}
|
|
131
|
+
/** 테스트에서 탐색 결과를 되돌리기 위한 것. 운영 경로에서는 부르지 않는다. */
|
|
132
|
+
export function resetChromeCache() {
|
|
133
|
+
cached = undefined;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 지금 살아 있는 크롬들. 서버가 내려갈 때 같이 정리하려고 들고 있는다.
|
|
137
|
+
* 훅은 실제로 하나라도 띄운 뒤에만 건다 — 아무것도 안 그리는 프로세스(테스트 등)의
|
|
138
|
+
* 시그널 동작을 바꾸지 않기 위해서다.
|
|
139
|
+
*/
|
|
140
|
+
const live = new Set();
|
|
141
|
+
let hooked = false;
|
|
142
|
+
function killAll() {
|
|
143
|
+
for (const child of live) {
|
|
144
|
+
try {
|
|
145
|
+
child.kill('SIGKILL');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// 이미 죽었으면 그만이다.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
live.clear();
|
|
152
|
+
}
|
|
153
|
+
function hookProcessExit() {
|
|
154
|
+
if (hooked)
|
|
155
|
+
return;
|
|
156
|
+
hooked = true;
|
|
157
|
+
// exit 핸들러에서는 동기 작업만 된다. kill 은 동기라 여기서 충분하다.
|
|
158
|
+
process.on('exit', killAll);
|
|
159
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
160
|
+
process.on(signal, () => {
|
|
161
|
+
killAll();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const BASE_FLAGS = [
|
|
167
|
+
'--headless',
|
|
168
|
+
'--disable-gpu',
|
|
169
|
+
'--no-first-run',
|
|
170
|
+
'--no-default-browser-check',
|
|
171
|
+
'--disable-extensions',
|
|
172
|
+
'--disable-background-networking',
|
|
173
|
+
'--disable-component-update',
|
|
174
|
+
'--disable-sync',
|
|
175
|
+
'--disable-default-apps',
|
|
176
|
+
'--mute-audio',
|
|
177
|
+
'--hide-scrollbars',
|
|
178
|
+
// 네트워크 원천 차단. 이 페이지는 외부를 볼 일이 없다.
|
|
179
|
+
'--host-resolver-rules=MAP * ~NOTFOUND',
|
|
180
|
+
];
|
|
181
|
+
function tail(text, lines = 4) {
|
|
182
|
+
return text.trim().split('\n').slice(-lines).join('\n');
|
|
183
|
+
}
|
|
184
|
+
export function runForTest(chrome, args, options, ready) {
|
|
185
|
+
return run(chrome, args, options, ready);
|
|
186
|
+
}
|
|
187
|
+
function run(chrome, args, options, ready) {
|
|
188
|
+
const full = [...BASE_FLAGS, `--user-data-dir=${options.profileDir}`, ...args];
|
|
189
|
+
const limit = options.timeoutMs ?? 30_000;
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
hookProcessExit();
|
|
192
|
+
const child = (options.spawnImpl ?? spawn)(chrome, full, {
|
|
193
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
194
|
+
});
|
|
195
|
+
live.add(child);
|
|
196
|
+
let out = '';
|
|
197
|
+
let err = '';
|
|
198
|
+
let settled = false;
|
|
199
|
+
// ★ stdout 을 무제한으로 모으면 페이지가 큰 문자열을 뱉을 때 그대로 따라 커진다.
|
|
200
|
+
// DOM 은 우리가 만든 것이라 정상이면 수백 KB 다. 상한을 넘으면 비정상이다.
|
|
201
|
+
// ★ 붙이기 **전에** 검사하면 마지막 청크는 상한을 넘긴 채로 들어간다.
|
|
202
|
+
// 그리고 문자열 길이는 UTF-16 단위라 바이트가 아니다. 바이트로 세고,
|
|
203
|
+
// 더한 뒤에 판정한다.
|
|
204
|
+
const MAX_STDOUT = options.maxStdoutBytes ?? 32 * 1024 * 1024;
|
|
205
|
+
let outBytes = 0;
|
|
206
|
+
child.stdout.on('data', (chunk) => {
|
|
207
|
+
outBytes += chunk.length;
|
|
208
|
+
if (outBytes > MAX_STDOUT) {
|
|
209
|
+
finish(() => {
|
|
210
|
+
reject(new Error(`크롬 출력이 ${MAX_STDOUT / 1024 / 1024}MB 를 넘었습니다 — 입력이 비정상입니다.`));
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
out += chunk.toString('utf8');
|
|
215
|
+
});
|
|
216
|
+
child.stderr.on('data', (chunk) => {
|
|
217
|
+
// ★ 크롬은 정상 동작 중에도 stderr 에 경고를 쏟는다. 실패 판정에 쓰지 않고
|
|
218
|
+
// 오류 메시지에 붙일 꼬리만 남긴다. 무한정 모으지 않는다.
|
|
219
|
+
if (err.length < 8000)
|
|
220
|
+
err += chunk.toString('utf8');
|
|
221
|
+
});
|
|
222
|
+
const finish = (action) => {
|
|
223
|
+
if (settled)
|
|
224
|
+
return;
|
|
225
|
+
settled = true;
|
|
226
|
+
clearInterval(poll);
|
|
227
|
+
clearTimeout(deadline);
|
|
228
|
+
live.delete(child);
|
|
229
|
+
child.kill('SIGKILL');
|
|
230
|
+
action();
|
|
231
|
+
};
|
|
232
|
+
const check = (onNotReady) => {
|
|
233
|
+
void (async () => {
|
|
234
|
+
const ok = await Promise.resolve(ready(out)).catch(() => false);
|
|
235
|
+
if (ok)
|
|
236
|
+
finish(() => { resolve(out); });
|
|
237
|
+
else
|
|
238
|
+
onNotReady?.();
|
|
239
|
+
})();
|
|
240
|
+
};
|
|
241
|
+
const poll = setInterval(() => { check(); }, 120);
|
|
242
|
+
const deadline = setTimeout(() => {
|
|
243
|
+
finish(() => {
|
|
244
|
+
reject(new Error(`크롬이 ${limit}ms 안에 결과를 내지 못했습니다.\n${tail(err)}`));
|
|
245
|
+
});
|
|
246
|
+
}, limit);
|
|
247
|
+
child.on('error', (cause) => {
|
|
248
|
+
finish(() => { reject(new Error(`크롬을 실행하지 못했습니다: ${cause.message}`)); });
|
|
249
|
+
});
|
|
250
|
+
// ★ 'exit' 이 아니라 'close' 를 본다. Node 는 'exit' 시점에 stdio 가 아직
|
|
251
|
+
// 열려 있을 수 있다고 명시한다 — 마지막 </html> 이 그 뒤에 도착하면
|
|
252
|
+
// 멀쩡한 결과를 실패로 처리하게 된다. 'close' 는 stdio 까지 닫힌 뒤다.
|
|
253
|
+
child.on('close', (code) => {
|
|
254
|
+
// 스스로 끝난 경우. 산출물이 있으면 성공, 없으면 진짜 실패다.
|
|
255
|
+
check(() => {
|
|
256
|
+
finish(() => {
|
|
257
|
+
reject(new Error(`크롬이 결과 없이 종료했습니다 (code ${String(code)})\n${tail(err)}`));
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** PNG 가 끝까지 쓰였는지 — 마지막 IEND 청크로 확인한다. */
|
|
264
|
+
async function pngComplete(path) {
|
|
265
|
+
const buf = await readFile(path).catch(() => null);
|
|
266
|
+
if (!buf || buf.length < 16)
|
|
267
|
+
return false;
|
|
268
|
+
const end = buf.subarray(buf.length - 8);
|
|
269
|
+
return (end[0] === 0x49 && end[1] === 0x45 && end[2] === 0x4e && end[3] === 0x44 &&
|
|
270
|
+
end[4] === 0xae && end[5] === 0x42 && end[6] === 0x60 && end[7] === 0x82);
|
|
271
|
+
}
|
|
272
|
+
/** 스크립트가 다 돈 뒤의 DOM 을 문자열로 받는다. 자가감사 결과를 읽는 데 쓴다. */
|
|
273
|
+
export async function dumpDom(fileUrl, options) {
|
|
274
|
+
const chrome = await findChrome();
|
|
275
|
+
// 스크립트가 동기라 load 시점이면 이미 끝나 있다. </html> 이 오면 그만 기다린다.
|
|
276
|
+
return run(chrome, ['--dump-dom', fileUrl], options, (out) => out.includes('</html>'));
|
|
277
|
+
}
|
|
278
|
+
export async function screenshot(fileUrl, outPath, options) {
|
|
279
|
+
const chrome = await findChrome();
|
|
280
|
+
await run(chrome, [
|
|
281
|
+
`--window-size=${options.width},${options.height}`,
|
|
282
|
+
`--force-device-scale-factor=${options.scale ?? 2}`,
|
|
283
|
+
`--screenshot=${outPath}`,
|
|
284
|
+
fileUrl,
|
|
285
|
+
], options, () => pngComplete(outPath));
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=chrome.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chrome.js","sourceRoot":"","sources":["../../src/render/chrome.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 글 표지(썸네일) 카드.
|
|
3
|
+
*
|
|
4
|
+
* 벨로그는 글 목록·SNS 공유에서 `thumbnail` 을 쓴다. 없으면 본문 첫 이미지를 쓰거나
|
|
5
|
+
* 그냥 비어 보인다. 제목만 있는 글이라도 표지 한 장이 있으면 목록에서 확실히 다르다.
|
|
6
|
+
*
|
|
7
|
+
* 크기는 1200×630 — OG 이미지 표준 비율이라 트위터·슬랙 미리보기에서 잘리지 않는다.
|
|
8
|
+
* 줄바꿈은 여기서도 브라우저 실측으로 한다. 글자수로 자르면 한글에서 반드시 틀린다.
|
|
9
|
+
*/
|
|
10
|
+
export interface CoverSpec {
|
|
11
|
+
title: string;
|
|
12
|
+
subtitle?: string | undefined;
|
|
13
|
+
/** 상단 작은 라벨 (예: '디버깅 기록', 'OSS 기여') */
|
|
14
|
+
kicker?: string | undefined;
|
|
15
|
+
tags?: string[] | undefined;
|
|
16
|
+
tone?: string | undefined;
|
|
17
|
+
/** 우하단 서명 (예: 'velog.io/@milcho0604') */
|
|
18
|
+
footer?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
export interface CoverAudit {
|
|
21
|
+
w: number;
|
|
22
|
+
h: number;
|
|
23
|
+
/** 넘쳐서 잘라낸 줄 */
|
|
24
|
+
truncated: string[];
|
|
25
|
+
/** 실제로 쓰인 제목 글자 크기 — 줄어들었으면 제목이 길다는 뜻 */
|
|
26
|
+
titleSize: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function buildCoverHtml(spec: CoverSpec): string;
|
|
29
|
+
export declare function formatCoverAudit(a: CoverAudit): string;
|