@loviusc/sa-spec 0.2.6 → 0.2.7
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/README.en.md +17 -1
- package/README.ja.md +17 -1
- package/README.ko.md +17 -1
- package/README.zh-CN.md +17 -1
- package/README.zh-TW.md +30 -1
- package/install.bat +43 -0
- package/install.sh +43 -0
- package/package.json +8 -2
- package/readme.md +30 -1
- package/src/cli.js +176 -0
- package/src/pdf.js +605 -0
- package/templates/AGENTS.md +2 -1
- package/templates/readme.md +30 -1
- package/templates/skills/system-analysis/SKILL.md +2 -1
- package/uninstall.bat +22 -0
- package/uninstall.sh +15 -0
- package/update.bat +43 -0
- package/update.sh +30 -0
package/src/pdf.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { spawnSync } = require("child_process");
|
|
5
|
+
const { pathToFileURL } = require("url");
|
|
6
|
+
|
|
7
|
+
function escapeHtml(str) {
|
|
8
|
+
return String(str)
|
|
9
|
+
.replace(/&/g, "&")
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">")
|
|
12
|
+
.replace(/"/g, """);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseInline(text) {
|
|
16
|
+
if (!text) return "";
|
|
17
|
+
const codeSpans = [];
|
|
18
|
+
let processed = String(text).replace(/`([^`]+)`/g, (_, code) => {
|
|
19
|
+
const idx = codeSpans.length;
|
|
20
|
+
codeSpans.push(`<code>${escapeHtml(code)}</code>`);
|
|
21
|
+
return `\x00CODE_${idx}\x00`;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
processed = escapeHtml(processed);
|
|
25
|
+
|
|
26
|
+
// Images: 
|
|
27
|
+
processed = processed.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
|
|
28
|
+
return `<img alt="${alt}" src="${src}" style="max-width:100%;" />`;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Links: [text](url)
|
|
32
|
+
processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
|
|
33
|
+
return `<a href="${href}">${label}</a>`;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Bold + Italic
|
|
37
|
+
processed = processed.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
38
|
+
// Bold
|
|
39
|
+
processed = processed.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
40
|
+
processed = processed.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
41
|
+
// Italic
|
|
42
|
+
processed = processed.replace(/\*([^*\n]+)\*/g, "<em>$1</em>");
|
|
43
|
+
// Strikethrough
|
|
44
|
+
processed = processed.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
45
|
+
|
|
46
|
+
// Restore inline code spans
|
|
47
|
+
processed = processed.replace(/\x00CODE_(\d+)\x00/g, (_, idx) => codeSpans[Number(idx)]);
|
|
48
|
+
return processed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseTableRow(line) {
|
|
52
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
53
|
+
return trimmed.split("|").map((cell) => cell.trim());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isTableSeparator(line) {
|
|
57
|
+
if (!line || !line.includes("|")) return false;
|
|
58
|
+
const cells = parseTableRow(line);
|
|
59
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getTableAlignments(sepLine) {
|
|
63
|
+
return parseTableRow(sepLine).map((c) => {
|
|
64
|
+
if (c.startsWith(":") && c.endsWith(":")) return "center";
|
|
65
|
+
if (c.endsWith(":")) return "right";
|
|
66
|
+
return "left";
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function markdownToHtmlBody(markdown) {
|
|
71
|
+
const lines = String(markdown).replace(/\r\n/g, "\n").split("\n");
|
|
72
|
+
const out = [];
|
|
73
|
+
let i = 0;
|
|
74
|
+
|
|
75
|
+
while (i < lines.length) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
|
|
78
|
+
// Page break comment
|
|
79
|
+
if (/^<!--\s*pagebreak\s*-->$/i.test(line.trim())) {
|
|
80
|
+
out.push('<div class="page-break"></div>');
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Empty line
|
|
86
|
+
if (!line.trim()) {
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fenced code block (``` or ````)
|
|
92
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})(\S*)\s*$/);
|
|
93
|
+
if (fenceMatch) {
|
|
94
|
+
const fence = fenceMatch[1];
|
|
95
|
+
const lang = fenceMatch[2] || "";
|
|
96
|
+
const codeLines = [];
|
|
97
|
+
i++;
|
|
98
|
+
while (i < lines.length && !lines[i].startsWith(fence)) {
|
|
99
|
+
codeLines.push(lines[i]);
|
|
100
|
+
i++;
|
|
101
|
+
}
|
|
102
|
+
if (i < lines.length) i++; // skip closing fence
|
|
103
|
+
const langLabel = lang ? `<div class="code-lang">${escapeHtml(lang)}</div>` : "";
|
|
104
|
+
out.push(
|
|
105
|
+
`<div class="code-block">${langLabel}<pre><code class="language-${escapeHtml(lang)}">${escapeHtml(
|
|
106
|
+
codeLines.join("\n")
|
|
107
|
+
)}</code></pre></div>`
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Horizontal rule
|
|
113
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) {
|
|
114
|
+
out.push("<hr />");
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Heading
|
|
120
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
121
|
+
if (headingMatch) {
|
|
122
|
+
const level = headingMatch[1].length;
|
|
123
|
+
const content = parseInline(headingMatch[2].trim());
|
|
124
|
+
out.push(`<h${level}>${content}</h${level}>`);
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Table
|
|
130
|
+
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
131
|
+
const headers = parseTableRow(line);
|
|
132
|
+
const alignments = getTableAlignments(lines[i + 1]);
|
|
133
|
+
i += 2;
|
|
134
|
+
const rows = [];
|
|
135
|
+
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
|
136
|
+
rows.push(parseTableRow(lines[i]));
|
|
137
|
+
i++;
|
|
138
|
+
}
|
|
139
|
+
let tableHtml = "<table><thead><tr>";
|
|
140
|
+
headers.forEach((h, idx) => {
|
|
141
|
+
const align = alignments[idx] || "left";
|
|
142
|
+
tableHtml += `<th style="text-align:${align}">${parseInline(h)}</th>`;
|
|
143
|
+
});
|
|
144
|
+
tableHtml += "</tr></thead><tbody>";
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
tableHtml += "<tr>";
|
|
147
|
+
headers.forEach((_, idx) => {
|
|
148
|
+
const align = alignments[idx] || "left";
|
|
149
|
+
const cellVal = row[idx] !== undefined ? row[idx] : "";
|
|
150
|
+
tableHtml += `<td style="text-align:${align}">${parseInline(cellVal)}</td>`;
|
|
151
|
+
});
|
|
152
|
+
tableHtml += "</tr>";
|
|
153
|
+
}
|
|
154
|
+
tableHtml += "</tbody></table>";
|
|
155
|
+
out.push(tableHtml);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Blockquote
|
|
160
|
+
if (line.trim().startsWith(">")) {
|
|
161
|
+
const quoteLines = [];
|
|
162
|
+
while (i < lines.length && lines[i].trim().startsWith(">")) {
|
|
163
|
+
quoteLines.push(lines[i].replace(/^\s*>\s?/, ""));
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
const firstLine = quoteLines[0] || "";
|
|
167
|
+
const alertMatch = firstLine.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i);
|
|
168
|
+
if (alertMatch) {
|
|
169
|
+
const type = alertMatch[1].toUpperCase();
|
|
170
|
+
const restFirst = alertMatch[2];
|
|
171
|
+
const bodyLines = restFirst ? [restFirst, ...quoteLines.slice(1)] : quoteLines.slice(1);
|
|
172
|
+
out.push(
|
|
173
|
+
`<div class="alert alert-${type.toLowerCase()}"><div class="alert-title">${type}</div><p>${bodyLines
|
|
174
|
+
.map((l) => parseInline(l))
|
|
175
|
+
.join("<br/>")}</p></div>`
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
out.push(`<blockquote>${quoteLines.map((l) => parseInline(l)).join("<br/>")}</blockquote>`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Unordered or Ordered List
|
|
184
|
+
const ulMatch = line.match(/^(\s*)([-*+])\s+(.+)$/);
|
|
185
|
+
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
186
|
+
if (ulMatch || olMatch) {
|
|
187
|
+
const isOrdered = Boolean(olMatch);
|
|
188
|
+
const tag = isOrdered ? "ol" : "ul";
|
|
189
|
+
const items = [];
|
|
190
|
+
while (i < lines.length) {
|
|
191
|
+
const cur = lines[i];
|
|
192
|
+
const mUl = cur.match(/^(\s*)([-*+])\s+(.+)$/);
|
|
193
|
+
const mOl = cur.match(/^(\s*)(\d+)\.\s+(.+)$/);
|
|
194
|
+
const m = isOrdered ? mOl : mUl;
|
|
195
|
+
if (!m) break;
|
|
196
|
+
let itemText = m[3];
|
|
197
|
+
// Task list checkbox
|
|
198
|
+
const taskMatch = itemText.match(/^\[([ xX])\]\s+(.+)$/);
|
|
199
|
+
if (taskMatch) {
|
|
200
|
+
const checked = taskMatch[1].toLowerCase() === "x";
|
|
201
|
+
itemText = `<input type="checkbox" disabled ${checked ? "checked" : ""} /> ${parseInline(taskMatch[2])}`;
|
|
202
|
+
} else {
|
|
203
|
+
itemText = parseInline(itemText);
|
|
204
|
+
}
|
|
205
|
+
items.push(`<li>${itemText}</li>`);
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
out.push(`<${tag}>${items.join("")}</${tag}>`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Paragraph
|
|
213
|
+
const paraLines = [];
|
|
214
|
+
while (
|
|
215
|
+
i < lines.length &&
|
|
216
|
+
lines[i].trim() &&
|
|
217
|
+
!lines[i].match(/^(#{1,6})\s+/) &&
|
|
218
|
+
!lines[i].match(/^(`{3,}|~{3,})/) &&
|
|
219
|
+
!lines[i].trim().startsWith(">") &&
|
|
220
|
+
!lines[i].match(/^(\s*)([-*+]|\d+\.)\s+/) &&
|
|
221
|
+
!/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i].trim()) &&
|
|
222
|
+
!(lines[i].includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
|
|
223
|
+
) {
|
|
224
|
+
paraLines.push(parseInline(lines[i].trim()));
|
|
225
|
+
i++;
|
|
226
|
+
}
|
|
227
|
+
if (paraLines.length > 0) {
|
|
228
|
+
out.push(`<p>${paraLines.join("<br/>")}</p>`);
|
|
229
|
+
} else {
|
|
230
|
+
i++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return out.join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildHtmlDocument(title, markdownContent) {
|
|
238
|
+
const bodyHtml = markdownToHtmlBody(markdownContent);
|
|
239
|
+
return `<!DOCTYPE html>
|
|
240
|
+
<html lang="zh-TW">
|
|
241
|
+
<head>
|
|
242
|
+
<meta charset="UTF-8" />
|
|
243
|
+
<title>${escapeHtml(title)}</title>
|
|
244
|
+
<style>
|
|
245
|
+
@page {
|
|
246
|
+
size: A4;
|
|
247
|
+
margin: 18mm 16mm;
|
|
248
|
+
}
|
|
249
|
+
* {
|
|
250
|
+
box-sizing: border-box;
|
|
251
|
+
}
|
|
252
|
+
body {
|
|
253
|
+
font-family: -apple-system, BlinkMacSystemFont, "PingFang TC", "PingFang SC", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Malgun Gothic", "Microsoft JhengHei", "Noto Sans CJK TC", "Segoe UI", Roboto, sans-serif;
|
|
254
|
+
font-size: 10.5pt;
|
|
255
|
+
line-height: 1.65;
|
|
256
|
+
color: #1f2937;
|
|
257
|
+
margin: 0;
|
|
258
|
+
padding: 0;
|
|
259
|
+
background: #ffffff;
|
|
260
|
+
}
|
|
261
|
+
h1, h2, h3, h4, h5, h6 {
|
|
262
|
+
color: #111827;
|
|
263
|
+
font-weight: 700;
|
|
264
|
+
line-height: 1.35;
|
|
265
|
+
page-break-after: avoid;
|
|
266
|
+
break-after: avoid;
|
|
267
|
+
}
|
|
268
|
+
h1 {
|
|
269
|
+
font-size: 20pt;
|
|
270
|
+
border-bottom: 2px solid #e5e7eb;
|
|
271
|
+
padding-bottom: 6pt;
|
|
272
|
+
margin-top: 0;
|
|
273
|
+
margin-bottom: 12pt;
|
|
274
|
+
}
|
|
275
|
+
h2 {
|
|
276
|
+
font-size: 14.5pt;
|
|
277
|
+
border-bottom: 1px solid #e5e7eb;
|
|
278
|
+
padding-bottom: 4pt;
|
|
279
|
+
margin-top: 18pt;
|
|
280
|
+
margin-bottom: 8pt;
|
|
281
|
+
}
|
|
282
|
+
h3 {
|
|
283
|
+
font-size: 12pt;
|
|
284
|
+
margin-top: 14pt;
|
|
285
|
+
margin-bottom: 6pt;
|
|
286
|
+
}
|
|
287
|
+
h4, h5, h6 {
|
|
288
|
+
font-size: 10.5pt;
|
|
289
|
+
margin-top: 10pt;
|
|
290
|
+
margin-bottom: 4pt;
|
|
291
|
+
}
|
|
292
|
+
p {
|
|
293
|
+
margin: 0 0 8pt 0;
|
|
294
|
+
orphans: 3;
|
|
295
|
+
widows: 3;
|
|
296
|
+
}
|
|
297
|
+
ul, ol {
|
|
298
|
+
margin: 0 0 10pt 0;
|
|
299
|
+
padding-left: 20pt;
|
|
300
|
+
}
|
|
301
|
+
li {
|
|
302
|
+
margin-bottom: 3pt;
|
|
303
|
+
}
|
|
304
|
+
table {
|
|
305
|
+
width: 100%;
|
|
306
|
+
border-collapse: collapse;
|
|
307
|
+
margin: 10pt 0 14pt 0;
|
|
308
|
+
font-size: 9.5pt;
|
|
309
|
+
page-break-inside: auto;
|
|
310
|
+
}
|
|
311
|
+
tr {
|
|
312
|
+
page-break-inside: avoid;
|
|
313
|
+
break-inside: avoid;
|
|
314
|
+
}
|
|
315
|
+
th, td {
|
|
316
|
+
border: 1px solid #d1d5db;
|
|
317
|
+
padding: 6pt 8pt;
|
|
318
|
+
vertical-align: top;
|
|
319
|
+
word-break: break-word;
|
|
320
|
+
}
|
|
321
|
+
th {
|
|
322
|
+
background-color: #f3f4f6;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
color: #111827;
|
|
325
|
+
}
|
|
326
|
+
tbody tr:nth-child(even) {
|
|
327
|
+
background-color: #f9fafb;
|
|
328
|
+
}
|
|
329
|
+
code {
|
|
330
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
|
331
|
+
font-size: 9pt;
|
|
332
|
+
background-color: #f3f4f6;
|
|
333
|
+
padding: 1.5pt 4pt;
|
|
334
|
+
border-radius: 3pt;
|
|
335
|
+
color: #b91c1c;
|
|
336
|
+
}
|
|
337
|
+
.code-block {
|
|
338
|
+
margin: 8pt 0 12pt 0;
|
|
339
|
+
page-break-inside: avoid;
|
|
340
|
+
break-inside: avoid;
|
|
341
|
+
border: 1px solid #e5e7eb;
|
|
342
|
+
border-radius: 5pt;
|
|
343
|
+
background-color: #f8fafc;
|
|
344
|
+
overflow: hidden;
|
|
345
|
+
}
|
|
346
|
+
.code-lang {
|
|
347
|
+
background-color: #e2e8f0;
|
|
348
|
+
color: #475569;
|
|
349
|
+
font-size: 8pt;
|
|
350
|
+
font-family: "SFMono-Regular", Consolas, monospace;
|
|
351
|
+
padding: 2pt 8pt;
|
|
352
|
+
border-bottom: 1px solid #cbd5e1;
|
|
353
|
+
text-transform: uppercase;
|
|
354
|
+
}
|
|
355
|
+
pre {
|
|
356
|
+
margin: 0;
|
|
357
|
+
padding: 8pt 10pt;
|
|
358
|
+
overflow-x: auto;
|
|
359
|
+
white-space: pre-wrap;
|
|
360
|
+
word-wrap: break-word;
|
|
361
|
+
}
|
|
362
|
+
pre code {
|
|
363
|
+
background: transparent;
|
|
364
|
+
padding: 0;
|
|
365
|
+
color: #1e293b;
|
|
366
|
+
font-size: 8.5pt;
|
|
367
|
+
line-height: 1.5;
|
|
368
|
+
}
|
|
369
|
+
blockquote {
|
|
370
|
+
margin: 8pt 0 12pt 0;
|
|
371
|
+
padding: 6pt 12pt;
|
|
372
|
+
border-left: 3pt solid #9ca3af;
|
|
373
|
+
background-color: #f9fafb;
|
|
374
|
+
color: #4b5563;
|
|
375
|
+
}
|
|
376
|
+
.alert {
|
|
377
|
+
margin: 8pt 0 12pt 0;
|
|
378
|
+
padding: 8pt 12pt;
|
|
379
|
+
border-left: 3.5pt solid #3b82f6;
|
|
380
|
+
background-color: #eff6ff;
|
|
381
|
+
border-radius: 3pt;
|
|
382
|
+
page-break-inside: avoid;
|
|
383
|
+
}
|
|
384
|
+
.alert-title {
|
|
385
|
+
font-weight: 700;
|
|
386
|
+
font-size: 9.5pt;
|
|
387
|
+
margin-bottom: 3pt;
|
|
388
|
+
}
|
|
389
|
+
.alert-warning, .alert-caution {
|
|
390
|
+
border-left-color: #f59e0b;
|
|
391
|
+
background-color: #fffbeb;
|
|
392
|
+
}
|
|
393
|
+
.alert-important {
|
|
394
|
+
border-left-color: #8b5cf6;
|
|
395
|
+
background-color: #f5f3ff;
|
|
396
|
+
}
|
|
397
|
+
hr {
|
|
398
|
+
border: none;
|
|
399
|
+
border-top: 1px solid #e5e7eb;
|
|
400
|
+
margin: 14pt 0;
|
|
401
|
+
}
|
|
402
|
+
.page-break {
|
|
403
|
+
page-break-before: always;
|
|
404
|
+
break-before: page;
|
|
405
|
+
}
|
|
406
|
+
a {
|
|
407
|
+
color: #2563eb;
|
|
408
|
+
text-decoration: none;
|
|
409
|
+
}
|
|
410
|
+
</style>
|
|
411
|
+
</head>
|
|
412
|
+
<body>
|
|
413
|
+
${bodyHtml}
|
|
414
|
+
</body>
|
|
415
|
+
</html>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function findHeadlessBrowser(customBrowserPath) {
|
|
419
|
+
if (customBrowserPath && fs.existsSync(customBrowserPath)) {
|
|
420
|
+
return customBrowserPath;
|
|
421
|
+
}
|
|
422
|
+
if (process.env.SA_SPEC_BROWSER && fs.existsSync(process.env.SA_SPEC_BROWSER)) {
|
|
423
|
+
return process.env.SA_SPEC_BROWSER;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const candidates = [];
|
|
427
|
+
if (process.platform === "darwin") {
|
|
428
|
+
candidates.push(
|
|
429
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
430
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
431
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
432
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
433
|
+
);
|
|
434
|
+
} else if (process.platform === "win32") {
|
|
435
|
+
const programFiles = process.env["PROGRAMFILES"] || "C:\\Program Files";
|
|
436
|
+
const programFilesX86 = process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)";
|
|
437
|
+
const localAppData = process.env["LOCALAPPDATA"] || "";
|
|
438
|
+
candidates.push(
|
|
439
|
+
path.join(programFiles, "Google\\Chrome\\Application\\chrome.exe"),
|
|
440
|
+
path.join(programFilesX86, "Google\\Chrome\\Application\\chrome.exe"),
|
|
441
|
+
path.join(localAppData, "Google\\Chrome\\Application\\chrome.exe"),
|
|
442
|
+
path.join(programFiles, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
443
|
+
path.join(programFilesX86, "Microsoft\\Edge\\Application\\msedge.exe"),
|
|
444
|
+
path.join(programFiles, "BraveSoftware\\Brave-Browser\\Application\\brave.exe")
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
const linuxBins = [
|
|
448
|
+
"google-chrome",
|
|
449
|
+
"google-chrome-stable",
|
|
450
|
+
"chromium",
|
|
451
|
+
"chromium-browser",
|
|
452
|
+
"microsoft-edge",
|
|
453
|
+
"brave-browser"
|
|
454
|
+
];
|
|
455
|
+
for (const bin of linuxBins) {
|
|
456
|
+
const whichRes = spawnSync("which", [bin], { encoding: "utf8" });
|
|
457
|
+
if (whichRes.status === 0 && whichRes.stdout.trim()) {
|
|
458
|
+
return whichRes.stdout.trim();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
for (const candidate of candidates) {
|
|
464
|
+
if (fs.existsSync(candidate)) {
|
|
465
|
+
return candidate;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderHtmlToPdf(htmlContent, outputPdfPath, customBrowserPath) {
|
|
472
|
+
const browser = findHeadlessBrowser(customBrowserPath);
|
|
473
|
+
const absPdfPath = path.resolve(outputPdfPath);
|
|
474
|
+
fs.mkdirSync(path.dirname(absPdfPath), { recursive: true });
|
|
475
|
+
|
|
476
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sa-spec-pdf-"));
|
|
477
|
+
const tmpHtmlPath = path.join(tmpDir, "spec.html");
|
|
478
|
+
fs.writeFileSync(tmpHtmlPath, htmlContent, "utf8");
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
if (browser) {
|
|
482
|
+
const fileUrl = pathToFileURL(tmpHtmlPath).href;
|
|
483
|
+
const profileDir = path.join(tmpDir, "profile");
|
|
484
|
+
const commonFlags = [
|
|
485
|
+
"--disable-gpu",
|
|
486
|
+
"--no-sandbox",
|
|
487
|
+
"--disable-crash-reporter",
|
|
488
|
+
"--no-first-run",
|
|
489
|
+
"--no-default-browser-check",
|
|
490
|
+
`--user-data-dir=${profileDir}`,
|
|
491
|
+
"--no-pdf-header-footer",
|
|
492
|
+
`--print-to-pdf=${absPdfPath}`,
|
|
493
|
+
fileUrl
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
// Try modern --headless=new first, fallback to --headless
|
|
497
|
+
let res = spawnSync(browser, ["--headless=new", ...commonFlags], {
|
|
498
|
+
encoding: "utf8",
|
|
499
|
+
timeout: 30000
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
if (res.status !== 0 || !fs.existsSync(absPdfPath)) {
|
|
503
|
+
res = spawnSync(browser, ["--headless", ...commonFlags], {
|
|
504
|
+
encoding: "utf8",
|
|
505
|
+
timeout: 30000
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (fs.existsSync(absPdfPath)) {
|
|
510
|
+
return { ok: true, engine: path.basename(browser), pdfPath: absPdfPath };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Fallback: wkhtmltopdf if available
|
|
515
|
+
const wkWhich = spawnSync("which", ["wkhtmltopdf"], { encoding: "utf8" });
|
|
516
|
+
if (wkWhich.status === 0 && wkWhich.stdout.trim()) {
|
|
517
|
+
const wkBin = wkWhich.stdout.trim();
|
|
518
|
+
const wkRes = spawnSync(wkBin, ["--enable-local-file-access", tmpHtmlPath, absPdfPath], {
|
|
519
|
+
encoding: "utf8",
|
|
520
|
+
timeout: 30000
|
|
521
|
+
});
|
|
522
|
+
if (wkRes.status === 0 && fs.existsSync(absPdfPath)) {
|
|
523
|
+
return { ok: true, engine: "wkhtmltopdf", pdfPath: absPdfPath };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
error: browser
|
|
530
|
+
? "瀏覽器 Headless PDF 轉換失敗(請確認終端機權限或瀏覽器狀態)。"
|
|
531
|
+
: "未偵測到本機 Chrome / Edge / Brave / Chromium 瀏覽器或 wkhtmltopdf。"
|
|
532
|
+
};
|
|
533
|
+
} finally {
|
|
534
|
+
try {
|
|
535
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
536
|
+
} catch (_) {}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function convertMarkdownFileToPdf(mdFilePath, outputPdfPath, customBrowserPath) {
|
|
541
|
+
const absMdPath = path.resolve(mdFilePath);
|
|
542
|
+
if (!fs.existsSync(absMdPath)) {
|
|
543
|
+
return { ok: false, error: `找不到檔案: ${mdFilePath}` };
|
|
544
|
+
}
|
|
545
|
+
const content = fs.readFileSync(absMdPath, "utf8");
|
|
546
|
+
const title = path.basename(absMdPath, path.extname(absMdPath));
|
|
547
|
+
const targetPdf = outputPdfPath
|
|
548
|
+
? path.resolve(outputPdfPath)
|
|
549
|
+
: path.join(path.dirname(absMdPath), `${title}.pdf`);
|
|
550
|
+
const html = buildHtmlDocument(title, content);
|
|
551
|
+
return renderHtmlToPdf(html, targetPdf, customBrowserPath);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildMergedRequirementMarkdown(reqDir) {
|
|
555
|
+
const dirName = path.basename(reqDir);
|
|
556
|
+
const sections = [];
|
|
557
|
+
|
|
558
|
+
const mdOrder = [
|
|
559
|
+
{ file: "srs.md", label: "SRS 需求規格書" },
|
|
560
|
+
{ file: "db-schema.md", label: "DB Schema 資料庫規格" },
|
|
561
|
+
{ file: "openapi.yaml", label: "OpenAPI 介面規格", isYaml: true },
|
|
562
|
+
{ file: "handoff.md", label: "Handoff 開發交接摘要" },
|
|
563
|
+
{ file: "history.md", label: "History 版本歷程" }
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
for (const item of mdOrder) {
|
|
567
|
+
const filePath = path.join(reqDir, item.file);
|
|
568
|
+
if (!fs.existsSync(filePath)) continue;
|
|
569
|
+
const raw = fs.readFileSync(filePath, "utf8").trim();
|
|
570
|
+
if (!raw) continue;
|
|
571
|
+
|
|
572
|
+
if (item.isYaml) {
|
|
573
|
+
sections.push(`# ${item.label} (\`${item.file}\`)\n\n\`\`\`yaml\n${raw}\n\`\`\``);
|
|
574
|
+
} else {
|
|
575
|
+
sections.push(raw);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const umlDir = path.join(reqDir, "uml");
|
|
580
|
+
if (fs.existsSync(umlDir)) {
|
|
581
|
+
const pumlFiles = fs
|
|
582
|
+
.readdirSync(umlDir)
|
|
583
|
+
.filter((f) => f.endsWith(".puml"))
|
|
584
|
+
.sort();
|
|
585
|
+
if (pumlFiles.length > 0) {
|
|
586
|
+
const umlBlocks = pumlFiles.map((f) => {
|
|
587
|
+
const pumlContent = fs.readFileSync(path.join(umlDir, f), "utf8").trim();
|
|
588
|
+
return `## \`uml/${f}\`\n\n\`\`\`plantuml\n${pumlContent}\n\`\`\``;
|
|
589
|
+
});
|
|
590
|
+
sections.push(`# UML 圖表規格 (\`uml/\`)\n\n${umlBlocks.join("\n\n")}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
title: dirName,
|
|
596
|
+
markdown: sections.join("\n\n<!-- pagebreak -->\n\n")
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
module.exports = {
|
|
601
|
+
buildHtmlDocument,
|
|
602
|
+
convertMarkdownFileToPdf,
|
|
603
|
+
buildMergedRequirementMarkdown,
|
|
604
|
+
renderHtmlToPdf
|
|
605
|
+
};
|
package/templates/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
# 指令流程
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
使用五個指令,並支援簡寫別名(兩者等價):
|
|
19
19
|
|
|
20
20
|
| 指令 | 簡寫 | 用途 | 規則 |
|
|
21
21
|
|---|---|---|---|
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
| `spec:propose` | `scp` | 提出需求 | 使用者提出需求後,自動依需求名稱建立並切換至專屬 feature 分支(`feat/<name>`,若已在 feature branch 則沿用),檢查是否有設定 Git remote,若有則自動將分支推上 remote(`git push -u <remote> feat/<name>`),並建立草稿目錄(內部使用 `{{REQ_ID}}` 佔位),整理需求摘要、目標、初步流程與待確認事項,不歸檔、不修改既有需求。 |
|
|
24
24
|
| `spec:analyze` | `sca` | 分析與起草 | 先檢查新需求是否與 `project/` 內所有既有需求衝突;無衝突才產出 SRS、UML、OpenAPI、DB Schema 草稿;有衝突則只列出衝突與待釐清問題,待使用者釐清後再次執行 `spec:analyze`(或 `sca`)。 |
|
|
25
25
|
| `spec:commit` | `scc` | 確認歸檔 | 只把使用者已確認的版本歸檔到該需求資料夾;若有 Git remote 則先將目前分支推上 remote 並同步遠端狀態,接著才查詢主分支(`master`/`main`)最新可用序號定號(`REQ-xxx`)、替換佔位符,更新 `project/index.md`、`project/system-spec.md` 與 `history.md`,建立 Git commit(若有 remote 則推送分支),再自動切換回主分支完成合併並推送到 remote。 |
|
|
26
|
+
| `spec:pdf` | `spd` | 匯出 PDF | 呼叫 `sa-spec pdf <target> [--split] [--output <path>]`(或 `--all`),將指定的單一 `.md` 檔轉為同名 `.pdf`,或將指定需求目錄/編號(如 `REQ-001`)整包合併匯出成一份完整規格書 PDF(加 `--split` 則各別轉為獨立 PDF)。 |
|
|
26
27
|
|
|
27
28
|
# 重要限制
|
|
28
29
|
|