contactsheet 0.1.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.md +243 -0
- package/dist/canvas/app.js +2675 -0
- package/dist/canvas/app.js.map +7 -0
- package/dist/canvas/favicon.png +0 -0
- package/dist/canvas/index.html +59 -0
- package/dist/canvas/logo.png +0 -0
- package/dist/canvas/style-pins.css +104 -0
- package/dist/canvas/style-select.css +10 -0
- package/dist/canvas/style-sidebar.css +132 -0
- package/dist/canvas/style-wall.css +110 -0
- package/dist/canvas/style.css +1189 -0
- package/dist/cli.js +1783 -0
- package/dist/cli.js.map +7 -0
- package/package.json +53 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1783 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
// src/config.ts
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
var DEFAULT_TARGET = "http://localhost:3000";
|
|
11
|
+
var DEFAULT_PORT = 5199;
|
|
12
|
+
var DEFAULT_DESIGN_DIR = "design";
|
|
13
|
+
function loadConfig(cwd, flags) {
|
|
14
|
+
const projectRoot = path.resolve(cwd);
|
|
15
|
+
const file = path.join(projectRoot, "contactsheet.config.json");
|
|
16
|
+
let fromFile = {};
|
|
17
|
+
if (fs.existsSync(file)) {
|
|
18
|
+
try {
|
|
19
|
+
fromFile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new Error(`contactsheet.config.json \u89E3\u6790\u5931\u8D25:${err.message}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const pick = (key) => flags[key] ?? fromFile[key];
|
|
25
|
+
const port = Number(pick("port") ?? DEFAULT_PORT);
|
|
26
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`\u7AEF\u53E3\u4E0D\u5408\u6CD5:${String(pick("port"))}`);
|
|
27
|
+
return {
|
|
28
|
+
projectRoot,
|
|
29
|
+
// 末尾斜杠去掉,后面一律靠 `${target}/xxx` 拼
|
|
30
|
+
target: String(pick("target") ?? DEFAULT_TARGET).replace(/\/+$/, ""),
|
|
31
|
+
port,
|
|
32
|
+
appDir: pick("appDir") ?? detectAppDir(projectRoot),
|
|
33
|
+
designDir: pick("designDir") ?? DEFAULT_DESIGN_DIR
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function detectAppDir(projectRoot) {
|
|
37
|
+
return fs.existsSync(path.join(projectRoot, "src", "app")) ? "src/app" : "app";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/inject/index.ts
|
|
41
|
+
import { promises as fs2 } from "node:fs";
|
|
42
|
+
import path2 from "node:path";
|
|
43
|
+
|
|
44
|
+
// src/inject/templates.ts
|
|
45
|
+
var HEADER = "// \u7531 contactsheet \u81EA\u52A8\u751F\u6210 \u2014\u2014 \u8BF7\u52FF\u7F16\u8F91,\u4E0B\u6B21\u542F\u52A8\u4F1A\u88AB\u8986\u76D6\n";
|
|
46
|
+
function pageTemplate(registryImport) {
|
|
47
|
+
return HEADER + String.raw`import { Suspense } from "react"
|
|
48
|
+
import { notFound } from "next/navigation"
|
|
49
|
+
import { modules } from "${registryImport}"
|
|
50
|
+
import { CsErrorBoundary, CsErrorCard } from "./boundary"
|
|
51
|
+
|
|
52
|
+
// 类型一律用 any:不依赖用户项目的 Next typegen(PageProps<"..."> 要求 .next/types 已生成)
|
|
53
|
+
|
|
54
|
+
// Next 16 的 params 不解码百分号转义(实测 params.id = "Button--%E9%BB%98%E8%AE%A4"),这里自己解
|
|
55
|
+
function decodeId(raw: string): string {
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(raw)
|
|
58
|
+
} catch {
|
|
59
|
+
return raw // 文件名里真带 % 时 decode 会抛,退回原串
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// id = fileSlug + "--" + exportName;exportName 不含 "-",所以按最后一个 "--" 切
|
|
64
|
+
function lookupEntry(id: string): any {
|
|
65
|
+
const i = id.lastIndexOf("--")
|
|
66
|
+
if (i <= 0) return null
|
|
67
|
+
const mod: any = (modules as any)[id.slice(0, i)]
|
|
68
|
+
if (!mod) return null
|
|
69
|
+
return mod[id.slice(i + 2)] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ?args= 是 encodeURIComponent(JSON.stringify(args)),Next 已解码一层,这里只需 JSON.parse
|
|
73
|
+
function parseArgs(raw: any): any {
|
|
74
|
+
if (typeof raw !== "string" || raw.length === 0) return {}
|
|
75
|
+
try {
|
|
76
|
+
const v = JSON.parse(raw)
|
|
77
|
+
return v && typeof v === "object" ? v : {}
|
|
78
|
+
} catch {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 默认无底色(裸放画布);底色由外壳按每板开关注入(大部分组件自带 surface,无底色的一键切换)
|
|
84
|
+
// 不留内边距:wrapper 一旦有 padding,组件的高亮框就永远比可见的白底小一圈(实测 390 宽的板
|
|
85
|
+
// 四周各差 16px),两者对不齐。留白交给组件自己,画板的底就等于组件的盒子。
|
|
86
|
+
const wrap: any = {}
|
|
87
|
+
// 提示与错误卡不是被审视的组件,贴边反而挤,单独留内边距
|
|
88
|
+
const padded: any = { padding: 16 }
|
|
89
|
+
const hint: any = { font: "13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace", color: "#666" }
|
|
90
|
+
const loading: any = { font: "12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace", opacity: 0.5 }
|
|
91
|
+
|
|
92
|
+
export default async function Page(props: any) {
|
|
93
|
+
if (process.env.NODE_ENV === "production") notFound() // 生产构建里这条路不存在
|
|
94
|
+
const params: any = await props.params
|
|
95
|
+
const searchParams: any = await props.searchParams
|
|
96
|
+
const id: string = decodeId(String(params?.id ?? ""))
|
|
97
|
+
const entry: any = lookupEntry(id)
|
|
98
|
+
if (!entry) notFound()
|
|
99
|
+
|
|
100
|
+
const kind: string = typeof entry.url === "string" ? "screen" : "component"
|
|
101
|
+
const args: any = { ...(entry.args ?? {}), ...parseArgs(searchParams?.args) }
|
|
102
|
+
// 转义 "<" 防止 args 里的字符串提前闭合 script 标签(JSON 里 < 解析回 "<")
|
|
103
|
+
const meta: string = JSON.stringify({ id, args, env: entry.env, kind }).replace(/</g, "\\u003c")
|
|
104
|
+
const metaTag = <script type="application/json" id="__cs_meta" dangerouslySetInnerHTML={{ __html: meta }} />
|
|
105
|
+
|
|
106
|
+
// 页面画板由外壳直接 iframe 目标 url,不在这条路由渲染
|
|
107
|
+
if (kind === "screen") {
|
|
108
|
+
return (
|
|
109
|
+
<div data-cs-artboard={id} style={padded}>
|
|
110
|
+
{metaTag}
|
|
111
|
+
<p style={hint}>页面画板:请直接打开 {entry.url}</p>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof entry.render !== "function") {
|
|
117
|
+
return (
|
|
118
|
+
<div data-cs-artboard={id} style={padded}>
|
|
119
|
+
{metaTag}
|
|
120
|
+
<CsErrorCard name={id} error="画板既没有 render(args) 也没有 url" />
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// render 自己同步抛错时 ErrorBoundary 够不着,这里兜一层
|
|
126
|
+
let node: any = null
|
|
127
|
+
try {
|
|
128
|
+
node = entry.render(args)
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
return (
|
|
131
|
+
<div data-cs-artboard={id} style={padded}>
|
|
132
|
+
{metaTag}
|
|
133
|
+
<CsErrorCard name={id} error={String(err?.message ?? err)} />
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 没声明 env.width 的组件画板收缩到内容宽(shrink-wrap),外壳按它定 iframe 宽 —— "裸放在画布上"
|
|
139
|
+
const wrapStyle: any = entry.env && entry.env.width ? wrap : { ...wrap, width: "fit-content" }
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div data-cs-artboard={id} style={wrapStyle}>
|
|
143
|
+
{metaTag}
|
|
144
|
+
<CsErrorBoundary name={id}>
|
|
145
|
+
<Suspense fallback={<div style={loading}>loading…</div>}>{node}</Suspense>
|
|
146
|
+
</CsErrorBoundary>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
function boundaryTemplate() {
|
|
153
|
+
return HEADER + String.raw`"use client"
|
|
154
|
+
import React from "react"
|
|
155
|
+
|
|
156
|
+
const cardStyle: any = {
|
|
157
|
+
border: "1px dashed #e5484d",
|
|
158
|
+
borderRadius: 6,
|
|
159
|
+
background: "#fff5f5",
|
|
160
|
+
color: "#c62a2f",
|
|
161
|
+
padding: "10px 12px",
|
|
162
|
+
font: "12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
163
|
+
whiteSpace: "pre-wrap",
|
|
164
|
+
wordBreak: "break-word",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function CsErrorCard(props: any) {
|
|
168
|
+
return (
|
|
169
|
+
<div style={cardStyle} data-cs-error="1">
|
|
170
|
+
<div style={{ fontWeight: 700, marginBottom: 4 }}>{props.name}</div>
|
|
171
|
+
<div>{props.error}</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export class CsErrorBoundary extends React.Component<any, any> {
|
|
177
|
+
constructor(props: any) {
|
|
178
|
+
super(props)
|
|
179
|
+
this.state = { error: null }
|
|
180
|
+
}
|
|
181
|
+
static getDerivedStateFromError(error: any) {
|
|
182
|
+
return { error: String(error?.message ?? error) }
|
|
183
|
+
}
|
|
184
|
+
render() {
|
|
185
|
+
if (this.state.error) return <CsErrorCard name={this.props.name} error={this.state.error} />
|
|
186
|
+
return this.props.children
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
function routeTemplate(registryImport) {
|
|
192
|
+
return HEADER + String.raw`import { modules, files } from "${registryImport}"
|
|
193
|
+
|
|
194
|
+
// 不要加 export const dynamic = "force-dynamic":它与 Next 16 的 cacheComponents 互斥,
|
|
195
|
+
// 用户开了那个配置会让 registry/ab/tokens 三条路由同时 500,且报错指向用户自己的 next.config。
|
|
196
|
+
// dev 下 route handler 本来就不缓存,production 下这条路由直接 404,加它没有收益。
|
|
197
|
+
|
|
198
|
+
// GET /__cs/registry → RegistryEntry[];kind 在这里判真值:有 url 是 screen,有 render 是 component
|
|
199
|
+
export async function GET() {
|
|
200
|
+
if (process.env.NODE_ENV === "production") return new Response("Not Found", { status: 404 })
|
|
201
|
+
|
|
202
|
+
const entries: any[] = []
|
|
203
|
+
for (const slug of Object.keys(modules as any)) {
|
|
204
|
+
const mod: any = (modules as any)[slug]
|
|
205
|
+
if (!mod) continue
|
|
206
|
+
for (const exportName of Object.keys(mod)) {
|
|
207
|
+
const v: any = mod[exportName]
|
|
208
|
+
if (!v || typeof v !== "object") continue
|
|
209
|
+
// 没有 render 也没有 url 的对象照样进注册表(按 component):墙上出错误卡,
|
|
210
|
+
// 而不是静默消失 —— 用户把 render 敲错时必须有反馈
|
|
211
|
+
const kind = typeof v.url === "string" ? "screen" : "component"
|
|
212
|
+
entries.push({
|
|
213
|
+
id: slug + "--" + exportName,
|
|
214
|
+
file: (files as any)[slug] ?? "",
|
|
215
|
+
exportName,
|
|
216
|
+
kind,
|
|
217
|
+
args: v.args,
|
|
218
|
+
env: v.env,
|
|
219
|
+
url: v.url,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return Response.json(entries)
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
function tokensTemplate() {
|
|
228
|
+
return HEADER + String.raw`"use client"
|
|
229
|
+
import { useEffect, useState } from "react"
|
|
230
|
+
|
|
231
|
+
type Token = { name: string; value: string }
|
|
232
|
+
|
|
233
|
+
// 枚举同源样式表里 :root / :host / html 下的自定义属性
|
|
234
|
+
// (Tailwind v4 的 @theme 编译后就是 @layer theme 里的 ":root, :host",靠递归 cssRules 拿到)
|
|
235
|
+
function collectTokens(): Token[] {
|
|
236
|
+
const found = new Map<string, string>()
|
|
237
|
+
const visit = (rules: any) => {
|
|
238
|
+
if (!rules) return
|
|
239
|
+
for (let i = 0; i < rules.length; i++) {
|
|
240
|
+
const rule: any = rules[i]
|
|
241
|
+
if (rule.cssRules) visit(rule.cssRules)
|
|
242
|
+
const style: any = rule.style
|
|
243
|
+
if (!style) continue
|
|
244
|
+
const sel: string = typeof rule.selectorText === "string" ? rule.selectorText : ""
|
|
245
|
+
if (sel && !/:root|:host|(^|,)\s*html\b/.test(sel)) continue
|
|
246
|
+
for (let k = 0; k < style.length; k++) {
|
|
247
|
+
const prop: string = style[k]
|
|
248
|
+
if (prop && prop.slice(0, 2) === "--") found.set(prop, String(style.getPropertyValue(prop)).trim())
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
for (let i = 0; i < document.styleSheets.length; i++) {
|
|
253
|
+
try {
|
|
254
|
+
visit((document.styleSheets[i] as any).cssRules)
|
|
255
|
+
} catch {
|
|
256
|
+
// 跨域样式表读不到 cssRules,跳过
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return Array.from(found, ([name, value]) => ({ name, value })).sort((a, b) => a.name.localeCompare(b.name))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 值本身就是颜色的(shadcn 的 --primary / --background 这些没有 --color- 前缀)也给方块
|
|
263
|
+
function isColorValue(v: string): boolean {
|
|
264
|
+
return /^(#|rgba?\(|hsla?\(|hwb\(|lab\(|lch\(|oklab\(|oklch\(|color\()/i.test(v)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mono = "12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace"
|
|
268
|
+
const page: any = { padding: 24, font: mono, color: "#111", background: "#fff", minHeight: "100vh" }
|
|
269
|
+
const h2: any = { font: "600 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace", margin: "24px 0 10px", color: "#555" }
|
|
270
|
+
const grid: any = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(190px, 1fr))", gap: 12 }
|
|
271
|
+
const cell: any = { border: "1px solid #eee", borderRadius: 6, padding: 10, overflow: "hidden" }
|
|
272
|
+
const nameStyle: any = { fontWeight: 600, wordBreak: "break-all" }
|
|
273
|
+
const valueStyle: any = { color: "#888", wordBreak: "break-all" }
|
|
274
|
+
|
|
275
|
+
function Cell(props: any) {
|
|
276
|
+
return (
|
|
277
|
+
<div style={cell}>
|
|
278
|
+
{props.children}
|
|
279
|
+
<div style={nameStyle}>{props.token.name}</div>
|
|
280
|
+
<div style={valueStyle}>{props.token.value}</div>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function Group(props: any) {
|
|
286
|
+
if (props.tokens.length === 0) return null
|
|
287
|
+
return (
|
|
288
|
+
<section>
|
|
289
|
+
<h2 style={h2}>
|
|
290
|
+
{props.title} · {props.tokens.length}
|
|
291
|
+
</h2>
|
|
292
|
+
<div style={grid}>
|
|
293
|
+
{props.tokens.map((t: Token) => (
|
|
294
|
+
<Cell key={t.name} token={t}>
|
|
295
|
+
{props.kind === "color" || isColorValue(t.value) ? (
|
|
296
|
+
<div
|
|
297
|
+
style={{
|
|
298
|
+
height: 44,
|
|
299
|
+
marginBottom: 8,
|
|
300
|
+
borderRadius: 4,
|
|
301
|
+
border: "1px solid rgba(0,0,0,.1)",
|
|
302
|
+
background: "var(" + t.name + ")",
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
) : null}
|
|
306
|
+
{props.kind === "radius" ? (
|
|
307
|
+
<div
|
|
308
|
+
style={{
|
|
309
|
+
height: 44,
|
|
310
|
+
marginBottom: 8,
|
|
311
|
+
border: "1px solid #ddd",
|
|
312
|
+
background: "#f6f6f6",
|
|
313
|
+
borderRadius: "var(" + t.name + ")",
|
|
314
|
+
}}
|
|
315
|
+
/>
|
|
316
|
+
) : null}
|
|
317
|
+
</Cell>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
</section>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export default function TokensPage() {
|
|
325
|
+
const [tokens, setTokens] = useState<Token[]>([])
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
setTokens(collectTokens())
|
|
328
|
+
}, [])
|
|
329
|
+
|
|
330
|
+
if (process.env.NODE_ENV === "production") return null // 生产里这页不出现
|
|
331
|
+
|
|
332
|
+
const pick = (p: string) => tokens.filter((t) => t.name.indexOf(p) === 0)
|
|
333
|
+
const known = ["--color-", "--radius-", "--font-", "--spacing-"]
|
|
334
|
+
const rest = tokens.filter((t) => !known.some((p) => t.name.indexOf(p) === 0))
|
|
335
|
+
|
|
336
|
+
return (
|
|
337
|
+
<main style={page}>
|
|
338
|
+
<div style={{ font: mono, color: "#888" }}>contactsheet · design tokens · 共 {tokens.length} 个自定义属性</div>
|
|
339
|
+
{tokens.length === 0 ? (
|
|
340
|
+
<p style={{ color: "#888", marginTop: 16 }}>没读到自定义属性(样式表可能还没加载完,或都是跨域的)。</p>
|
|
341
|
+
) : null}
|
|
342
|
+
<Group title="颜色 --color-*" kind="color" tokens={pick("--color-")} />
|
|
343
|
+
<Group title="圆角 --radius-*" kind="radius" tokens={pick("--radius-")} />
|
|
344
|
+
<Group title="字体 --font-*" kind="text" tokens={pick("--font-")} />
|
|
345
|
+
<Group title="间距 --spacing-*" kind="text" tokens={pick("--spacing-")} />
|
|
346
|
+
<Group title="其它" kind="text" tokens={rest} />
|
|
347
|
+
</main>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
`;
|
|
351
|
+
}
|
|
352
|
+
function registryTemplate(items) {
|
|
353
|
+
const imports = items.map((it) => "import * as " + it.varName + ' from "' + it.importPath + '"').join("\n");
|
|
354
|
+
const mods = items.map((it) => ' "' + it.slug + '": ' + it.varName + ",").join("\n");
|
|
355
|
+
const fileMap = items.map((it) => ' "' + it.slug + '": "' + it.file + '",').join("\n");
|
|
356
|
+
return "// \u7531 contactsheet \u81EA\u52A8\u751F\u6210 \u2014\u2014 \u8BF7\u52FF\u7F16\u8F91,\u753B\u677F\u6587\u4EF6\u589E\u5220\u6539\u540E\u4F1A\u88AB\u91CD\u5199\n" + (imports ? imports + "\n\n" : "") + "export const modules: Record<string, Record<string, unknown>> = " + (items.length ? "{\n" + mods + "\n}\n" : "{}\n") + "\n// fileSlug \u2192 repo \u76F8\u5BF9\u8DEF\u5F84,\u7ED9 /__cs/registry \u586B RegistryEntry.file\nexport const files: Record<string, string> = " + (items.length ? "{\n" + fileMap + "\n}\n" : "{}\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/inject/index.ts
|
|
360
|
+
var CS_DIR = "%5F%5Fcs";
|
|
361
|
+
var GITIGNORE_BEGIN = "# contactsheet begin";
|
|
362
|
+
var GITIGNORE_END = "# contactsheet end";
|
|
363
|
+
function normRel(p) {
|
|
364
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
365
|
+
}
|
|
366
|
+
function csDirRel(cfg) {
|
|
367
|
+
return normRel(cfg.appDir) + "/" + CS_DIR;
|
|
368
|
+
}
|
|
369
|
+
function registryImportFrom(fromDirRel, cfg) {
|
|
370
|
+
const target = normRel(cfg.designDir) + "/__generated__/registry";
|
|
371
|
+
const rel = path2.posix.relative(fromDirRel, target);
|
|
372
|
+
return rel.startsWith(".") ? rel : "./" + rel;
|
|
373
|
+
}
|
|
374
|
+
async function readOr(file, fallback) {
|
|
375
|
+
try {
|
|
376
|
+
return await fs2.readFile(file, "utf8");
|
|
377
|
+
} catch {
|
|
378
|
+
return fallback;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
async function writeIfChanged(file, content) {
|
|
382
|
+
if (await readOr(file, null) === content) return false;
|
|
383
|
+
await fs2.mkdir(path2.dirname(file), { recursive: true });
|
|
384
|
+
await fs2.writeFile(file, content, "utf8");
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
function findBlock(text2) {
|
|
388
|
+
const start = text2.indexOf(GITIGNORE_BEGIN);
|
|
389
|
+
if (start < 0) return null;
|
|
390
|
+
const at = text2.indexOf(GITIGNORE_END, start);
|
|
391
|
+
if (at < 0) return null;
|
|
392
|
+
return { start, end: at + GITIGNORE_END.length };
|
|
393
|
+
}
|
|
394
|
+
function gitignoreBlock(cfg) {
|
|
395
|
+
const design = normRel(cfg.designDir);
|
|
396
|
+
return [GITIGNORE_BEGIN, csDirRel(cfg) + "/", design + "/__generated__/", design + "/.canvas/", GITIGNORE_END].join(
|
|
397
|
+
"\n"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
async function updateGitignore(cfg) {
|
|
401
|
+
const file = path2.join(cfg.projectRoot, ".gitignore");
|
|
402
|
+
const old = await readOr(file, "");
|
|
403
|
+
const block = gitignoreBlock(cfg);
|
|
404
|
+
const found = findBlock(old);
|
|
405
|
+
const next = found ? old.slice(0, found.start) + block + old.slice(found.end) : (old.trimEnd() ? old.trimEnd() + "\n\n" : "") + block + "\n";
|
|
406
|
+
await writeIfChanged(file, next);
|
|
407
|
+
}
|
|
408
|
+
async function removeGitignoreBlock(cfg) {
|
|
409
|
+
const file = path2.join(cfg.projectRoot, ".gitignore");
|
|
410
|
+
const old = await readOr(file, null);
|
|
411
|
+
if (old === null) return;
|
|
412
|
+
const found = findBlock(old);
|
|
413
|
+
if (!found) return;
|
|
414
|
+
const left = (old.slice(0, found.start) + old.slice(found.end)).replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
415
|
+
await writeIfChanged(file, left ? left + "\n" : "");
|
|
416
|
+
}
|
|
417
|
+
async function ensureInjected(cfg) {
|
|
418
|
+
const root = cfg.projectRoot;
|
|
419
|
+
const designAbs = path2.join(root, normRel(cfg.designDir));
|
|
420
|
+
await fs2.mkdir(designAbs, { recursive: true });
|
|
421
|
+
await fs2.mkdir(path2.join(designAbs, ".canvas"), { recursive: true });
|
|
422
|
+
const abDirRel = csDirRel(cfg) + "/ab/[id]";
|
|
423
|
+
await writeIfChanged(path2.join(root, abDirRel, "page.tsx"), pageTemplate(registryImportFrom(abDirRel, cfg)));
|
|
424
|
+
await writeIfChanged(path2.join(root, abDirRel, "boundary.tsx"), boundaryTemplate());
|
|
425
|
+
const routeDirRel = csDirRel(cfg) + "/registry";
|
|
426
|
+
await writeIfChanged(path2.join(root, routeDirRel, "route.ts"), routeTemplate(registryImportFrom(routeDirRel, cfg)));
|
|
427
|
+
await writeIfChanged(path2.join(root, csDirRel(cfg), "tokens", "page.tsx"), tokensTemplate());
|
|
428
|
+
await updateGitignore(cfg);
|
|
429
|
+
await regenerateRegistry(cfg);
|
|
430
|
+
}
|
|
431
|
+
async function removeInjected(cfg) {
|
|
432
|
+
await fs2.rm(path2.join(cfg.projectRoot, csDirRel(cfg)), { recursive: true, force: true });
|
|
433
|
+
await fs2.rm(path2.join(cfg.projectRoot, normRel(cfg.designDir), "__generated__"), { recursive: true, force: true });
|
|
434
|
+
await removeGitignoreBlock(cfg);
|
|
435
|
+
}
|
|
436
|
+
async function scanArtboards(designAbs, sub = "") {
|
|
437
|
+
let dirents;
|
|
438
|
+
try {
|
|
439
|
+
dirents = await fs2.readdir(path2.join(designAbs, sub), { withFileTypes: true });
|
|
440
|
+
} catch {
|
|
441
|
+
return [];
|
|
442
|
+
}
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const d of dirents) {
|
|
445
|
+
const rel = sub ? sub + "/" + d.name : d.name;
|
|
446
|
+
if (d.isDirectory()) {
|
|
447
|
+
if (d.name === "__generated__" || d.name === "node_modules" || d.name.startsWith(".")) continue;
|
|
448
|
+
out.push(...await scanArtboards(designAbs, rel));
|
|
449
|
+
} else if (/\.artboard\.tsx?$/.test(d.name)) {
|
|
450
|
+
out.push(rel);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
function extractExports(source) {
|
|
456
|
+
const re = /^export\s+const\s+([\p{L}\w$]+)/gmu;
|
|
457
|
+
const names = [];
|
|
458
|
+
let m;
|
|
459
|
+
while ((m = re.exec(source)) !== null) {
|
|
460
|
+
if (!names.includes(m[1])) names.push(m[1]);
|
|
461
|
+
}
|
|
462
|
+
return names;
|
|
463
|
+
}
|
|
464
|
+
function slugOf(rel) {
|
|
465
|
+
return rel.replace(/\.artboard\.tsx?$/, "").replace(/\//g, "__");
|
|
466
|
+
}
|
|
467
|
+
async function regenerateRegistry(cfg) {
|
|
468
|
+
const designRel = normRel(cfg.designDir);
|
|
469
|
+
const designAbs = path2.join(cfg.projectRoot, designRel);
|
|
470
|
+
const rels = (await scanArtboards(designAbs)).sort();
|
|
471
|
+
const items = [];
|
|
472
|
+
const entries = [];
|
|
473
|
+
for (const rel of rels) {
|
|
474
|
+
const source = await readOr(path2.join(designAbs, rel), null);
|
|
475
|
+
if (source === null) continue;
|
|
476
|
+
const names = extractExports(source);
|
|
477
|
+
if (names.length === 0) continue;
|
|
478
|
+
const slug = slugOf(rel);
|
|
479
|
+
const file = designRel + "/" + rel;
|
|
480
|
+
items.push({
|
|
481
|
+
varName: "m" + items.length,
|
|
482
|
+
// registry.tsx 在 <designDir>/__generated__/ 下,上一级就是 designDir
|
|
483
|
+
importPath: "../" + rel.replace(/\.tsx?$/, ""),
|
|
484
|
+
slug,
|
|
485
|
+
file
|
|
486
|
+
});
|
|
487
|
+
for (const exportName of names) {
|
|
488
|
+
entries.push({ id: slug + "--" + exportName, file, exportName, kind: "component" });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
await writeIfChanged(path2.join(designAbs, "__generated__", "registry.tsx"), registryTemplate(items));
|
|
492
|
+
return entries;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/init/index.ts
|
|
496
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
497
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
498
|
+
var DEFAULT_TARGET2 = "http://localhost:3000";
|
|
499
|
+
var DEFAULT_PORT2 = 5199;
|
|
500
|
+
var DEFAULT_DESIGN_DIR2 = "design";
|
|
501
|
+
var UI_DIR_CANDIDATES = ["components/ui", "src/components/ui"];
|
|
502
|
+
async function runInit(cwd, flags) {
|
|
503
|
+
const root = resolve(cwd);
|
|
504
|
+
assertNextProject(root);
|
|
505
|
+
const appDir = detectAppDir2(root);
|
|
506
|
+
const cfg = writeConfigFile(root, appDir, flags);
|
|
507
|
+
console.log(`contactsheet init \u2014\u2014 ${root}`);
|
|
508
|
+
console.log(` app \u76EE\u5F55:${cfg.appDir}`);
|
|
509
|
+
console.log(` \u914D\u7F6E:contactsheet.config.json(target ${cfg.target},port ${cfg.port},\u753B\u677F\u76EE\u5F55 ${cfg.designDir})`);
|
|
510
|
+
seedArtboards(root, cfg.designDir);
|
|
511
|
+
writeMcpJson(root, cfg.port);
|
|
512
|
+
writeClaudeHook(root, cfg.port);
|
|
513
|
+
console.log("");
|
|
514
|
+
console.log("\u4E0B\u4E00\u6B65:");
|
|
515
|
+
console.log(" 1. \u7167\u5E38\u8D77\u4F60\u81EA\u5DF1\u7684 dev server(next dev),\u786E\u8BA4\u5B83\u5728 " + cfg.target);
|
|
516
|
+
console.log(" 2. \u53E6\u5F00\u4E00\u4E2A\u7EC8\u7AEF:npx contactsheet");
|
|
517
|
+
console.log(` 3. \u6253\u5F00 http://localhost:${cfg.port}/__cs`);
|
|
518
|
+
}
|
|
519
|
+
function assertNextProject(root) {
|
|
520
|
+
const pkgPath = join(root, "package.json");
|
|
521
|
+
if (!existsSync(pkgPath)) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`contactsheet init \u4E2D\u6B62:${root} \u4E0B\u6CA1\u6709 package.json\u3002
|
|
524
|
+
contactsheet \u662F\u9644\u7740\u5728\u4F60\u81EA\u5DF1\u7684 Next.js \u9879\u76EE\u4E0A\u8DD1\u7684,\u8BF7\u5230\u9879\u76EE\u6839\u76EE\u5F55\u518D\u6267\u884C\u4E00\u6B21\u3002`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const pkg = readJsonObject(pkgPath) ?? {};
|
|
528
|
+
const deps = { ...asRecord(pkg["dependencies"]), ...asRecord(pkg["devDependencies"]) };
|
|
529
|
+
if (!("next" in deps)) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`contactsheet init \u4E2D\u6B62:${pkgPath} \u7684 dependencies / devDependencies \u91CC\u6CA1\u6709 next\u3002
|
|
532
|
+
contactsheet v1 \u53EA\u8BA4 Next.js App Router \u9879\u76EE \u2014\u2014 \u5B83\u9760\u5F80\u4F60\u7684 app \u76EE\u5F55\u6CE8\u5165\u4E00\u6761\u753B\u677F\u8DEF\u7531\u5E72\u6D3B,
|
|
533
|
+
\u6E32\u67D3\u540E\u7AEF\u5C31\u662F\u4F60\u81EA\u5DF1\u7684 next dev,\u6CA1\u6709 Next \u5C31\u6CA1\u6709\u753B\u677F\u3002`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
function detectAppDir2(root) {
|
|
538
|
+
for (const candidate of ["src/app", "app"]) {
|
|
539
|
+
if (isDir(join(root, candidate))) return candidate;
|
|
540
|
+
}
|
|
541
|
+
throw new Error(
|
|
542
|
+
`contactsheet init \u4E2D\u6B62:${root} \u4E0B\u65E2\u6CA1\u6709 src/app/ \u4E5F\u6CA1\u6709 app/\u3002
|
|
543
|
+
contactsheet v1 \u53EA\u652F\u6301 App Router(\u753B\u677F\u8DEF\u7531\u8981\u6CE8\u5165\u5230 app \u76EE\u5F55\u91CC)\u3002Pages Router \u9879\u76EE\u6682\u65F6\u7528\u4E0D\u4E86\u3002`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
function writeConfigFile(root, appDir, flags) {
|
|
547
|
+
const file = join(root, "contactsheet.config.json");
|
|
548
|
+
const existing = readJsonObject(file) ?? {};
|
|
549
|
+
const resolved = {
|
|
550
|
+
target: pickString(flags.target, existing["target"], DEFAULT_TARGET2),
|
|
551
|
+
port: pickNumber(flags.port, existing["port"], DEFAULT_PORT2),
|
|
552
|
+
appDir: pickString(flags.appDir, existing["appDir"], appDir),
|
|
553
|
+
designDir: pickString(flags.designDir, existing["designDir"], DEFAULT_DESIGN_DIR2)
|
|
554
|
+
};
|
|
555
|
+
writeJsonFile(file, { ...existing, ...resolved });
|
|
556
|
+
return { projectRoot: root, ...resolved };
|
|
557
|
+
}
|
|
558
|
+
function seedArtboards(root, designDir) {
|
|
559
|
+
const uiDir = UI_DIR_CANDIDATES.map((p) => join(root, p)).find(isDir);
|
|
560
|
+
if (!uiDir) {
|
|
561
|
+
console.log(` \u753B\u677F:\u6CA1\u627E\u5230 components/ui/,\u8DF3\u8FC7\u81EA\u52A8\u94FA\u753B\u677F(\u624B\u5199 ${designDir}/*.artboard.tsx \u4E00\u6837\u7528)`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const files = readdirSync(uiDir).filter((f) => f.endsWith(".tsx")).sort();
|
|
565
|
+
const targetDir = join(root, designDir);
|
|
566
|
+
let created = 0;
|
|
567
|
+
let skipped = 0;
|
|
568
|
+
let unreadable = 0;
|
|
569
|
+
for (const base of files) {
|
|
570
|
+
const componentFile = join(uiDir, base);
|
|
571
|
+
const name = firstComponentExport(readFileSync(componentFile, "utf8"));
|
|
572
|
+
if (!name) {
|
|
573
|
+
unreadable++;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const artboard = join(targetDir, `${name}.artboard.tsx`);
|
|
577
|
+
if (existsSync(artboard)) {
|
|
578
|
+
skipped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
mkdirSync(targetDir, { recursive: true });
|
|
582
|
+
writeFileSync(artboard, artboardTemplate(name, importPathFor(root, designDir, componentFile)), "utf8");
|
|
583
|
+
created++;
|
|
584
|
+
}
|
|
585
|
+
console.log(` \u753B\u677F:\u94FA\u4E86 ${created} \u5757\u753B\u677F,\u8DF3\u8FC7 ${skipped} \u4E2A\u5DF2\u5B58\u5728` + (unreadable ? `,${unreadable} \u4E2A\u6587\u4EF6\u6CA1\u8BA4\u51FA\u7EC4\u4EF6\u5BFC\u51FA` : ""));
|
|
586
|
+
}
|
|
587
|
+
function firstComponentExport(source) {
|
|
588
|
+
const hits = [];
|
|
589
|
+
const declRe = /^export\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
590
|
+
for (const m of source.matchAll(declRe)) {
|
|
591
|
+
if (isPascal(m[1])) hits.push({ index: m.index ?? 0, name: m[1] });
|
|
592
|
+
}
|
|
593
|
+
const listRe = /^export\s*\{([^}]*)\}/gm;
|
|
594
|
+
for (const m of source.matchAll(listRe)) {
|
|
595
|
+
for (const raw of m[1].split(",")) {
|
|
596
|
+
const item = raw.trim();
|
|
597
|
+
if (!item || item.startsWith("type ")) continue;
|
|
598
|
+
const parts = item.split(/\s+as\s+/);
|
|
599
|
+
const name = parts[parts.length - 1].trim();
|
|
600
|
+
if (isPascal(name)) {
|
|
601
|
+
hits.push({ index: m.index ?? 0, name });
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (hits.length === 0) return null;
|
|
607
|
+
hits.sort((a, b) => a.index - b.index);
|
|
608
|
+
return hits[0].name;
|
|
609
|
+
}
|
|
610
|
+
function isPascal(name) {
|
|
611
|
+
return /^[A-Z][A-Za-z0-9_$]*$/.test(name);
|
|
612
|
+
}
|
|
613
|
+
function importPathFor(root, designDir, componentFile) {
|
|
614
|
+
const aliasBase = readAliasBase(root);
|
|
615
|
+
if (aliasBase && toPosix(componentFile).startsWith(toPosix(aliasBase) + "/")) {
|
|
616
|
+
return "@/" + toPosix(relative(aliasBase, componentFile)).replace(/\.tsx$/, "");
|
|
617
|
+
}
|
|
618
|
+
const rel = toPosix(relative(join(root, designDir), componentFile)).replace(/\.tsx$/, "");
|
|
619
|
+
return rel.startsWith(".") ? rel : "./" + rel;
|
|
620
|
+
}
|
|
621
|
+
function readAliasBase(root) {
|
|
622
|
+
const file = join(root, "tsconfig.json");
|
|
623
|
+
if (!existsSync(file)) return null;
|
|
624
|
+
let parsed;
|
|
625
|
+
try {
|
|
626
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
627
|
+
} catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
const paths = asRecord(asRecord(asRecord(parsed)["compilerOptions"])["paths"]);
|
|
631
|
+
const entry = paths["@/*"];
|
|
632
|
+
const first = Array.isArray(entry) ? entry[0] : void 0;
|
|
633
|
+
if (typeof first !== "string") return null;
|
|
634
|
+
return resolve(root, first.replace(/\/?\*$/, ""));
|
|
635
|
+
}
|
|
636
|
+
function artboardTemplate(name, importPath) {
|
|
637
|
+
return `// contactsheet \u81EA\u52A8\u751F\u6210\u7684\u753B\u677F\u9AA8\u67B6 \u2014\u2014 \u968F\u4FBF\u6539,init \u4E0D\u4F1A\u518D\u8986\u76D6\u5B83
|
|
638
|
+
import { ${name} } from '${importPath}'
|
|
639
|
+
|
|
640
|
+
export const \u9ED8\u8BA4 = {
|
|
641
|
+
render: () => <${name}>\u793A\u4F8B</${name}>,
|
|
642
|
+
}
|
|
643
|
+
`;
|
|
644
|
+
}
|
|
645
|
+
function writeMcpJson(root, port) {
|
|
646
|
+
const file = join(root, ".mcp.json");
|
|
647
|
+
const doc = readJsonObject(file) ?? {};
|
|
648
|
+
const servers = asRecord(doc["mcpServers"]);
|
|
649
|
+
const had = "contactsheet" in servers;
|
|
650
|
+
servers["contactsheet"] = { type: "http", url: `http://localhost:${port}/__cs/mcp` };
|
|
651
|
+
doc["mcpServers"] = servers;
|
|
652
|
+
writeJsonFile(file, doc);
|
|
653
|
+
const others = Object.keys(servers).filter((k) => k !== "contactsheet").length;
|
|
654
|
+
console.log(
|
|
655
|
+
` .mcp.json:${had ? "\u66F4\u65B0" : "\u5199\u5165"} mcpServers.contactsheet` + (others ? `(\u53E6\u5916 ${others} \u4E2A server \u539F\u6837\u4FDD\u7559)` : "")
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
function writeClaudeHook(root, port) {
|
|
659
|
+
const file = join(root, ".claude", "settings.json");
|
|
660
|
+
const doc = readJsonObject(file) ?? {};
|
|
661
|
+
const hooks = asRecord(doc["hooks"]);
|
|
662
|
+
const list = Array.isArray(hooks["UserPromptSubmit"]) ? hooks["UserPromptSubmit"] : [];
|
|
663
|
+
const existingCommands = [];
|
|
664
|
+
for (const group of list) {
|
|
665
|
+
for (const h of Array.isArray(asRecord(group)["hooks"]) ? asRecord(group)["hooks"] : []) {
|
|
666
|
+
const cmd = asRecord(h)["command"];
|
|
667
|
+
if (typeof cmd === "string") existingCommands.push(cmd);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const already = existingCommands.filter((c) => c.includes("/__cs/api/context"));
|
|
671
|
+
if (already.length > 0) {
|
|
672
|
+
console.log(` .claude/settings.json:context hook \u5DF2\u5B58\u5728,\u4E0D\u91CD\u590D\u6DFB\u52A0`);
|
|
673
|
+
if (!already.some((c) => c.includes(`localhost:${port}/`))) {
|
|
674
|
+
console.log(` \u6CE8\u610F:\u5DF2\u6709\u7684 hook \u6253\u7684\u4E0D\u662F :${port},\u7AEF\u53E3\u6362\u8FC7\u7684\u8BDD\u8BF7\u624B\u6539\u8FD9\u6761 command`);
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const command = `curl -s --noproxy '*' --max-time 1 http://localhost:${port}/__cs/api/context || true`;
|
|
679
|
+
list.push({ hooks: [{ type: "command", command }] });
|
|
680
|
+
hooks["UserPromptSubmit"] = list;
|
|
681
|
+
doc["hooks"] = hooks;
|
|
682
|
+
writeJsonFile(file, doc);
|
|
683
|
+
console.log(` .claude/settings.json:\u52A0\u4E86 UserPromptSubmit hook(\u81EA\u52A8\u628A\u6279\u6CE8\u548C\u9009\u4E2D\u5143\u7D20\u5E26\u7ED9 Claude)`);
|
|
684
|
+
}
|
|
685
|
+
function isDir(p) {
|
|
686
|
+
try {
|
|
687
|
+
return statSync(p).isDirectory();
|
|
688
|
+
} catch {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
function toPosix(p) {
|
|
693
|
+
return p.split("\\").join("/");
|
|
694
|
+
}
|
|
695
|
+
function asRecord(v) {
|
|
696
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
697
|
+
}
|
|
698
|
+
function pickString(...candidates) {
|
|
699
|
+
for (const c of candidates) if (typeof c === "string" && c !== "") return c;
|
|
700
|
+
return "";
|
|
701
|
+
}
|
|
702
|
+
function pickNumber(...candidates) {
|
|
703
|
+
for (const c of candidates) if (typeof c === "number" && Number.isFinite(c)) return c;
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
function readJsonObject(file) {
|
|
707
|
+
if (!existsSync(file)) return null;
|
|
708
|
+
const raw = readFileSync(file, "utf8");
|
|
709
|
+
if (raw.trim() === "") return {};
|
|
710
|
+
let parsed;
|
|
711
|
+
try {
|
|
712
|
+
parsed = JSON.parse(raw);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`contactsheet init \u4E2D\u6B62:${file} \u4E0D\u662F\u5408\u6CD5\u7684 JSON(${err.message})\u3002
|
|
716
|
+
init \u53EA\u4F1A\u5F80\u91CC\u5408\u5E76\u3001\u4E0D\u4F1A\u91CD\u5199,\u6240\u4EE5\u770B\u4E0D\u61C2\u7684\u6587\u4EF6\u4E00\u5F8B\u4E0D\u52A8 \u2014\u2014 \u8BF7\u5148\u4FEE\u597D\u5B83\u518D\u8DD1\u4E00\u6B21\u3002`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
720
|
+
throw new Error(`contactsheet init \u4E2D\u6B62:${file} \u7684\u9876\u5C42\u4E0D\u662F\u4E00\u4E2A JSON \u5BF9\u8C61,init \u4E0D\u52A8\u5B83\u3002`);
|
|
721
|
+
}
|
|
722
|
+
return parsed;
|
|
723
|
+
}
|
|
724
|
+
function writeJsonFile(file, value) {
|
|
725
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
726
|
+
writeFileSync(file, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/server/index.ts
|
|
730
|
+
import fs6 from "node:fs";
|
|
731
|
+
import http from "node:http";
|
|
732
|
+
import httpProxy from "http-proxy";
|
|
733
|
+
|
|
734
|
+
// src/mcp/index.ts
|
|
735
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
736
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
737
|
+
import { z } from "zod";
|
|
738
|
+
async function guard(run) {
|
|
739
|
+
try {
|
|
740
|
+
return await run();
|
|
741
|
+
} catch (err) {
|
|
742
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
743
|
+
const text2 = msg.startsWith("contactsheet:") ? msg : `contactsheet: ${msg}`;
|
|
744
|
+
return { isError: true, content: [{ type: "text", text: text2 }] };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function text(value) {
|
|
748
|
+
return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
|
|
749
|
+
}
|
|
750
|
+
function buildServer(services) {
|
|
751
|
+
const server = new McpServer(
|
|
752
|
+
{ name: "contactsheet", version: "0.1.0" },
|
|
753
|
+
{ capabilities: { tools: {} } }
|
|
754
|
+
);
|
|
755
|
+
server.registerTool(
|
|
756
|
+
"canvas_list",
|
|
757
|
+
{
|
|
758
|
+
title: "\u5217\u51FA\u753B\u677F",
|
|
759
|
+
description: "\u5217\u51FA contactsheet \u753B\u5E03\u4E0A\u7684\u5168\u90E8\u753B\u677F(\u8BBE\u8BA1\u7A3F)\u3002\u8FD4\u56DE JSON \u6570\u7EC4,\u6BCF\u9879\u542B id\u3001file(\u6E90\u6587\u4EF6\u76F8\u5BF9\u8DEF\u5F84)\u3001exportName\u3001kind(component=\u7EC4\u4EF6\u753B\u677F / screen=\u9875\u9762\u753B\u677F)\u3001args(\u7EC4\u4EF6\u9ED8\u8BA4\u5165\u53C2)\u3001env(\u5BBD\u9AD8\u7B49)\u3002\u8981\u622A\u67D0\u5757\u753B\u677F\u524D\u5148\u7528\u5B83\u62FF\u5230\u51C6\u786E\u7684 id\u3002",
|
|
760
|
+
annotations: { readOnlyHint: true }
|
|
761
|
+
},
|
|
762
|
+
async () => guard(async () => text(await services.getRegistry()))
|
|
763
|
+
);
|
|
764
|
+
server.registerTool(
|
|
765
|
+
"canvas_screenshot",
|
|
766
|
+
{
|
|
767
|
+
title: "\u622A\u56FE\u753B\u677F",
|
|
768
|
+
description: "\u7ED9\u753B\u677F\u622A\u56FE\u5E76\u628A\u56FE\u7247\u8FD4\u56DE\u7ED9\u4F60\u770B\u3002\u4F20 id \u622A\u5355\u5757\u753B\u677F(id \u4ECE canvas_list \u62FF,\u7EC4\u4EF6\u753B\u677F\u622A\u5168\u9875);\u4E0D\u4F20 id \u622A\u6574\u9762\u753B\u5E03\u5899\u7684\u6982\u89C8\u3002\u8FD4\u56DE\u4E00\u5F20 PNG \u56FE\u7247 + \u56FE\u7247\u5728 repo \u91CC\u7684\u4FDD\u5B58\u8DEF\u5F84\u3002\u60F3\u786E\u8BA4 UI \u6539\u52A8\u7684\u5B9E\u9645\u6548\u679C\u5C31\u7528\u5B83\u3002",
|
|
769
|
+
inputSchema: {
|
|
770
|
+
id: z.string().optional().describe("\u753B\u677F id(\u6765\u81EA canvas_list);\u7701\u7565\u5219\u622A\u6574\u9762\u5899\u7684\u6982\u89C8\u56FE")
|
|
771
|
+
},
|
|
772
|
+
annotations: { readOnlyHint: true }
|
|
773
|
+
},
|
|
774
|
+
async ({ id }) => guard(async () => {
|
|
775
|
+
const shot = await services.takeShot(id ? { id } : {});
|
|
776
|
+
return {
|
|
777
|
+
content: [
|
|
778
|
+
{ type: "image", data: shot.base64, mimeType: "image/png" },
|
|
779
|
+
{ type: "text", text: `\u5DF2\u4FDD\u5B58:${shot.path}(${shot.width}\xD7${shot.height})` }
|
|
780
|
+
]
|
|
781
|
+
};
|
|
782
|
+
})
|
|
783
|
+
);
|
|
784
|
+
server.registerTool(
|
|
785
|
+
"canvas_selection",
|
|
786
|
+
{
|
|
787
|
+
title: "\u5F53\u524D\u9009\u4E2D\u5143\u7D20",
|
|
788
|
+
description: "\u8BFB\u7528\u6237\u6B64\u523B\u5728 contactsheet \u753B\u5E03\u4E0A\u70B9\u9009\u7684\u5143\u7D20:\u8FD4\u56DE JSON,\u542B artboardId(\u54EA\u5757\u753B\u677F)\u3001selector(\u753B\u677F\u6587\u6863\u5185\u7684 CSS \u9009\u62E9\u5668)\u3001x/y(\u5143\u7D20\u5185 0-1 \u76F8\u5BF9\u5750\u6807)\u3001ts(\u65F6\u95F4\u6233)\u3002\u7528\u6237\u8BF4\u300C\u8FD9\u4E2A\u6309\u94AE\u300D\u300C\u8FD9\u91CC\u300D\u7684\u65F6\u5019\u5148\u8C03\u5B83\u786E\u8BA4\u6307\u7684\u662F\u8C01\u3002\u6CA1\u6709\u9009\u4E2D\u65F6\u8FD4\u56DE no selection\u3002",
|
|
789
|
+
annotations: { readOnlyHint: true }
|
|
790
|
+
},
|
|
791
|
+
async () => guard(async () => {
|
|
792
|
+
const selection2 = services.getSelection();
|
|
793
|
+
return selection2 ? text(selection2) : text("no selection");
|
|
794
|
+
})
|
|
795
|
+
);
|
|
796
|
+
server.registerTool(
|
|
797
|
+
"canvas_annotations",
|
|
798
|
+
{
|
|
799
|
+
title: "\u672A\u5904\u7406\u6279\u6CE8",
|
|
800
|
+
description: "\u5217\u51FA\u753B\u5E03\u4E0A\u72B6\u6001\u4E3A open(\u672A\u5904\u7406)\u7684\u6279\u6CE8:\u8FD4\u56DE JSON \u6570\u7EC4,\u6BCF\u9879\u542B id\u3001text(\u7528\u6237\u5199\u7684\u8BDD)\u3001artboardId\u3001anchor(\u951A\u5B9A\u7684\u5143\u7D20\u4E0E\u4F4D\u7F6E)\u3001refs(\u9644\u5E26\u7684\u53C2\u8003\u56FE\u8DEF\u5F84)\u3001createdAt\u3002\u8FD9\u4E9B\u5C31\u662F\u7528\u6237\u5E0C\u671B\u4F60\u6539\u7684\u5730\u65B9\u3002",
|
|
801
|
+
annotations: { readOnlyHint: true }
|
|
802
|
+
},
|
|
803
|
+
async () => guard(async () => {
|
|
804
|
+
const all = await services.getAnnotations();
|
|
805
|
+
return text(all.filter((a) => a.status === "open"));
|
|
806
|
+
})
|
|
807
|
+
);
|
|
808
|
+
return server;
|
|
809
|
+
}
|
|
810
|
+
function sendJsonRpcError(res, status, code, message) {
|
|
811
|
+
if (res.headersSent) {
|
|
812
|
+
res.end();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
816
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
|
|
817
|
+
}
|
|
818
|
+
function createMcpHandler(services) {
|
|
819
|
+
return async function handleMcpRequest(req, res) {
|
|
820
|
+
if (req.method !== "POST") {
|
|
821
|
+
sendJsonRpcError(res, 405, -32e3, "Method not allowed.");
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const server = buildServer(services);
|
|
825
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
826
|
+
res.on("close", () => {
|
|
827
|
+
void transport.close().catch(() => {
|
|
828
|
+
});
|
|
829
|
+
void server.close().catch(() => {
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
try {
|
|
833
|
+
await server.connect(transport);
|
|
834
|
+
await transport.handleRequest(req, res);
|
|
835
|
+
} catch (err) {
|
|
836
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
837
|
+
sendJsonRpcError(res, 500, -32603, `Internal server error: ${msg}`);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/shot/index.ts
|
|
843
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
844
|
+
import path3 from "node:path";
|
|
845
|
+
import { chromium } from "playwright-core";
|
|
846
|
+
var IDLE_MS = 6e4;
|
|
847
|
+
var NAV_TIMEOUT_MS = 3e4;
|
|
848
|
+
var browserPromise = null;
|
|
849
|
+
var idleTimer = null;
|
|
850
|
+
async function getBrowser() {
|
|
851
|
+
if (!browserPromise) {
|
|
852
|
+
browserPromise = chromium.launch({ channel: "msedge", headless: true }).catch((err) => {
|
|
853
|
+
browserPromise = null;
|
|
854
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
855
|
+
throw new Error(
|
|
856
|
+
`contactsheet: \u542F\u52A8 Microsoft Edge \u5931\u8D25(playwright-core channel=msedge)\u3002\u8BF7\u786E\u8BA4\u672C\u673A\u88C5\u4E86 Edge\u3002\u539F\u59CB\u9519\u8BEF:${msg}`
|
|
857
|
+
);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return browserPromise;
|
|
861
|
+
}
|
|
862
|
+
function scheduleIdleClose() {
|
|
863
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
864
|
+
idleTimer = setTimeout(() => {
|
|
865
|
+
idleTimer = null;
|
|
866
|
+
void closeBrowser();
|
|
867
|
+
}, IDLE_MS);
|
|
868
|
+
idleTimer.unref?.();
|
|
869
|
+
}
|
|
870
|
+
async function closeBrowser() {
|
|
871
|
+
if (idleTimer) {
|
|
872
|
+
clearTimeout(idleTimer);
|
|
873
|
+
idleTimer = null;
|
|
874
|
+
}
|
|
875
|
+
const pending = browserPromise;
|
|
876
|
+
browserPromise = null;
|
|
877
|
+
if (!pending) return;
|
|
878
|
+
try {
|
|
879
|
+
const browser = await pending;
|
|
880
|
+
await browser.close();
|
|
881
|
+
} catch {
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async function captureUrl(opts) {
|
|
885
|
+
const browser = await getBrowser();
|
|
886
|
+
const context = await browser.newContext({ viewport: { width: opts.width, height: opts.height } });
|
|
887
|
+
try {
|
|
888
|
+
const page = await context.newPage();
|
|
889
|
+
try {
|
|
890
|
+
await page.goto(opts.url, { waitUntil: "networkidle", timeout: NAV_TIMEOUT_MS });
|
|
891
|
+
} catch {
|
|
892
|
+
await page.waitForLoadState("domcontentloaded").catch(() => {
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
if (opts.settleMs > 0) await page.waitForTimeout(opts.settleMs);
|
|
896
|
+
return await page.screenshot({ fullPage: opts.fullPage, type: "png" });
|
|
897
|
+
} finally {
|
|
898
|
+
await context.close();
|
|
899
|
+
scheduleIdleClose();
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
async function fetchRegistry(cfg) {
|
|
903
|
+
const url = `${cfg.target.replace(/\/+$/, "")}/__cs/registry`;
|
|
904
|
+
let res;
|
|
905
|
+
try {
|
|
906
|
+
res = await fetch(url);
|
|
907
|
+
} catch (err) {
|
|
908
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
909
|
+
throw new Error(
|
|
910
|
+
`contactsheet: \u8BFB\u4E0D\u5230\u6CE8\u518C\u8868 ${url}\u3002\u786E\u8BA4\u4F60\u7684 next dev \u6B63\u5728\u8DD1,\u5E76\u4E14 contactsheet \u5DF2\u6CE8\u5165\u8DEF\u7531(\u5728\u9879\u76EE\u91CC\u8DD1 npx contactsheet)\u3002\u539F\u59CB\u9519\u8BEF:${msg}`
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
if (!res.ok) {
|
|
914
|
+
throw new Error(
|
|
915
|
+
`contactsheet: \u6CE8\u518C\u8868 ${url} \u8FD4\u56DE ${res.status}\u3002\u6CE8\u5165\u8DEF\u7531\u53EF\u80FD\u6CA1\u751F\u6548,\u91CD\u8DD1 npx contactsheet \u8BA9\u5B83\u91CD\u5199\u6CE8\u5165\u6587\u4EF6\u3002`
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return await res.json();
|
|
919
|
+
}
|
|
920
|
+
function safeFileName(id) {
|
|
921
|
+
return id.replace(/[/\\:*?"<>|]/g, "_");
|
|
922
|
+
}
|
|
923
|
+
function pngSize(buf) {
|
|
924
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
925
|
+
}
|
|
926
|
+
async function save(cfg, name, buf) {
|
|
927
|
+
const file = `${safeFileName(name)}.png`;
|
|
928
|
+
const abs = path3.join(cfg.projectRoot, cfg.designDir, ".canvas", "shots", file);
|
|
929
|
+
await mkdir(path3.dirname(abs), { recursive: true });
|
|
930
|
+
await writeFile(abs, buf);
|
|
931
|
+
return `${cfg.designDir.split(path3.sep).join("/").replace(/\/+$/, "")}/.canvas/shots/${file}`;
|
|
932
|
+
}
|
|
933
|
+
async function screenshot(cfg, req) {
|
|
934
|
+
let buf;
|
|
935
|
+
let name;
|
|
936
|
+
if (req.id) {
|
|
937
|
+
const entries = await fetchRegistry(cfg);
|
|
938
|
+
const entry = entries.find((e) => e.id === req.id);
|
|
939
|
+
if (!entry) {
|
|
940
|
+
const known = entries.slice(0, 10).map((e) => e.id).join(", ");
|
|
941
|
+
throw new Error(
|
|
942
|
+
`contactsheet: \u6CE8\u518C\u8868\u91CC\u6CA1\u6709\u753B\u677F "${req.id}"\u3002\u5DF2\u77E5 id:${known || "(\u7A7A)"}${entries.length > 10 ? " \u2026" : ""}`
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
const base = `http://localhost:${cfg.port}`;
|
|
946
|
+
let url;
|
|
947
|
+
if (entry.kind === "screen") {
|
|
948
|
+
url = `${base}${entry.url ?? "/"}`;
|
|
949
|
+
} else {
|
|
950
|
+
url = `${base}/__cs/ab/${encodeURIComponent(entry.id)}`;
|
|
951
|
+
if (req.args) url += `?args=${encodeURIComponent(JSON.stringify(req.args))}`;
|
|
952
|
+
}
|
|
953
|
+
buf = await captureUrl({
|
|
954
|
+
url,
|
|
955
|
+
width: entry.env?.width ?? 480,
|
|
956
|
+
height: 900,
|
|
957
|
+
fullPage: true,
|
|
958
|
+
settleMs: 50
|
|
959
|
+
});
|
|
960
|
+
name = entry.id;
|
|
961
|
+
} else {
|
|
962
|
+
buf = await captureUrl({
|
|
963
|
+
url: `http://localhost:${cfg.port}/__cs`,
|
|
964
|
+
width: 2400,
|
|
965
|
+
height: 1350,
|
|
966
|
+
fullPage: false,
|
|
967
|
+
settleMs: 3e3
|
|
968
|
+
});
|
|
969
|
+
name = "wall";
|
|
970
|
+
}
|
|
971
|
+
const relPath = await save(cfg, name, buf);
|
|
972
|
+
const { width, height } = pngSize(buf);
|
|
973
|
+
return { path: relPath, base64: buf.toString("base64"), width, height };
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/server/api.ts
|
|
977
|
+
import { randomUUID } from "node:crypto";
|
|
978
|
+
|
|
979
|
+
// src/server/assets.ts
|
|
980
|
+
import fs3 from "node:fs";
|
|
981
|
+
import path4 from "node:path";
|
|
982
|
+
import { fileURLToPath } from "node:url";
|
|
983
|
+
var here = path4.dirname(fileURLToPath(import.meta.url));
|
|
984
|
+
var pkgRoot = path4.resolve(here, "..");
|
|
985
|
+
var DIRS = [
|
|
986
|
+
path4.join(here, "canvas"),
|
|
987
|
+
// 发布形态:dist/cli.js 旁边的 dist/canvas
|
|
988
|
+
path4.join(pkgRoot, "dist", "canvas"),
|
|
989
|
+
path4.join(pkgRoot, "src", "canvas")
|
|
990
|
+
// 开发期直读源码里的 index.html / style.css
|
|
991
|
+
];
|
|
992
|
+
function findUiFile(name) {
|
|
993
|
+
for (const dir of DIRS) {
|
|
994
|
+
const p = path4.join(dir, name);
|
|
995
|
+
if (fs3.existsSync(p) && fs3.statSync(p).isFile()) return p;
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
var MIME = {
|
|
1000
|
+
".html": "text/html; charset=utf-8",
|
|
1001
|
+
".js": "text/javascript; charset=utf-8",
|
|
1002
|
+
".css": "text/css; charset=utf-8",
|
|
1003
|
+
".map": "application/json; charset=utf-8",
|
|
1004
|
+
".json": "application/json; charset=utf-8",
|
|
1005
|
+
".png": "image/png",
|
|
1006
|
+
".svg": "image/svg+xml",
|
|
1007
|
+
".jpg": "image/jpeg",
|
|
1008
|
+
".jpeg": "image/jpeg",
|
|
1009
|
+
".gif": "image/gif",
|
|
1010
|
+
".webp": "image/webp"
|
|
1011
|
+
};
|
|
1012
|
+
function mimeOf(file) {
|
|
1013
|
+
return MIME[path4.extname(file)] ?? "application/octet-stream";
|
|
1014
|
+
}
|
|
1015
|
+
function pkgVersion() {
|
|
1016
|
+
try {
|
|
1017
|
+
const pkg = JSON.parse(fs3.readFileSync(path4.join(pkgRoot, "package.json"), "utf8"));
|
|
1018
|
+
return pkg.version ?? "0.0.0";
|
|
1019
|
+
} catch {
|
|
1020
|
+
return "0.0.0";
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/server/push.ts
|
|
1025
|
+
import fs4 from "node:fs/promises";
|
|
1026
|
+
import net from "node:net";
|
|
1027
|
+
import os from "node:os";
|
|
1028
|
+
import path5 from "node:path";
|
|
1029
|
+
function isLive(sock) {
|
|
1030
|
+
return new Promise((resolve2) => {
|
|
1031
|
+
const s = new net.Socket();
|
|
1032
|
+
const done = (ok) => {
|
|
1033
|
+
s.destroy();
|
|
1034
|
+
resolve2(ok);
|
|
1035
|
+
};
|
|
1036
|
+
s.on("connect", () => done(true));
|
|
1037
|
+
s.on("error", () => done(false));
|
|
1038
|
+
s.setTimeout(250, () => done(false));
|
|
1039
|
+
s.connect({ path: sock });
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
async function listTargets(cfg) {
|
|
1043
|
+
const root = path5.resolve(cfg.projectRoot);
|
|
1044
|
+
const dir = path5.join(os.homedir(), ".claude", "sessions");
|
|
1045
|
+
let names;
|
|
1046
|
+
try {
|
|
1047
|
+
names = await fs4.readdir(dir);
|
|
1048
|
+
} catch {
|
|
1049
|
+
return [];
|
|
1050
|
+
}
|
|
1051
|
+
const out = [];
|
|
1052
|
+
await Promise.all(
|
|
1053
|
+
names.filter((n) => n.endsWith(".json")).map(async (n) => {
|
|
1054
|
+
let rec;
|
|
1055
|
+
try {
|
|
1056
|
+
rec = JSON.parse(await fs4.readFile(path5.join(dir, n), "utf8"));
|
|
1057
|
+
} catch {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (!rec.pid || !rec.cwd || !rec.messagingSocketPath) return;
|
|
1061
|
+
if (rec.kind && rec.kind !== "interactive") return;
|
|
1062
|
+
const rel = path5.relative(root, path5.resolve(rec.cwd));
|
|
1063
|
+
if (rel.startsWith("..") || path5.isAbsolute(rel)) return;
|
|
1064
|
+
if (!await isLive(rec.messagingSocketPath)) return;
|
|
1065
|
+
out.push({
|
|
1066
|
+
pid: rec.pid,
|
|
1067
|
+
name: rec.name ?? `pid ${rec.pid}`,
|
|
1068
|
+
status: rec.status ?? "unknown",
|
|
1069
|
+
cwd: rec.cwd,
|
|
1070
|
+
socket: rec.messagingSocketPath,
|
|
1071
|
+
updatedAt: rec.updatedAt ?? 0
|
|
1072
|
+
});
|
|
1073
|
+
})
|
|
1074
|
+
);
|
|
1075
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1076
|
+
return out;
|
|
1077
|
+
}
|
|
1078
|
+
function inject(sock, content) {
|
|
1079
|
+
return new Promise((resolve2, reject) => {
|
|
1080
|
+
const s = net.createConnection({ path: sock });
|
|
1081
|
+
s.setTimeout(3e3, () => {
|
|
1082
|
+
s.destroy();
|
|
1083
|
+
reject(new Error("\u5199\u5165\u8D85\u65F6"));
|
|
1084
|
+
});
|
|
1085
|
+
s.on("error", reject);
|
|
1086
|
+
s.on("connect", () => {
|
|
1087
|
+
const line = JSON.stringify({ type: "user", message: { role: "user", content } }) + "\n";
|
|
1088
|
+
s.end(line, () => resolve2());
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
async function pushToSession(cfg, text2, pid) {
|
|
1093
|
+
const targets = await listTargets(cfg);
|
|
1094
|
+
if (!targets.length) {
|
|
1095
|
+
return {
|
|
1096
|
+
ok: false,
|
|
1097
|
+
reason: `\u6CA1\u627E\u5230\u5DE5\u4F5C\u76EE\u5F55\u5728 ${cfg.projectRoot} \u4E0B\u7684 Claude Code \u4F1A\u8BDD(\u8981\u5148\u5728\u9879\u76EE\u76EE\u5F55\u91CC\u5F00\u7740 claude)`
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
let target;
|
|
1101
|
+
if (pid !== void 0) {
|
|
1102
|
+
target = targets.find((t) => t.pid === pid);
|
|
1103
|
+
if (!target) return { ok: false, reason: `\u4F1A\u8BDD pid ${pid} \u5DF2\u4E0D\u5728(\u53EF\u80FD\u521A\u5173\u6389),\u91CD\u6309 p \u91CD\u9009` };
|
|
1104
|
+
} else if (targets.length === 1) {
|
|
1105
|
+
target = targets[0];
|
|
1106
|
+
} else {
|
|
1107
|
+
return {
|
|
1108
|
+
ok: false,
|
|
1109
|
+
choose: targets.map(({ pid: p, name, status, cwd }) => ({ pid: p, name, status, cwd }))
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const body = `[contactsheet] \u7528\u6237\u5728\u753B\u5E03\u4E0A\u6309\u4E0B\u4E86\u63A8\u9001\u952E,\u4EE5\u4E0B\u662F\u5F53\u524D\u753B\u5E03\u4E0A\u4E0B\u6587,\u8BF7\u6309\u6279\u6CE8\u5904\u7406:
|
|
1113
|
+
|
|
1114
|
+
` + text2;
|
|
1115
|
+
await inject(target.socket, body);
|
|
1116
|
+
return { ok: true, pid: target.pid, name: target.name };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// src/server/sse.ts
|
|
1120
|
+
var clients = /* @__PURE__ */ new Set();
|
|
1121
|
+
var heartbeat = null;
|
|
1122
|
+
function attach(req, res) {
|
|
1123
|
+
res.writeHead(200, {
|
|
1124
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1125
|
+
"cache-control": "no-cache, no-transform",
|
|
1126
|
+
connection: "keep-alive",
|
|
1127
|
+
"x-accel-buffering": "no"
|
|
1128
|
+
});
|
|
1129
|
+
res.write(": contactsheet connected\n\n");
|
|
1130
|
+
clients.add(res);
|
|
1131
|
+
const drop = () => {
|
|
1132
|
+
clients.delete(res);
|
|
1133
|
+
if (clients.size === 0) stopHeartbeat();
|
|
1134
|
+
};
|
|
1135
|
+
req.on("close", drop);
|
|
1136
|
+
req.on("error", drop);
|
|
1137
|
+
res.on("close", drop);
|
|
1138
|
+
startHeartbeat();
|
|
1139
|
+
}
|
|
1140
|
+
function broadcast(ev) {
|
|
1141
|
+
const chunk = `event: ${ev.type}
|
|
1142
|
+
data: ${JSON.stringify(ev)}
|
|
1143
|
+
|
|
1144
|
+
`;
|
|
1145
|
+
for (const res of clients) {
|
|
1146
|
+
try {
|
|
1147
|
+
res.write(chunk);
|
|
1148
|
+
} catch {
|
|
1149
|
+
clients.delete(res);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
function closeAll() {
|
|
1154
|
+
for (const res of clients) {
|
|
1155
|
+
try {
|
|
1156
|
+
res.end();
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
clients.clear();
|
|
1161
|
+
stopHeartbeat();
|
|
1162
|
+
}
|
|
1163
|
+
function startHeartbeat() {
|
|
1164
|
+
if (heartbeat) return;
|
|
1165
|
+
heartbeat = setInterval(() => {
|
|
1166
|
+
for (const res of clients) res.write(": ping\n\n");
|
|
1167
|
+
}, 15e3);
|
|
1168
|
+
heartbeat.unref();
|
|
1169
|
+
}
|
|
1170
|
+
function stopHeartbeat() {
|
|
1171
|
+
if (!heartbeat) return;
|
|
1172
|
+
clearInterval(heartbeat);
|
|
1173
|
+
heartbeat = null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// src/server/store.ts
|
|
1177
|
+
import fs5 from "node:fs/promises";
|
|
1178
|
+
import path6 from "node:path";
|
|
1179
|
+
var selection = null;
|
|
1180
|
+
function getSelection() {
|
|
1181
|
+
return selection;
|
|
1182
|
+
}
|
|
1183
|
+
function setSelection(next) {
|
|
1184
|
+
selection = next;
|
|
1185
|
+
}
|
|
1186
|
+
function canvasDir(cfg) {
|
|
1187
|
+
return path6.join(cfg.projectRoot, cfg.designDir, ".canvas");
|
|
1188
|
+
}
|
|
1189
|
+
function annotationsFile(cfg) {
|
|
1190
|
+
return path6.join(canvasDir(cfg), "annotations.json");
|
|
1191
|
+
}
|
|
1192
|
+
async function readAnnotations(cfg) {
|
|
1193
|
+
let raw;
|
|
1194
|
+
try {
|
|
1195
|
+
raw = await fs5.readFile(annotationsFile(cfg), "utf8");
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
if (err.code === "ENOENT") return [];
|
|
1198
|
+
throw err;
|
|
1199
|
+
}
|
|
1200
|
+
if (!raw.trim()) return [];
|
|
1201
|
+
let list;
|
|
1202
|
+
try {
|
|
1203
|
+
list = JSON.parse(raw);
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
`annotations.json \u89E3\u6790\u5931\u8D25(${err.message})\u3002\u4E3A\u907F\u514D\u8986\u76D6\u5386\u53F2\u5DF2\u62D2\u7EDD\u5199\u5165\u2014\u2014\u8BF7\u4FEE\u597D ${annotationsFile(cfg)} \u518D\u8BD5\u3002`
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
if (!Array.isArray(list)) {
|
|
1210
|
+
throw new Error(`annotations.json \u9876\u5C42\u4E0D\u662F\u6570\u7EC4,\u62D2\u7EDD\u5199\u5165\u4EE5\u514D\u8986\u76D6\u5386\u53F2:${annotationsFile(cfg)}`);
|
|
1211
|
+
}
|
|
1212
|
+
const anns = list;
|
|
1213
|
+
let next = anns.reduce((m, a) => Math.max(m, a.seq ?? 0), 0);
|
|
1214
|
+
for (const a of anns) if (typeof a.seq !== "number") a.seq = ++next;
|
|
1215
|
+
return anns;
|
|
1216
|
+
}
|
|
1217
|
+
async function writeAnnotations(cfg, list) {
|
|
1218
|
+
const file = annotationsFile(cfg);
|
|
1219
|
+
await fs5.mkdir(path6.dirname(file), { recursive: true });
|
|
1220
|
+
await fs5.copyFile(file, `${file}.bak`).catch(() => void 0);
|
|
1221
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1222
|
+
await fs5.writeFile(tmp, JSON.stringify(list, null, 2) + "\n", "utf8");
|
|
1223
|
+
await fs5.rename(tmp, file);
|
|
1224
|
+
}
|
|
1225
|
+
var chain = Promise.resolve();
|
|
1226
|
+
function updateAnnotations(cfg, fn) {
|
|
1227
|
+
const run = chain.then(async () => {
|
|
1228
|
+
const list = await readAnnotations(cfg);
|
|
1229
|
+
const result = fn(list);
|
|
1230
|
+
await writeAnnotations(cfg, list);
|
|
1231
|
+
return { result, list };
|
|
1232
|
+
});
|
|
1233
|
+
chain = run.catch(() => void 0);
|
|
1234
|
+
return run;
|
|
1235
|
+
}
|
|
1236
|
+
function newAnnotationId(list) {
|
|
1237
|
+
const base = "a" + Date.now().toString(36);
|
|
1238
|
+
let id = base;
|
|
1239
|
+
for (let n = 1; list.some((a) => a.id === id); n++) id = base + n.toString(36);
|
|
1240
|
+
return id;
|
|
1241
|
+
}
|
|
1242
|
+
async function saveRef(cfg, name, dataBase64) {
|
|
1243
|
+
const safe = path6.basename(name).replace(/[^\w.-]+/g, "-").replace(/^[.-]+/, "") || "ref.png";
|
|
1244
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1245
|
+
const dir = path6.join(canvasDir(cfg), "refs");
|
|
1246
|
+
await fs5.mkdir(dir, { recursive: true });
|
|
1247
|
+
const ext = path6.extname(safe);
|
|
1248
|
+
const stem = safe.slice(0, safe.length - ext.length);
|
|
1249
|
+
let filename = `${day}-${safe}`;
|
|
1250
|
+
for (let n = 2; await exists(path6.join(dir, filename)); n++) filename = `${day}-${stem}-${n}${ext}`;
|
|
1251
|
+
const body = dataBase64.replace(/^data:[^;,]*;base64,/, "");
|
|
1252
|
+
await fs5.writeFile(path6.join(dir, filename), Buffer.from(body, "base64"));
|
|
1253
|
+
return `${cfg.designDir}/.canvas/refs/${filename}`;
|
|
1254
|
+
}
|
|
1255
|
+
async function readRef(cfg, rel) {
|
|
1256
|
+
const root = path6.join(canvasDir(cfg), "refs");
|
|
1257
|
+
const abs = path6.resolve(cfg.projectRoot, rel);
|
|
1258
|
+
if (abs !== root && !abs.startsWith(root + path6.sep)) return null;
|
|
1259
|
+
try {
|
|
1260
|
+
return { body: await fs5.readFile(abs), file: abs };
|
|
1261
|
+
} catch {
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
async function exists(p) {
|
|
1266
|
+
try {
|
|
1267
|
+
await fs5.access(p);
|
|
1268
|
+
return true;
|
|
1269
|
+
} catch {
|
|
1270
|
+
return false;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
async function buildContextText(cfg) {
|
|
1274
|
+
const open = (await readAnnotations(cfg)).filter((a) => a.status === "open");
|
|
1275
|
+
const sel = getSelection();
|
|
1276
|
+
if (!sel && open.length === 0) return "";
|
|
1277
|
+
const lines = ["## contactsheet \u753B\u5E03\u4E0A\u4E0B\u6587"];
|
|
1278
|
+
if (sel) {
|
|
1279
|
+
lines.push(`\u5F53\u524D\u9009\u4E2D:\u753B\u677F ${sel.artboardId} \xB7 \u9009\u62E9\u5668 \`${sel.selector}\` \xB7 \u70B9\u4F4D ${fmt(sel.x)},${fmt(sel.y)}`);
|
|
1280
|
+
}
|
|
1281
|
+
if (open.length > 0) {
|
|
1282
|
+
lines.push(`\u672A\u89E3\u51B3\u6279\u6CE8(${open.length} \u6761):`);
|
|
1283
|
+
for (const a of open) {
|
|
1284
|
+
const where = [a.artboardId, a.anchor?.selector].filter(Boolean).join(" @ ");
|
|
1285
|
+
lines.push(`- #${a.seq} [${a.id}] ${where ? where + " \u2014\u2014 " : ""}${a.text}`);
|
|
1286
|
+
for (const ref of a.refs ?? []) lines.push(` \u53C2\u8003\u56FE:${ref}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return lines.join("\n") + "\n";
|
|
1290
|
+
}
|
|
1291
|
+
function fmt(n) {
|
|
1292
|
+
return typeof n === "number" && Number.isFinite(n) ? n.toFixed(2) : "?";
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// src/server/api.ts
|
|
1296
|
+
var ANN = "/__cs/api/annotations";
|
|
1297
|
+
var TOKEN = null;
|
|
1298
|
+
function pushToken() {
|
|
1299
|
+
if (!TOKEN) TOKEN = randomUUID();
|
|
1300
|
+
return TOKEN;
|
|
1301
|
+
}
|
|
1302
|
+
function originAllowed(cfg, req) {
|
|
1303
|
+
const origin = req.headers.origin;
|
|
1304
|
+
if (!origin) return true;
|
|
1305
|
+
const ok = /* @__PURE__ */ new Set([
|
|
1306
|
+
`http://localhost:${cfg.port}`,
|
|
1307
|
+
`http://127.0.0.1:${cfg.port}`,
|
|
1308
|
+
`http://[::1]:${cfg.port}`
|
|
1309
|
+
]);
|
|
1310
|
+
return ok.has(origin);
|
|
1311
|
+
}
|
|
1312
|
+
async function handleApi(cfg, req, res, pathname) {
|
|
1313
|
+
const method = req.method ?? "GET";
|
|
1314
|
+
if (method !== "GET" && !originAllowed(cfg, req)) {
|
|
1315
|
+
return sendText(res, 403, "contactsheet: \u62D2\u7EDD\u8DE8\u6E90\u5199\u5165(Origin \u4E0D\u662F\u672C\u673A\u753B\u5E03)");
|
|
1316
|
+
}
|
|
1317
|
+
if (pathname === "/__cs/api/state") {
|
|
1318
|
+
if (method !== "GET") return sendText(res, 405, "method not allowed");
|
|
1319
|
+
const info = {
|
|
1320
|
+
version: pkgVersion(),
|
|
1321
|
+
target: cfg.target,
|
|
1322
|
+
designDir: cfg.designDir,
|
|
1323
|
+
projectRoot: cfg.projectRoot
|
|
1324
|
+
};
|
|
1325
|
+
return sendJson(res, 200, info);
|
|
1326
|
+
}
|
|
1327
|
+
if (pathname === "/__cs/api/selection") {
|
|
1328
|
+
if (method === "GET") return sendJson(res, 200, getSelection());
|
|
1329
|
+
if (method === "POST") {
|
|
1330
|
+
const body = await readJson(req);
|
|
1331
|
+
setSelection(body && typeof body.artboardId === "string" ? body : null);
|
|
1332
|
+
return sendEmpty(res, 204);
|
|
1333
|
+
}
|
|
1334
|
+
return sendText(res, 405, "method not allowed");
|
|
1335
|
+
}
|
|
1336
|
+
if (pathname === ANN || pathname.startsWith(ANN + "/")) {
|
|
1337
|
+
return handleAnnotations(cfg, req, res, pathname, method);
|
|
1338
|
+
}
|
|
1339
|
+
if (pathname.startsWith("/__cs/api/refs/")) {
|
|
1340
|
+
if (method !== "GET") return sendText(res, 405, "method not allowed");
|
|
1341
|
+
const rel = pathname.slice("/__cs/api/refs/".length).split("/").map(decodeURIComponent).join("/");
|
|
1342
|
+
const hit = await readRef(cfg, rel);
|
|
1343
|
+
if (!hit) return sendText(res, 404, "contactsheet: \u627E\u4E0D\u5230\u53C2\u8003\u56FE");
|
|
1344
|
+
res.writeHead(200, { "content-type": mimeOf(hit.file), "cache-control": "no-store" });
|
|
1345
|
+
res.end(hit.body);
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (pathname === "/__cs/api/refs") {
|
|
1349
|
+
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
1350
|
+
const body = await readJson(req);
|
|
1351
|
+
if (!body?.dataBase64) return sendText(res, 400, "\u7F3A\u5C11 dataBase64");
|
|
1352
|
+
const rel = await saveRef(cfg, body.name || "ref.png", body.dataBase64);
|
|
1353
|
+
return sendJson(res, 200, { path: rel });
|
|
1354
|
+
}
|
|
1355
|
+
if (pathname === "/__cs/api/context") {
|
|
1356
|
+
if (method !== "GET") return sendText(res, 405, "method not allowed");
|
|
1357
|
+
try {
|
|
1358
|
+
return sendText(res, 200, await buildContextText(cfg));
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
return sendText(res, 200, `[contactsheet] \u8BFB\u6279\u6CE8\u5931\u8D25:${errText(err)}`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (pathname === "/__cs/api/push") {
|
|
1364
|
+
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
1365
|
+
if (req.headers["x-cs-token"] !== pushToken()) {
|
|
1366
|
+
return sendText(res, 403, "contactsheet: \u7F3A\u5C11\u753B\u5E03 token,\u62D2\u7EDD\u63A8\u9001(\u8BF7\u4ECE\u753B\u5E03\u9875\u64CD\u4F5C)");
|
|
1367
|
+
}
|
|
1368
|
+
const body = await readJson(req);
|
|
1369
|
+
try {
|
|
1370
|
+
const text2 = await buildContextText(cfg);
|
|
1371
|
+
if (!text2.trim()) return sendJson(res, 200, { ok: false, reason: "\u6CA1\u6709\u5F85\u5904\u7406\u7684\u6279\u6CE8\u6216\u9009\u4E2D,\u5148\u6309 c \u9489\u4E00\u6761" });
|
|
1372
|
+
return sendJson(res, 200, await pushToSession(cfg, text2, body?.pid));
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
return sendJson(res, 200, { ok: false, reason: `\u6CE8\u5165\u5931\u8D25:${errText(err)}` });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (pathname === "/__cs/api/screenshot") {
|
|
1378
|
+
if (method !== "POST") return sendText(res, 405, "method not allowed");
|
|
1379
|
+
const body = await readJson(req);
|
|
1380
|
+
try {
|
|
1381
|
+
return sendJson(res, 200, await screenshot(cfg, body ?? {}));
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
return sendText(res, 500, `screenshot \u5931\u8D25:${errText(err)}`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return sendText(res, 404, "not found");
|
|
1387
|
+
}
|
|
1388
|
+
async function handleAnnotations(cfg, req, res, pathname, method) {
|
|
1389
|
+
const id = pathname === ANN ? "" : decodeURIComponent(pathname.slice(ANN.length + 1));
|
|
1390
|
+
if (!id && method === "GET") {
|
|
1391
|
+
return sendJson(res, 200, await readAnnotations(cfg));
|
|
1392
|
+
}
|
|
1393
|
+
if (!id && method === "POST") {
|
|
1394
|
+
const body = await readJson(req);
|
|
1395
|
+
const { result, list } = await updateAnnotations(cfg, (all) => {
|
|
1396
|
+
const ann = {
|
|
1397
|
+
id: newAnnotationId(all),
|
|
1398
|
+
// 永久序号:全表 max+1,包含 verified 的 —— 号只涨不复用,核验消失也不让后来者顶号
|
|
1399
|
+
seq: all.reduce((m, a) => Math.max(m, a.seq ?? 0), 0) + 1,
|
|
1400
|
+
artboardId: body?.artboardId,
|
|
1401
|
+
anchor: body?.anchor,
|
|
1402
|
+
text: typeof body?.text === "string" ? body.text : "",
|
|
1403
|
+
refs: Array.isArray(body?.refs) ? body.refs : [],
|
|
1404
|
+
status: body?.status === "resolved" ? "resolved" : "open",
|
|
1405
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1406
|
+
};
|
|
1407
|
+
all.push(ann);
|
|
1408
|
+
return ann;
|
|
1409
|
+
});
|
|
1410
|
+
broadcast({ type: "annotations", annotations: list });
|
|
1411
|
+
return sendJson(res, 201, result);
|
|
1412
|
+
}
|
|
1413
|
+
if (id && method === "PATCH") {
|
|
1414
|
+
const patch = await readJson(req);
|
|
1415
|
+
const { result, list } = await updateAnnotations(cfg, (all) => {
|
|
1416
|
+
const hit = all.find((a) => a.id === id);
|
|
1417
|
+
if (!hit) return null;
|
|
1418
|
+
Object.assign(hit, patch, { id: hit.id, createdAt: hit.createdAt });
|
|
1419
|
+
return hit;
|
|
1420
|
+
});
|
|
1421
|
+
if (!result) return sendText(res, 404, "annotation not found");
|
|
1422
|
+
broadcast({ type: "annotations", annotations: list });
|
|
1423
|
+
return sendJson(res, 200, result);
|
|
1424
|
+
}
|
|
1425
|
+
if (id && method === "DELETE") {
|
|
1426
|
+
const { result, list } = await updateAnnotations(cfg, (all) => {
|
|
1427
|
+
const i = all.findIndex((a) => a.id === id);
|
|
1428
|
+
if (i < 0) return false;
|
|
1429
|
+
all.splice(i, 1);
|
|
1430
|
+
return true;
|
|
1431
|
+
});
|
|
1432
|
+
if (!result) return sendText(res, 404, "annotation not found");
|
|
1433
|
+
broadcast({ type: "annotations", annotations: list });
|
|
1434
|
+
return sendEmpty(res, 204);
|
|
1435
|
+
}
|
|
1436
|
+
return sendText(res, 405, "method not allowed");
|
|
1437
|
+
}
|
|
1438
|
+
function sendJson(res, code, body) {
|
|
1439
|
+
res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1440
|
+
res.end(JSON.stringify(body ?? null));
|
|
1441
|
+
}
|
|
1442
|
+
function sendText(res, code, body) {
|
|
1443
|
+
res.writeHead(code, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1444
|
+
res.end(body);
|
|
1445
|
+
}
|
|
1446
|
+
function sendEmpty(res, code) {
|
|
1447
|
+
res.writeHead(code);
|
|
1448
|
+
res.end();
|
|
1449
|
+
}
|
|
1450
|
+
function errText(err) {
|
|
1451
|
+
return err instanceof Error ? err.message : String(err);
|
|
1452
|
+
}
|
|
1453
|
+
async function readJson(req) {
|
|
1454
|
+
const chunks = [];
|
|
1455
|
+
for await (const c of req) chunks.push(c);
|
|
1456
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
1457
|
+
if (!raw) return null;
|
|
1458
|
+
return JSON.parse(raw);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// src/server/proxy-log.ts
|
|
1462
|
+
var WINDOW_MS = 5e3;
|
|
1463
|
+
function createProxyLog(target) {
|
|
1464
|
+
const windows = /* @__PURE__ */ new Map();
|
|
1465
|
+
let down = false;
|
|
1466
|
+
let silenced = 0;
|
|
1467
|
+
return {
|
|
1468
|
+
fail(err, url) {
|
|
1469
|
+
const code = err.code ?? err.message;
|
|
1470
|
+
if (code === "ECONNREFUSED") {
|
|
1471
|
+
if (down) {
|
|
1472
|
+
silenced++;
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
down = true;
|
|
1476
|
+
console.error(
|
|
1477
|
+
`[contactsheet] \u26A0\uFE0F \u76EE\u6807 ${target} \u8FDE\u4E0D\u4E0A\u4E86 \u2014\u2014 \u8D77 next dev \u540E\u4F1A\u81EA\u52A8\u6062\u590D(\u753B\u5E03\u4F1A\u81EA\u5DF1\u91CD\u8BD5)\u3002\u671F\u95F4\u7684\u4EE3\u7406\u5931\u8D25\u4E0D\u518D\u9010\u6761\u6253\u5370\u3002`
|
|
1478
|
+
);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const key = `${code} ${pathOf(url)}`;
|
|
1482
|
+
const win = windows.get(key);
|
|
1483
|
+
if (win) {
|
|
1484
|
+
win.extra++;
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
console.error(`[contactsheet] \u4EE3\u7406\u5931\u8D25 ${pathOf(url)} ${code}`);
|
|
1488
|
+
const timer = setTimeout(() => {
|
|
1489
|
+
const extra = windows.get(key)?.extra ?? 0;
|
|
1490
|
+
windows.delete(key);
|
|
1491
|
+
if (extra > 0) {
|
|
1492
|
+
console.error(`[contactsheet] (\u8FC7\u53BB ${WINDOW_MS / 1e3} \u79D2\u8FD8\u6709 ${extra} \u6B21\u540C\u7C7B\u5931\u8D25:${key})`);
|
|
1493
|
+
}
|
|
1494
|
+
}, WINDOW_MS);
|
|
1495
|
+
timer.unref();
|
|
1496
|
+
windows.set(key, { extra: 0, timer });
|
|
1497
|
+
},
|
|
1498
|
+
alive() {
|
|
1499
|
+
if (!down) return;
|
|
1500
|
+
down = false;
|
|
1501
|
+
const n = silenced;
|
|
1502
|
+
silenced = 0;
|
|
1503
|
+
console.log(`[contactsheet] \u76EE\u6807 ${target} \u5DF2\u6062\u590D${n > 0 ? `(\u4E0D\u53EF\u8FBE\u671F\u95F4\u538B\u6389 ${n} \u6761\u4EE3\u7406\u5931\u8D25)` : ""}`);
|
|
1504
|
+
},
|
|
1505
|
+
close() {
|
|
1506
|
+
for (const win of windows.values()) clearTimeout(win.timer);
|
|
1507
|
+
windows.clear();
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function pathOf(url) {
|
|
1512
|
+
if (!url) return "(\u65E0 url)";
|
|
1513
|
+
const q = url.indexOf("?");
|
|
1514
|
+
return q < 0 ? url : url.slice(0, q);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/server/index.ts
|
|
1518
|
+
async function startServer(cfg) {
|
|
1519
|
+
const proxy = httpProxy.createProxyServer({ target: cfg.target, ws: true, changeOrigin: true });
|
|
1520
|
+
const proxyLog = createProxyLog(cfg.target);
|
|
1521
|
+
proxy.on("error", (err, req, res) => {
|
|
1522
|
+
proxyLog.fail(err, req?.url);
|
|
1523
|
+
if (!res) return;
|
|
1524
|
+
if ("writeHead" in res) {
|
|
1525
|
+
if (!res.headersSent) res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
|
|
1526
|
+
res.end(downPage(cfg.target));
|
|
1527
|
+
} else {
|
|
1528
|
+
res.destroy();
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
proxy.on("proxyRes", () => proxyLog.alive());
|
|
1532
|
+
proxy.on("open", () => proxyLog.alive());
|
|
1533
|
+
let mcp = null;
|
|
1534
|
+
try {
|
|
1535
|
+
mcp = createMcpHandler(makeServices(cfg));
|
|
1536
|
+
} catch (err) {
|
|
1537
|
+
console.warn(`[contactsheet] MCP \u672A\u5C31\u7EEA(/__cs/mcp \u8FD4\u56DE 501):${errText(err)}`);
|
|
1538
|
+
}
|
|
1539
|
+
const server = http.createServer((req, res) => {
|
|
1540
|
+
handle(cfg, proxy, mcp, req, res).catch((err) => {
|
|
1541
|
+
console.error(`[contactsheet] \u8BF7\u6C42\u5904\u7406\u5931\u8D25 ${req.url}`, err);
|
|
1542
|
+
if (!res.headersSent) sendText(res, 500, errText(err));
|
|
1543
|
+
else res.end();
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
server.on("upgrade", (req, socket, head) => proxy.ws(req, socket, head));
|
|
1547
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1548
|
+
server.on("connection", (s) => {
|
|
1549
|
+
sockets.add(s);
|
|
1550
|
+
s.on("close", () => sockets.delete(s));
|
|
1551
|
+
});
|
|
1552
|
+
const host = cfg.host ?? "127.0.0.1";
|
|
1553
|
+
await new Promise((resolve2, reject) => {
|
|
1554
|
+
server.once("error", reject);
|
|
1555
|
+
server.listen(cfg.port, host, () => {
|
|
1556
|
+
server.off("error", reject);
|
|
1557
|
+
resolve2();
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
1561
|
+
console.warn(
|
|
1562
|
+
`[contactsheet] \u26A0\uFE0F \u76D1\u542C\u5728 ${host}:${cfg.port} \u2014\u2014 \u753B\u5E03\u65E0\u9274\u6743,\u540C\u7F51\u7EDC\u7684\u4EFB\u4F55\u4EBA\u90FD\u80FD\u8BFB\u4F60\u7684\u6279\u6CE8\u3001\u5E76\u5411\u4F60\u7684 Claude Code \u4F1A\u8BDD\u63A8\u9001\u6D88\u606F\u3002\u53EA\u5728\u53EF\u4FE1\u7F51\u7EDC\u8FD9\u4E48\u505A\u3002`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
return {
|
|
1566
|
+
async close() {
|
|
1567
|
+
closeAll();
|
|
1568
|
+
proxyLog.close();
|
|
1569
|
+
proxy.close();
|
|
1570
|
+
await new Promise((resolve2) => {
|
|
1571
|
+
const fallback = setTimeout(resolve2, 2e3);
|
|
1572
|
+
for (const s of sockets) s.destroy();
|
|
1573
|
+
server.closeAllConnections();
|
|
1574
|
+
server.close(() => {
|
|
1575
|
+
clearTimeout(fallback);
|
|
1576
|
+
resolve2();
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
async function handle(cfg, proxy, mcp, req, res) {
|
|
1583
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
1584
|
+
if (pathname === "/__cs" || pathname === "/__cs/") return serveUi(res, "index.html");
|
|
1585
|
+
if (pathname.startsWith("/__cs/ui/")) {
|
|
1586
|
+
const name = pathname.slice("/__cs/ui/".length);
|
|
1587
|
+
if (!name || name.includes("..")) return sendText(res, 400, "bad asset path");
|
|
1588
|
+
return serveUi(res, name);
|
|
1589
|
+
}
|
|
1590
|
+
if (pathname === "/__cs/events") return attach(req, res);
|
|
1591
|
+
if (pathname === "/__cs/mcp") {
|
|
1592
|
+
if (!mcp) return sendText(res, 501, "contactsheet: MCP \u6A21\u5757\u672A\u5C31\u7EEA");
|
|
1593
|
+
return mcp(req, res);
|
|
1594
|
+
}
|
|
1595
|
+
if (pathname.startsWith("/__cs/api/")) return handleApi(cfg, req, res, pathname);
|
|
1596
|
+
proxy.web(req, res);
|
|
1597
|
+
}
|
|
1598
|
+
function serveUi(res, name) {
|
|
1599
|
+
const file = findUiFile(name);
|
|
1600
|
+
if (!file) return sendText(res, 404, `contactsheet: \u627E\u4E0D\u5230\u753B\u5E03\u8D44\u4EA7 ${name},\u5148\u8DD1 node build.mjs`);
|
|
1601
|
+
let body = fs6.readFileSync(file);
|
|
1602
|
+
if (name === "index.html") {
|
|
1603
|
+
body = Buffer.from(
|
|
1604
|
+
body.toString("utf8").replace("</head>", `<meta name="cs-token" content="${pushToken()}" />
|
|
1605
|
+
</head>`)
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
res.writeHead(200, { "content-type": mimeOf(file), "cache-control": "no-store" });
|
|
1609
|
+
res.end(body);
|
|
1610
|
+
}
|
|
1611
|
+
function makeServices(cfg) {
|
|
1612
|
+
return {
|
|
1613
|
+
// registry 由注入进用户 app 的路由提供,直连 target(node fetch 不走系统代理)
|
|
1614
|
+
async getRegistry() {
|
|
1615
|
+
try {
|
|
1616
|
+
const r = await fetch(`${cfg.target}/__cs/registry`);
|
|
1617
|
+
if (!r.ok) return [];
|
|
1618
|
+
const data = await r.json();
|
|
1619
|
+
return Array.isArray(data) ? data : [];
|
|
1620
|
+
} catch {
|
|
1621
|
+
return [];
|
|
1622
|
+
}
|
|
1623
|
+
},
|
|
1624
|
+
getSelection: () => getSelection(),
|
|
1625
|
+
getAnnotations: () => readAnnotations(cfg),
|
|
1626
|
+
takeShot: async (req) => screenshot(cfg, req)
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
function downPage(target) {
|
|
1630
|
+
return `<!doctype html><html lang="zh"><meta charset="utf-8"><title>contactsheet</title>
|
|
1631
|
+
<body style="font:14px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;padding:48px;color:#333;background:#fafafa">
|
|
1632
|
+
<h1 style="font-size:15px;margin:0 0 12px">contactsheet:\u76EE\u6807 ${escapeHtml(target)} \u672A\u542F\u52A8</h1>
|
|
1633
|
+
<p style="margin:0 0 8px">\u5148\u5728\u9879\u76EE\u91CC\u8DD1 <code>next dev</code>,\u518D\u5237\u65B0\u672C\u9875\u3002</p>
|
|
1634
|
+
<p style="margin:0"><a href="/__cs" style="color:#06c">\u2190 \u56DE\u753B\u5E03</a></p>
|
|
1635
|
+
</body></html>`;
|
|
1636
|
+
}
|
|
1637
|
+
function escapeHtml(s) {
|
|
1638
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// src/watch/index.ts
|
|
1642
|
+
import path7 from "node:path";
|
|
1643
|
+
import { watch } from "chokidar";
|
|
1644
|
+
var DEBOUNCE_MS = 200;
|
|
1645
|
+
function isArtboard(p) {
|
|
1646
|
+
return /\.artboard\.tsx?$/.test(p);
|
|
1647
|
+
}
|
|
1648
|
+
async function startWatcher(cfg, onChange) {
|
|
1649
|
+
const designAbs = path7.join(cfg.projectRoot, cfg.designDir.replace(/\\/g, "/").replace(/\/+$/, ""));
|
|
1650
|
+
let timer = null;
|
|
1651
|
+
let closed = false;
|
|
1652
|
+
const flush = () => {
|
|
1653
|
+
timer = null;
|
|
1654
|
+
regenerateRegistry(cfg).then((entries) => {
|
|
1655
|
+
if (!closed) onChange(entries);
|
|
1656
|
+
}).catch((err) => {
|
|
1657
|
+
console.error("[contactsheet] \u91CD\u5EFA registry \u5931\u8D25:", err);
|
|
1658
|
+
});
|
|
1659
|
+
};
|
|
1660
|
+
const schedule = () => {
|
|
1661
|
+
if (closed) return;
|
|
1662
|
+
if (timer) clearTimeout(timer);
|
|
1663
|
+
timer = setTimeout(flush, DEBOUNCE_MS);
|
|
1664
|
+
};
|
|
1665
|
+
const watcher = watch(designAbs, {
|
|
1666
|
+
ignoreInitial: false,
|
|
1667
|
+
// 首次也算一次变更
|
|
1668
|
+
ignored: (p) => {
|
|
1669
|
+
const rel = path7.relative(designAbs, p);
|
|
1670
|
+
if (!rel) return false;
|
|
1671
|
+
return rel.split(path7.sep).some((seg) => seg === "__generated__" || seg === ".canvas" || seg === "node_modules");
|
|
1672
|
+
}
|
|
1673
|
+
});
|
|
1674
|
+
watcher.on("add", (p) => isArtboard(p) && schedule());
|
|
1675
|
+
watcher.on("change", (p) => isArtboard(p) && schedule());
|
|
1676
|
+
watcher.on("unlink", (p) => isArtboard(p) && schedule());
|
|
1677
|
+
watcher.on("error", (err) => console.error("[contactsheet] watcher \u51FA\u9519:", err));
|
|
1678
|
+
schedule();
|
|
1679
|
+
return {
|
|
1680
|
+
async close() {
|
|
1681
|
+
closed = true;
|
|
1682
|
+
if (timer) {
|
|
1683
|
+
clearTimeout(timer);
|
|
1684
|
+
timer = null;
|
|
1685
|
+
}
|
|
1686
|
+
await watcher.close();
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// src/cli.ts
|
|
1692
|
+
var HELP = `contactsheet \u2014\u2014 \u628A UI \u7684\u5404\u79CD\u72B6\u6001\u644A\u5728\u4E00\u9762\u53EF\u7F29\u653E\u7684\u5899\u4E0A
|
|
1693
|
+
|
|
1694
|
+
contactsheet [up] \u9644\u7740\u5230\u5F53\u524D Next.js repo:\u6CE8\u5165\u753B\u677F\u8DEF\u7531 + \u8D77\u5916\u58F3(\u9ED8\u8BA4\u547D\u4EE4)
|
|
1695
|
+
contactsheet init \u521D\u59CB\u5316\u914D\u7F6E\u3001.mcp.json\u3001hook,\u5E76\u81EA\u52A8\u94FA\u4E00\u6279\u753B\u677F
|
|
1696
|
+
contactsheet clean \u79FB\u9664\u6CE8\u5165\u7684\u6587\u4EF6
|
|
1697
|
+
|
|
1698
|
+
--port <n> \u5916\u58F3\u7AEF\u53E3(\u9ED8\u8BA4 5199)
|
|
1699
|
+
--target <url> next dev \u5730\u5740(\u9ED8\u8BA4 http://localhost:3000)
|
|
1700
|
+
--design-dir <dir> \u753B\u677F\u76EE\u5F55(\u9ED8\u8BA4 design)
|
|
1701
|
+
-h, --help \u663E\u793A\u672C\u5E2E\u52A9`;
|
|
1702
|
+
main().catch((err) => {
|
|
1703
|
+
console.error(`[contactsheet] ${err instanceof Error ? err.message : String(err)}`);
|
|
1704
|
+
process.exit(1);
|
|
1705
|
+
});
|
|
1706
|
+
async function main() {
|
|
1707
|
+
const { values, positionals } = parseArgs({
|
|
1708
|
+
args: process.argv.slice(2),
|
|
1709
|
+
options: {
|
|
1710
|
+
port: { type: "string" },
|
|
1711
|
+
target: { type: "string" },
|
|
1712
|
+
"design-dir": { type: "string" },
|
|
1713
|
+
help: { type: "boolean", short: "h" }
|
|
1714
|
+
},
|
|
1715
|
+
allowPositionals: true
|
|
1716
|
+
});
|
|
1717
|
+
if (values.help) {
|
|
1718
|
+
console.log(HELP);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
const flags = {};
|
|
1722
|
+
if (values.port !== void 0) {
|
|
1723
|
+
const port = Number(values.port);
|
|
1724
|
+
if (!Number.isInteger(port)) throw new Error(`--port \u4E0D\u662F\u6574\u6570:${values.port}`);
|
|
1725
|
+
flags.port = port;
|
|
1726
|
+
}
|
|
1727
|
+
if (values.target !== void 0) flags.target = values.target;
|
|
1728
|
+
if (values["design-dir"] !== void 0) flags.designDir = values["design-dir"];
|
|
1729
|
+
const cmd = positionals[0] ?? "up";
|
|
1730
|
+
if (cmd === "up") return up(flags);
|
|
1731
|
+
if (cmd === "init") return runInit(process.cwd(), flags);
|
|
1732
|
+
if (cmd === "clean") return removeInjected(loadConfig(process.cwd(), flags));
|
|
1733
|
+
console.error(`\u672A\u77E5\u547D\u4EE4:${cmd}
|
|
1734
|
+
|
|
1735
|
+
${HELP}`);
|
|
1736
|
+
process.exit(1);
|
|
1737
|
+
}
|
|
1738
|
+
async function up(flags) {
|
|
1739
|
+
const cfg = loadConfig(process.cwd(), flags);
|
|
1740
|
+
await tryStep("\u6CE8\u5165\u753B\u677F\u8DEF\u7531", () => ensureInjected(cfg));
|
|
1741
|
+
const watcher = await tryStep(
|
|
1742
|
+
"\u542F\u52A8\u6587\u4EF6\u76D1\u542C",
|
|
1743
|
+
() => startWatcher(cfg, (entries) => broadcast({ type: "registry", entries }))
|
|
1744
|
+
);
|
|
1745
|
+
const server = await startServer(cfg);
|
|
1746
|
+
banner(cfg);
|
|
1747
|
+
let closing = false;
|
|
1748
|
+
const shutdown = async (sig) => {
|
|
1749
|
+
if (closing) return;
|
|
1750
|
+
closing = true;
|
|
1751
|
+
console.log(`
|
|
1752
|
+
[contactsheet] \u6536\u5230 ${sig},\u6B63\u5728\u9000\u51FA\u2026`);
|
|
1753
|
+
if (watcher) await quiet(() => watcher.close());
|
|
1754
|
+
await quiet(() => server.close());
|
|
1755
|
+
await quiet(() => closeBrowser());
|
|
1756
|
+
process.exit(0);
|
|
1757
|
+
};
|
|
1758
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
1759
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
1760
|
+
}
|
|
1761
|
+
async function tryStep(label, fn) {
|
|
1762
|
+
try {
|
|
1763
|
+
return await fn();
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
console.warn(`[contactsheet] ${label}\u5931\u8D25,\u5DF2\u8DF3\u8FC7:${err instanceof Error ? err.message : String(err)}`);
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
async function quiet(fn) {
|
|
1770
|
+
try {
|
|
1771
|
+
await fn();
|
|
1772
|
+
} catch {
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function banner(cfg) {
|
|
1776
|
+
console.log(`
|
|
1777
|
+
contactsheet http://localhost:${cfg.port}/__cs
|
|
1778
|
+
\u4EE3\u7406\u76EE\u6807 ${cfg.target}
|
|
1779
|
+
\u753B\u677F\u76EE\u5F55 ${cfg.designDir}/ \xB7 app \u76EE\u5F55 ${cfg.appDir}/
|
|
1780
|
+
Ctrl-C \u9000\u51FA
|
|
1781
|
+
`);
|
|
1782
|
+
}
|
|
1783
|
+
//# sourceMappingURL=cli.js.map
|