vitepress-theme-pm 0.1.0 → 0.2.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/dist/plugin.mjs +108 -4
- package/package.json +1 -1
- package/src/cli.mjs +13 -5
- package/src/components/Board.vue +89 -15
- package/src/components/TagFilterDropdown.vue +96 -0
- package/src/components/TicketDetail.vue +10 -6
- package/src/components/TicketFixModal.vue +104 -0
- package/src/plugins/markdownWriter.ts +138 -3
package/dist/plugin.mjs
CHANGED
|
@@ -19,6 +19,76 @@ function scanTickets(ticketsDir, dirRelative) {
|
|
|
19
19
|
};
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
|
+
function validateTickets(ticketsDir, dirRelative, prefix) {
|
|
23
|
+
if (!fs.existsSync(ticketsDir)) return [];
|
|
24
|
+
const files = fs.readdirSync(ticketsDir).filter((f) => f.endsWith(".md"));
|
|
25
|
+
const issues = [];
|
|
26
|
+
const entries = files.map((file) => {
|
|
27
|
+
const raw = fs.readFileSync(path.join(ticketsDir, file), "utf-8");
|
|
28
|
+
const parsed = matter(raw);
|
|
29
|
+
const id = Number(parsed.data.id) || 0;
|
|
30
|
+
const slug = path.basename(file, ".md");
|
|
31
|
+
return { file, id, slug };
|
|
32
|
+
});
|
|
33
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
if (e.id > 0) idCounts.set(e.id, (idCounts.get(e.id) || 0) + 1);
|
|
36
|
+
}
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const expectedSlug = prefix ? `${prefix}-${e.id}` : String(e.id);
|
|
39
|
+
const isDuplicate = e.id > 0 && (idCounts.get(e.id) || 0) > 1;
|
|
40
|
+
const isMissing = e.id <= 0;
|
|
41
|
+
const isMismatch = e.id > 0 && e.slug !== expectedSlug;
|
|
42
|
+
if (isDuplicate || isMissing || isMismatch) {
|
|
43
|
+
issues.push({
|
|
44
|
+
file: e.file,
|
|
45
|
+
currentId: e.id,
|
|
46
|
+
currentSlug: e.slug,
|
|
47
|
+
fixedId: 0,
|
|
48
|
+
// assigned below
|
|
49
|
+
fixedSlug: ""
|
|
50
|
+
// assigned below
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const goodIds = /* @__PURE__ */ new Set();
|
|
55
|
+
for (const e of entries) {
|
|
56
|
+
const expectedSlug = prefix ? `${prefix}-${e.id}` : String(e.id);
|
|
57
|
+
const isDuplicate = e.id > 0 && (idCounts.get(e.id) || 0) > 1;
|
|
58
|
+
const isMissing = e.id <= 0;
|
|
59
|
+
const isMismatch = e.id > 0 && e.slug !== expectedSlug;
|
|
60
|
+
if (!isDuplicate && !isMissing && !isMismatch) {
|
|
61
|
+
goodIds.add(e.id);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let nextFixId = 1;
|
|
65
|
+
for (const issue of issues) {
|
|
66
|
+
while (goodIds.has(nextFixId)) nextFixId++;
|
|
67
|
+
issue.fixedId = nextFixId;
|
|
68
|
+
issue.fixedSlug = prefix ? `${prefix}-${nextFixId}` : String(nextFixId);
|
|
69
|
+
goodIds.add(nextFixId);
|
|
70
|
+
nextFixId++;
|
|
71
|
+
}
|
|
72
|
+
return issues;
|
|
73
|
+
}
|
|
74
|
+
function fixTickets(ticketsDir, dirRelative, prefix) {
|
|
75
|
+
const issues = validateTickets(ticketsDir, dirRelative, prefix);
|
|
76
|
+
if (issues.length === 0) return [];
|
|
77
|
+
for (const issue of issues) {
|
|
78
|
+
const oldPath = path.join(ticketsDir, issue.file);
|
|
79
|
+
const newFile = `${issue.fixedSlug}.md`;
|
|
80
|
+
const newPath = path.join(ticketsDir, newFile);
|
|
81
|
+
const raw = fs.readFileSync(oldPath, "utf-8");
|
|
82
|
+
const parsed = matter(raw);
|
|
83
|
+
parsed.data.id = issue.fixedId;
|
|
84
|
+
const output = matter.stringify(parsed.content, parsed.data);
|
|
85
|
+
fs.writeFileSync(newPath, output);
|
|
86
|
+
if (oldPath !== newPath && fs.existsSync(oldPath)) {
|
|
87
|
+
fs.unlinkSync(oldPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return issues;
|
|
91
|
+
}
|
|
22
92
|
function getMaxTicketId(ticketsDir) {
|
|
23
93
|
if (!fs.existsSync(ticketsDir)) return 0;
|
|
24
94
|
const files = fs.readdirSync(ticketsDir).filter((f) => f.endsWith(".md"));
|
|
@@ -65,6 +135,38 @@ function markdownWriterPlugin() {
|
|
|
65
135
|
res.setHeader("Content-Type", "application/json");
|
|
66
136
|
res.end(JSON.stringify(tickets));
|
|
67
137
|
});
|
|
138
|
+
server.middlewares.use("/__vitepress_pm_validate", (req, res) => {
|
|
139
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
140
|
+
const dir = url.searchParams.get("dir") || "tickets";
|
|
141
|
+
const prefix = url.searchParams.get("prefix") || "";
|
|
142
|
+
const ticketsDir = path.resolve(srcDir, dir);
|
|
143
|
+
const issues = validateTickets(ticketsDir, dir, prefix);
|
|
144
|
+
res.setHeader("Content-Type", "application/json");
|
|
145
|
+
res.end(JSON.stringify(issues));
|
|
146
|
+
});
|
|
147
|
+
server.middlewares.use("/__vitepress_pm_fix", (req, res) => {
|
|
148
|
+
if (req.method !== "POST") {
|
|
149
|
+
res.statusCode = 405;
|
|
150
|
+
res.end("Method not allowed");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
let body = "";
|
|
154
|
+
req.on("data", (chunk) => {
|
|
155
|
+
body += chunk;
|
|
156
|
+
});
|
|
157
|
+
req.on("end", () => {
|
|
158
|
+
try {
|
|
159
|
+
const { dir, prefix } = JSON.parse(body);
|
|
160
|
+
const ticketsDir = path.resolve(srcDir, dir || "tickets");
|
|
161
|
+
const fixed = fixTickets(ticketsDir, dir || "tickets", prefix || "");
|
|
162
|
+
res.setHeader("Content-Type", "application/json");
|
|
163
|
+
res.end(JSON.stringify(fixed));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
res.statusCode = 500;
|
|
166
|
+
res.end(String(e));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
68
170
|
server.middlewares.use("/__vitepress_pm_create", (req, res) => {
|
|
69
171
|
if (req.method !== "POST") {
|
|
70
172
|
res.statusCode = 405;
|
|
@@ -77,7 +179,7 @@ function markdownWriterPlugin() {
|
|
|
77
179
|
});
|
|
78
180
|
req.on("end", () => {
|
|
79
181
|
try {
|
|
80
|
-
const { dir, status, title, priority, tags, body: ticketBody } = JSON.parse(body);
|
|
182
|
+
const { dir, prefix, status, title, priority, tags, body: ticketBody } = JSON.parse(body);
|
|
81
183
|
const ticketsDir = path.resolve(srcDir, dir || "tickets");
|
|
82
184
|
if (!fs.existsSync(ticketsDir)) {
|
|
83
185
|
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
@@ -92,16 +194,17 @@ function markdownWriterPlugin() {
|
|
|
92
194
|
priority: priority || "medium",
|
|
93
195
|
tags: tags || []
|
|
94
196
|
};
|
|
197
|
+
const slug = prefix ? `${prefix}-${id}` : String(id);
|
|
95
198
|
const bodyContent = ticketBody ? `
|
|
96
199
|
${ticketBody}
|
|
97
200
|
` : "\n";
|
|
98
201
|
const content = matter.stringify(bodyContent, frontmatter);
|
|
99
|
-
const filePath = path.join(ticketsDir, `${
|
|
202
|
+
const filePath = path.join(ticketsDir, `${slug}.md`);
|
|
100
203
|
fs.writeFileSync(filePath, content);
|
|
101
204
|
const ticket = {
|
|
102
205
|
...frontmatter,
|
|
103
206
|
body: ticketBody || "",
|
|
104
|
-
url: `/${dir || "tickets"}/${
|
|
207
|
+
url: `/${dir || "tickets"}/${slug}.html`
|
|
105
208
|
};
|
|
106
209
|
res.setHeader("Content-Type", "application/json");
|
|
107
210
|
res.end(JSON.stringify(ticket));
|
|
@@ -180,5 +283,6 @@ ${ticketBody}
|
|
|
180
283
|
export {
|
|
181
284
|
getMaxTicketId,
|
|
182
285
|
markdownWriterPlugin,
|
|
183
|
-
scanTickets
|
|
286
|
+
scanTickets,
|
|
287
|
+
validateTickets
|
|
184
288
|
};
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -18,7 +18,9 @@ Options:
|
|
|
18
18
|
--dir <path> Tickets directory (default: tickets)
|
|
19
19
|
--status <status> Initial status (default: backlog)
|
|
20
20
|
--priority <pri> Priority: critical, high, medium, low (default: medium)
|
|
21
|
-
--tags <tags> Comma-separated tags
|
|
21
|
+
--tags <tags> Comma-separated tags
|
|
22
|
+
--body <text> Ticket body/description (markdown)
|
|
23
|
+
--prefix <prefix> Ticket ID prefix (overrides board.md ticketPrefix)`)
|
|
22
24
|
process.exit(command ? 1 : 0)
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -41,6 +43,8 @@ function parseArgs(args) {
|
|
|
41
43
|
let status = 'backlog'
|
|
42
44
|
let priority = 'medium'
|
|
43
45
|
let tags = []
|
|
46
|
+
let body = ''
|
|
47
|
+
let prefix = null
|
|
44
48
|
|
|
45
49
|
let i = 0
|
|
46
50
|
while (i < args.length) {
|
|
@@ -48,11 +52,13 @@ function parseArgs(args) {
|
|
|
48
52
|
if (args[i] === '--status') { status = args[++i]; i++; continue }
|
|
49
53
|
if (args[i] === '--priority') { priority = args[++i]; i++; continue }
|
|
50
54
|
if (args[i] === '--tags') { tags = args[++i].split(',').map(t => t.trim()).filter(Boolean); i++; continue }
|
|
55
|
+
if (args[i] === '--body') { body = args[++i]; i++; continue }
|
|
56
|
+
if (args[i] === '--prefix') { prefix = args[++i]; i++; continue }
|
|
51
57
|
if (!title) { title = args[i] }
|
|
52
58
|
i++
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
return { title, dir, status, priority, tags }
|
|
61
|
+
return { title, dir, status, priority, tags, body, prefix }
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
function readTicketPrefix(siteDir) {
|
|
@@ -81,6 +87,7 @@ function createTicket(args) {
|
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
const id = getMaxTicketId(ticketsDir) + 1
|
|
90
|
+
const prefix = opts.prefix !== null ? opts.prefix : readTicketPrefix(path.dirname(ticketsDir))
|
|
84
91
|
const frontmatter = {
|
|
85
92
|
id,
|
|
86
93
|
title: opts.title,
|
|
@@ -91,11 +98,12 @@ function createTicket(args) {
|
|
|
91
98
|
frontmatter.tags = opts.tags
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
const
|
|
95
|
-
const
|
|
101
|
+
const slug = prefix ? `${prefix}-${id}` : String(id)
|
|
102
|
+
const bodyContent = opts.body ? `\n${opts.body}\n` : '\n'
|
|
103
|
+
const content = matter.stringify(bodyContent, frontmatter)
|
|
104
|
+
const filePath = path.join(ticketsDir, `${slug}.md`)
|
|
96
105
|
fs.writeFileSync(filePath, content)
|
|
97
106
|
|
|
98
|
-
const prefix = readTicketPrefix(path.dirname(ticketsDir))
|
|
99
107
|
const displayId = prefix ? `${prefix}-${id}` : String(id)
|
|
100
108
|
|
|
101
109
|
console.log(`Created ${displayId}: ${opts.title}`)
|
package/src/components/Board.vue
CHANGED
|
@@ -6,6 +6,8 @@ import { useDragDrop } from '../composables/useDragDrop'
|
|
|
6
6
|
import { useTicketWriter } from '../composables/useTicketWriter'
|
|
7
7
|
import BoardColumn from './BoardColumn.vue'
|
|
8
8
|
import TicketDetail from './TicketDetail.vue'
|
|
9
|
+
import TicketFixModal from './TicketFixModal.vue'
|
|
10
|
+
import TagFilterDropdown from './TagFilterDropdown.vue'
|
|
9
11
|
import '../styles/board.css'
|
|
10
12
|
|
|
11
13
|
const { frontmatter } = useData()
|
|
@@ -14,16 +16,31 @@ const columns = computed<Column[]>(() => frontmatter.value.columns || [])
|
|
|
14
16
|
const ticketsDir = computed(() => frontmatter.value.ticketsDir || 'tickets')
|
|
15
17
|
const ticketPrefix = computed(() => frontmatter.value.ticketPrefix || '')
|
|
16
18
|
const defaultColumn = computed(() => frontmatter.value.defaultColumn || (columns.value.length > 0 ? columns.value[0].key : 'backlog'))
|
|
19
|
+
const demo = computed(() => !!frontmatter.value.demo)
|
|
17
20
|
|
|
18
21
|
const tickets = ref<Ticket[]>([])
|
|
19
22
|
const selectedId = ref<number | null>(null)
|
|
20
23
|
const draftTicket = ref<Ticket | null>(null)
|
|
21
24
|
const filter = ref('')
|
|
25
|
+
const selectedTags = ref<string[]>([])
|
|
26
|
+
|
|
27
|
+
const allTags = computed(() =>
|
|
28
|
+
[...new Set(tickets.value.flatMap(t => t.tags || []))].sort()
|
|
29
|
+
)
|
|
30
|
+
const ticketIssues = ref<any[]>([])
|
|
31
|
+
const showFixModal = ref(false)
|
|
22
32
|
|
|
23
33
|
const { writeTicket } = useTicketWriter()
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
if (
|
|
35
|
+
function fetchValidation() {
|
|
36
|
+
if (!import.meta.env.DEV) return
|
|
37
|
+
fetch(`/__vitepress_pm_validate?dir=${encodeURIComponent(ticketsDir.value)}&prefix=${encodeURIComponent(ticketPrefix.value)}`)
|
|
38
|
+
.then(r => r.ok ? r.json() : [])
|
|
39
|
+
.then((data: any[]) => { ticketIssues.value = data })
|
|
40
|
+
.catch(() => { ticketIssues.value = [] })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadTickets() {
|
|
27
44
|
const url = import.meta.env.DEV
|
|
28
45
|
? `/__vitepress_pm_tickets?dir=${encodeURIComponent(ticketsDir.value)}`
|
|
29
46
|
: `${import.meta.env.BASE_URL}__vitepress_pm_tickets/${encodeURIComponent(ticketsDir.value)}.json`
|
|
@@ -33,14 +50,41 @@ if (typeof window !== 'undefined') {
|
|
|
33
50
|
if (r.ok) return r.json()
|
|
34
51
|
throw new Error('not available')
|
|
35
52
|
})
|
|
36
|
-
.then((data: Ticket[]) => {
|
|
53
|
+
.then((data: Ticket[]) => {
|
|
54
|
+
tickets.value = data
|
|
55
|
+
fetchValidation()
|
|
56
|
+
})
|
|
37
57
|
.catch(() => {})
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
function onFixed() {
|
|
61
|
+
showFixModal.value = false
|
|
62
|
+
if (demo.value) {
|
|
63
|
+
// Apply fixes in-memory only — don't reload from disk
|
|
64
|
+
for (const issue of ticketIssues.value) {
|
|
65
|
+
const ticket = tickets.value.find(t => t.id === issue.currentId || t.id === 0)
|
|
66
|
+
if (ticket) {
|
|
67
|
+
ticket.id = issue.fixedId
|
|
68
|
+
const slug = ticketPrefix.value ? `${ticketPrefix.value}-${issue.fixedId}` : String(issue.fixedId)
|
|
69
|
+
ticket.url = `/${ticketsDir.value}/${slug}.html`
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
ticketIssues.value = []
|
|
73
|
+
} else {
|
|
74
|
+
loadTickets()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Load tickets from dev plugin or static JSON
|
|
79
|
+
if (typeof window !== 'undefined') {
|
|
80
|
+
loadTickets()
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
function updateTicket(id: number, patch: Partial<Ticket>) {
|
|
41
84
|
tickets.value = tickets.value.map(t =>
|
|
42
85
|
t.id === id ? { ...t, ...patch } : t
|
|
43
86
|
)
|
|
87
|
+
if (demo.value) return
|
|
44
88
|
const ticket = tickets.value.find(t => t.id === id)
|
|
45
89
|
if (ticket?.url) {
|
|
46
90
|
// Don't send url/id to the file writer
|
|
@@ -64,13 +108,14 @@ function openNewTicket() {
|
|
|
64
108
|
}
|
|
65
109
|
|
|
66
110
|
async function confirmCreate(draft: Ticket) {
|
|
67
|
-
if (import.meta.env.DEV) {
|
|
111
|
+
if (import.meta.env.DEV && !demo.value) {
|
|
68
112
|
try {
|
|
69
113
|
const res = await fetch('/__vitepress_pm_create', {
|
|
70
114
|
method: 'POST',
|
|
71
115
|
headers: { 'Content-Type': 'application/json' },
|
|
72
116
|
body: JSON.stringify({
|
|
73
117
|
dir: ticketsDir.value,
|
|
118
|
+
prefix: ticketPrefix.value,
|
|
74
119
|
status: draft.status,
|
|
75
120
|
title: draft.title,
|
|
76
121
|
priority: draft.priority,
|
|
@@ -91,14 +136,16 @@ async function confirmCreate(draft: Ticket) {
|
|
|
91
136
|
|
|
92
137
|
// Production: create in-memory only
|
|
93
138
|
const maxId = tickets.value.reduce((m, t) => Math.max(m, t.id), 0)
|
|
139
|
+
const newId = maxId + 1
|
|
140
|
+
const slug = ticketPrefix.value ? `${ticketPrefix.value}-${newId}` : String(newId)
|
|
94
141
|
const ticket: Ticket = {
|
|
95
|
-
id:
|
|
142
|
+
id: newId,
|
|
96
143
|
title: draft.title,
|
|
97
144
|
status: draft.status,
|
|
98
145
|
priority: draft.priority,
|
|
99
146
|
tags: [...draft.tags],
|
|
100
147
|
body: draft.body,
|
|
101
|
-
url:
|
|
148
|
+
url: `/${ticketsDir.value}/${slug}.html`,
|
|
102
149
|
}
|
|
103
150
|
tickets.value = [...tickets.value, ticket]
|
|
104
151
|
draftTicket.value = null
|
|
@@ -112,13 +159,20 @@ function deleteTicket(id: number) {
|
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
const filteredTickets = computed(() => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
162
|
+
let result = tickets.value
|
|
163
|
+
if (filter.value) {
|
|
164
|
+
const q = filter.value.toLowerCase()
|
|
165
|
+
result = result.filter(t =>
|
|
166
|
+
t.title.toLowerCase().includes(q) ||
|
|
167
|
+
t.tags.some(tag => tag.includes(q)) ||
|
|
168
|
+
formatId(t.id).toLowerCase().includes(q)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
if (selectedTags.value.length > 0) {
|
|
172
|
+
const tagSet = new Set(selectedTags.value)
|
|
173
|
+
result = result.filter(t => t.tags.some(tag => tagSet.has(tag)))
|
|
174
|
+
}
|
|
175
|
+
return result
|
|
122
176
|
})
|
|
123
177
|
|
|
124
178
|
const selectedTicket = computed(() =>
|
|
@@ -146,11 +200,20 @@ const { dragOverColumn, handleDragStart, handleDragEnd, handleDragOver, handleDr
|
|
|
146
200
|
<input
|
|
147
201
|
v-model="filter"
|
|
148
202
|
placeholder="Filter tickets..."
|
|
149
|
-
style="font-size: 12px; padding:
|
|
203
|
+
style="font-size: 12px; padding: 4px 10px; background: #171923; border: 1px solid #2d3748; border-radius: 5px; color: #e2e8f0; outline: none; width: 200px; height: 28px; box-sizing: border-box"
|
|
150
204
|
>
|
|
205
|
+
<TagFilterDropdown
|
|
206
|
+
v-model="selectedTags"
|
|
207
|
+
:tags="allTags"
|
|
208
|
+
/>
|
|
209
|
+
<button
|
|
210
|
+
v-if="ticketIssues.length > 0"
|
|
211
|
+
style="font-size: 12px; padding: 4px 12px; background: rgba(237, 137, 54, 0.12); border: 1px solid rgba(237, 137, 54, 0.4); border-radius: 5px; color: #ed8936; cursor: pointer; font-weight: 600; line-height: 1.2; height: 28px; box-sizing: border-box"
|
|
212
|
+
@click="showFixModal = true"
|
|
213
|
+
>⚠ Fix {{ ticketIssues.length }} ticket{{ ticketIssues.length === 1 ? '' : 's' }}</button>
|
|
151
214
|
<button
|
|
152
215
|
title="New ticket"
|
|
153
|
-
style="font-size: 13px; padding: 4px 12px; background: #2d3748; border: 1px solid #4a5568; border-radius: 5px; color: #e2e8f0; cursor: pointer; font-weight: 600; line-height: 1.2"
|
|
216
|
+
style="font-size: 13px; padding: 4px 12px; background: #2d3748; border: 1px solid #4a5568; border-radius: 5px; color: #e2e8f0; cursor: pointer; font-weight: 600; line-height: 1.2; height: 28px; box-sizing: border-box"
|
|
154
217
|
@click="openNewTicket"
|
|
155
218
|
>+ New</button>
|
|
156
219
|
</div>
|
|
@@ -196,5 +259,16 @@ const { dragOverColumn, handleDragStart, handleDragEnd, handleDragOver, handleDr
|
|
|
196
259
|
@update="updateTicket"
|
|
197
260
|
@delete="deleteTicket"
|
|
198
261
|
/>
|
|
262
|
+
|
|
263
|
+
<!-- Fix modal -->
|
|
264
|
+
<TicketFixModal
|
|
265
|
+
v-if="showFixModal"
|
|
266
|
+
:issues="ticketIssues"
|
|
267
|
+
:tickets-dir="ticketsDir"
|
|
268
|
+
:ticket-prefix="ticketPrefix"
|
|
269
|
+
:demo="demo"
|
|
270
|
+
@close="showFixModal = false"
|
|
271
|
+
@fixed="onFixed"
|
|
272
|
+
/>
|
|
199
273
|
</div>
|
|
200
274
|
</template>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
tags: string[]
|
|
6
|
+
modelValue: string[]
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const emit = defineEmits<{
|
|
10
|
+
'update:modelValue': [tags: string[]]
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const open = ref(false)
|
|
14
|
+
const search = ref('')
|
|
15
|
+
const dropdownRef = ref<HTMLElement | null>(null)
|
|
16
|
+
|
|
17
|
+
const filteredTags = computed(() => {
|
|
18
|
+
if (!search.value) return props.tags
|
|
19
|
+
const q = search.value.toLowerCase()
|
|
20
|
+
return props.tags.filter(tag => tag.toLowerCase().includes(q))
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function toggle(tag: string) {
|
|
24
|
+
const selected = new Set(props.modelValue)
|
|
25
|
+
if (selected.has(tag)) {
|
|
26
|
+
selected.delete(tag)
|
|
27
|
+
} else {
|
|
28
|
+
selected.add(tag)
|
|
29
|
+
}
|
|
30
|
+
emit('update:modelValue', [...selected])
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clear() {
|
|
34
|
+
emit('update:modelValue', [])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function onClickOutside(e: MouseEvent) {
|
|
38
|
+
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
|
39
|
+
open.value = false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
|
44
|
+
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div ref="dropdownRef" style="position: relative">
|
|
49
|
+
<button
|
|
50
|
+
style="font-size: 12px; padding: 4px 12px; background: #2d3748; border: 1px solid #4a5568; border-radius: 5px; color: #e2e8f0; cursor: pointer; font-weight: 600; line-height: 1.2; height: 28px; box-sizing: border-box"
|
|
51
|
+
@click="open = !open"
|
|
52
|
+
>
|
|
53
|
+
Tags<span v-if="modelValue.length > 0"> ({{ modelValue.length }})</span>
|
|
54
|
+
</button>
|
|
55
|
+
|
|
56
|
+
<div
|
|
57
|
+
v-if="open"
|
|
58
|
+
style="position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; min-width: 220px; background: #171923; border: 1px solid #2d3748; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: flex; flex-direction: column; max-height: 300px"
|
|
59
|
+
>
|
|
60
|
+
<div style="padding: 8px; border-bottom: 1px solid #2d3748; display: flex; align-items: center; gap: 6px">
|
|
61
|
+
<input
|
|
62
|
+
v-model="search"
|
|
63
|
+
placeholder="Search tags..."
|
|
64
|
+
style="flex: 1; font-size: 12px; padding: 4px 8px; background: #0d1117; border: 1px solid #2d3748; border-radius: 4px; color: #e2e8f0; outline: none"
|
|
65
|
+
>
|
|
66
|
+
<button
|
|
67
|
+
v-if="modelValue.length > 0"
|
|
68
|
+
style="font-size: 11px; background: none; border: none; color: #718096; cursor: pointer; white-space: nowrap"
|
|
69
|
+
@click="clear"
|
|
70
|
+
>clear</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div style="overflow-y: auto; padding: 4px 0">
|
|
74
|
+
<label
|
|
75
|
+
v-for="tag in filteredTags"
|
|
76
|
+
:key="tag"
|
|
77
|
+
style="display: flex; align-items: center; gap: 8px; padding: 4px 12px; cursor: pointer; font-size: 12px; color: #e2e8f0"
|
|
78
|
+
@mouseenter="($event.currentTarget as HTMLElement).style.background = '#2d3748'"
|
|
79
|
+
@mouseleave="($event.currentTarget as HTMLElement).style.background = 'transparent'"
|
|
80
|
+
>
|
|
81
|
+
<input
|
|
82
|
+
type="checkbox"
|
|
83
|
+
:checked="modelValue.includes(tag)"
|
|
84
|
+
style="accent-color: #63b3ed"
|
|
85
|
+
@change="toggle(tag)"
|
|
86
|
+
>
|
|
87
|
+
<span style="display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px; background: #2d3748; color: #a0aec0">{{ tag }}</span>
|
|
88
|
+
</label>
|
|
89
|
+
<div
|
|
90
|
+
v-if="filteredTags.length === 0"
|
|
91
|
+
style="padding: 8px 12px; font-size: 12px; color: #718096"
|
|
92
|
+
>No tags found</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
@@ -41,6 +41,9 @@ const checks = computed(() => countCheckboxes(props.ticket.body))
|
|
|
41
41
|
const displayId = computed(() =>
|
|
42
42
|
props.ticketPrefix ? `${props.ticketPrefix}-${props.ticket.id}` : String(props.ticket.id)
|
|
43
43
|
)
|
|
44
|
+
const filePath = computed(() =>
|
|
45
|
+
props.ticket.url ? props.ticket.url.replace(/^\//, '').replace(/\.html$/, '.md') : ''
|
|
46
|
+
)
|
|
44
47
|
|
|
45
48
|
watch(() => props.ticket.id, () => {
|
|
46
49
|
draft.value = props.ticket.body
|
|
@@ -164,9 +167,10 @@ function onBackdropClick(e: MouseEvent) {
|
|
|
164
167
|
<span style="font-size: 12px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700">Description</span>
|
|
165
168
|
<button
|
|
166
169
|
v-if="!editing"
|
|
167
|
-
|
|
170
|
+
title="Edit description"
|
|
171
|
+
style="background: none; border: 1px solid #2d3748; color: #718096; cursor: pointer; font-size: 13px; padding: 4px 8px; border-radius: 4px; line-height: 1"
|
|
168
172
|
@click="draft = ticket.body; editing = true"
|
|
169
|
-
|
|
173
|
+
>✎</button>
|
|
170
174
|
<div v-else style="display: flex; gap: 6px">
|
|
171
175
|
<button
|
|
172
176
|
style="font-size: 12px; color: #6bcb6b; background: rgba(107, 203, 107, 0.09); border: 1px solid rgba(107, 203, 107, 0.27); border-radius: 4px; padding: 3px 12px; cursor: pointer"
|
|
@@ -231,10 +235,10 @@ function onBackdropClick(e: MouseEvent) {
|
|
|
231
235
|
<TagEditor :tags="ticket.tags" @add="addTag" @remove="removeTag" />
|
|
232
236
|
</div>
|
|
233
237
|
|
|
234
|
-
<!--
|
|
235
|
-
<div v-if="!createMode" style="margin-bottom: 20px">
|
|
236
|
-
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">
|
|
237
|
-
<span style="font-size:
|
|
238
|
+
<!-- File path -->
|
|
239
|
+
<div v-if="!createMode && filePath" style="margin-bottom: 20px">
|
|
240
|
+
<label style="display: block; font-size: 11px; color: #718096; text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 6px">File</label>
|
|
241
|
+
<span style="font-size: 12px; color: #4a5568; font-family: monospace; word-break: break-all">{{ filePath }}</span>
|
|
238
242
|
</div>
|
|
239
243
|
|
|
240
244
|
<!-- Create / Delete -->
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface TicketIssue {
|
|
5
|
+
file: string
|
|
6
|
+
currentId: number
|
|
7
|
+
currentSlug: string
|
|
8
|
+
fixedId: number
|
|
9
|
+
fixedSlug: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
issues: TicketIssue[]
|
|
14
|
+
ticketsDir: string
|
|
15
|
+
ticketPrefix: string
|
|
16
|
+
demo?: boolean
|
|
17
|
+
}>()
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
close: []
|
|
21
|
+
fixed: []
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const fixing = ref(false)
|
|
25
|
+
|
|
26
|
+
async function fix() {
|
|
27
|
+
fixing.value = true
|
|
28
|
+
try {
|
|
29
|
+
if (!props.demo) {
|
|
30
|
+
const res = await fetch('/__vitepress_pm_fix', {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ dir: props.ticketsDir, prefix: props.ticketPrefix }),
|
|
34
|
+
})
|
|
35
|
+
if (!res.ok) throw new Error(await res.text())
|
|
36
|
+
}
|
|
37
|
+
emit('fixed')
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error('Failed to fix tickets:', e)
|
|
40
|
+
} finally {
|
|
41
|
+
fixing.value = false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onBackdropClick(e: MouseEvent) {
|
|
46
|
+
if ((e.target as HTMLElement).classList.contains('fix-modal-backdrop')) {
|
|
47
|
+
emit('close')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div
|
|
54
|
+
class="fix-modal-backdrop"
|
|
55
|
+
style="position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(2px)"
|
|
56
|
+
@click="onBackdropClick"
|
|
57
|
+
>
|
|
58
|
+
<div style="width: 90vw; max-width: 640px; max-height: 80vh; background: #0d1117; border: 1px solid #2d3748; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 48px rgba(0,0,0,0.4)">
|
|
59
|
+
|
|
60
|
+
<!-- Header -->
|
|
61
|
+
<div style="display: flex; align-items: center; padding: 16px 24px; border-bottom: 1px solid #2d3748; background: #171923; gap: 12px">
|
|
62
|
+
<span style="font-size: 16px; font-weight: 700; color: #ed8936; flex: 1">⚠ Fix {{ issues.length }} ticket{{ issues.length === 1 ? '' : 's' }}</span>
|
|
63
|
+
<button
|
|
64
|
+
style="background: none; border: none; color: #718096; cursor: pointer; font-size: 20px; padding: 4px 8px; line-height: 1"
|
|
65
|
+
@click="emit('close')"
|
|
66
|
+
>×</button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<!-- Issue list -->
|
|
70
|
+
<div style="flex: 1; overflow-y: auto; padding: 16px 24px">
|
|
71
|
+
<div
|
|
72
|
+
v-for="issue in issues"
|
|
73
|
+
:key="issue.file"
|
|
74
|
+
style="display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #1a202c"
|
|
75
|
+
>
|
|
76
|
+
<div style="flex: 1; min-width: 0">
|
|
77
|
+
<div style="font-size: 13px; color: #a0aec0; font-family: monospace">
|
|
78
|
+
{{ issue.currentSlug }} <span style="color: #4a5568">(id: {{ issue.currentId }})</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<span style="color: #4a5568; font-size: 13px">→</span>
|
|
82
|
+
<div style="flex: 1; min-width: 0">
|
|
83
|
+
<div style="font-size: 13px; color: #6bcb6b; font-family: monospace">
|
|
84
|
+
{{ issue.fixedSlug }} <span style="color: #4a5568">(id: {{ issue.fixedId }})</span>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Footer -->
|
|
91
|
+
<div style="padding: 16px 24px; border-top: 1px solid #2d3748; display: flex; justify-content: flex-end; gap: 8px">
|
|
92
|
+
<button
|
|
93
|
+
style="font-size: 13px; color: #718096; background: none; border: 1px solid #2d3748; border-radius: 6px; padding: 6px 16px; cursor: pointer"
|
|
94
|
+
@click="emit('close')"
|
|
95
|
+
>Cancel</button>
|
|
96
|
+
<button
|
|
97
|
+
:disabled="fixing"
|
|
98
|
+
style="font-size: 13px; color: #1a202c; background: #ed8936; border: none; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-weight: 600"
|
|
99
|
+
@click="fix"
|
|
100
|
+
>{{ fixing ? 'Fixing...' : 'Fix all' }}</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
@@ -33,6 +33,104 @@ export function scanTickets(ticketsDir: string, dirRelative: string): ScannedTic
|
|
|
33
33
|
})
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
interface TicketIssue {
|
|
37
|
+
file: string
|
|
38
|
+
currentId: number
|
|
39
|
+
currentSlug: string
|
|
40
|
+
fixedId: number
|
|
41
|
+
fixedSlug: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate tickets for duplicate IDs, missing IDs, and filename/ID mismatches. */
|
|
45
|
+
export function validateTickets(ticketsDir: string, dirRelative: string, prefix: string): TicketIssue[] {
|
|
46
|
+
if (!fs.existsSync(ticketsDir)) return []
|
|
47
|
+
|
|
48
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'))
|
|
49
|
+
const issues: TicketIssue[] = []
|
|
50
|
+
|
|
51
|
+
// Parse all tickets
|
|
52
|
+
const entries = files.map(file => {
|
|
53
|
+
const raw = fs.readFileSync(path.join(ticketsDir, file), 'utf-8')
|
|
54
|
+
const parsed = matter(raw)
|
|
55
|
+
const id = Number(parsed.data.id) || 0
|
|
56
|
+
const slug = path.basename(file, '.md')
|
|
57
|
+
return { file, id, slug }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Detect duplicate IDs (only non-zero)
|
|
61
|
+
const idCounts = new Map<number, number>()
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
if (e.id > 0) idCounts.set(e.id, (idCounts.get(e.id) || 0) + 1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check each ticket for issues
|
|
67
|
+
for (const e of entries) {
|
|
68
|
+
const expectedSlug = prefix ? `${prefix}-${e.id}` : String(e.id)
|
|
69
|
+
const isDuplicate = e.id > 0 && (idCounts.get(e.id) || 0) > 1
|
|
70
|
+
const isMissing = e.id <= 0
|
|
71
|
+
const isMismatch = e.id > 0 && e.slug !== expectedSlug
|
|
72
|
+
|
|
73
|
+
if (isDuplicate || isMissing || isMismatch) {
|
|
74
|
+
issues.push({
|
|
75
|
+
file: e.file,
|
|
76
|
+
currentId: e.id,
|
|
77
|
+
currentSlug: e.slug,
|
|
78
|
+
fixedId: 0, // assigned below
|
|
79
|
+
fixedSlug: '', // assigned below
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Assign fresh sequential IDs to issues
|
|
85
|
+
// Collect IDs that are NOT problematic (keep them)
|
|
86
|
+
const goodIds = new Set<number>()
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
const expectedSlug = prefix ? `${prefix}-${e.id}` : String(e.id)
|
|
89
|
+
const isDuplicate = e.id > 0 && (idCounts.get(e.id) || 0) > 1
|
|
90
|
+
const isMissing = e.id <= 0
|
|
91
|
+
const isMismatch = e.id > 0 && e.slug !== expectedSlug
|
|
92
|
+
if (!isDuplicate && !isMissing && !isMismatch) {
|
|
93
|
+
goodIds.add(e.id)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let nextFixId = 1
|
|
98
|
+
for (const issue of issues) {
|
|
99
|
+
while (goodIds.has(nextFixId)) nextFixId++
|
|
100
|
+
issue.fixedId = nextFixId
|
|
101
|
+
issue.fixedSlug = prefix ? `${prefix}-${nextFixId}` : String(nextFixId)
|
|
102
|
+
goodIds.add(nextFixId)
|
|
103
|
+
nextFixId++
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return issues
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fix all ticket issues by rewriting frontmatter IDs and renaming files. */
|
|
110
|
+
function fixTickets(ticketsDir: string, dirRelative: string, prefix: string): TicketIssue[] {
|
|
111
|
+
const issues = validateTickets(ticketsDir, dirRelative, prefix)
|
|
112
|
+
if (issues.length === 0) return []
|
|
113
|
+
|
|
114
|
+
for (const issue of issues) {
|
|
115
|
+
const oldPath = path.join(ticketsDir, issue.file)
|
|
116
|
+
const newFile = `${issue.fixedSlug}.md`
|
|
117
|
+
const newPath = path.join(ticketsDir, newFile)
|
|
118
|
+
|
|
119
|
+
const raw = fs.readFileSync(oldPath, 'utf-8')
|
|
120
|
+
const parsed = matter(raw)
|
|
121
|
+
parsed.data.id = issue.fixedId
|
|
122
|
+
const output = matter.stringify(parsed.content, parsed.data)
|
|
123
|
+
|
|
124
|
+
// Write to new path first, then remove old if different
|
|
125
|
+
fs.writeFileSync(newPath, output)
|
|
126
|
+
if (oldPath !== newPath && fs.existsSync(oldPath)) {
|
|
127
|
+
fs.unlinkSync(oldPath)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return issues
|
|
132
|
+
}
|
|
133
|
+
|
|
36
134
|
/** Scan a tickets directory and return the highest numeric `id` found in frontmatter. */
|
|
37
135
|
export function getMaxTicketId(ticketsDir: string): number {
|
|
38
136
|
if (!fs.existsSync(ticketsDir)) return 0
|
|
@@ -89,6 +187,42 @@ export function markdownWriterPlugin(): Plugin {
|
|
|
89
187
|
res.end(JSON.stringify(tickets))
|
|
90
188
|
})
|
|
91
189
|
|
|
190
|
+
// Validate tickets for issues
|
|
191
|
+
server.middlewares.use('/__vitepress_pm_validate', (req, res) => {
|
|
192
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
193
|
+
const dir = url.searchParams.get('dir') || 'tickets'
|
|
194
|
+
const prefix = url.searchParams.get('prefix') || ''
|
|
195
|
+
const ticketsDir = path.resolve(srcDir, dir)
|
|
196
|
+
|
|
197
|
+
const issues = validateTickets(ticketsDir, dir, prefix)
|
|
198
|
+
res.setHeader('Content-Type', 'application/json')
|
|
199
|
+
res.end(JSON.stringify(issues))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Fix ticket issues (reassign IDs, rename files)
|
|
203
|
+
server.middlewares.use('/__vitepress_pm_fix', (req, res) => {
|
|
204
|
+
if (req.method !== 'POST') {
|
|
205
|
+
res.statusCode = 405
|
|
206
|
+
res.end('Method not allowed')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let body = ''
|
|
211
|
+
req.on('data', (chunk: string) => { body += chunk })
|
|
212
|
+
req.on('end', () => {
|
|
213
|
+
try {
|
|
214
|
+
const { dir, prefix } = JSON.parse(body)
|
|
215
|
+
const ticketsDir = path.resolve(srcDir, dir || 'tickets')
|
|
216
|
+
const fixed = fixTickets(ticketsDir, dir || 'tickets', prefix || '')
|
|
217
|
+
res.setHeader('Content-Type', 'application/json')
|
|
218
|
+
res.end(JSON.stringify(fixed))
|
|
219
|
+
} catch (e) {
|
|
220
|
+
res.statusCode = 500
|
|
221
|
+
res.end(String(e))
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
92
226
|
// Create a new ticket (server-assigned sequential ID)
|
|
93
227
|
server.middlewares.use('/__vitepress_pm_create', (req, res) => {
|
|
94
228
|
if (req.method !== 'POST') {
|
|
@@ -101,7 +235,7 @@ export function markdownWriterPlugin(): Plugin {
|
|
|
101
235
|
req.on('data', chunk => { body += chunk })
|
|
102
236
|
req.on('end', () => {
|
|
103
237
|
try {
|
|
104
|
-
const { dir, status, title, priority, tags, body: ticketBody } = JSON.parse(body)
|
|
238
|
+
const { dir, prefix, status, title, priority, tags, body: ticketBody } = JSON.parse(body)
|
|
105
239
|
const ticketsDir = path.resolve(srcDir, dir || 'tickets')
|
|
106
240
|
|
|
107
241
|
if (!fs.existsSync(ticketsDir)) {
|
|
@@ -121,15 +255,16 @@ export function markdownWriterPlugin(): Plugin {
|
|
|
121
255
|
tags: tags || [],
|
|
122
256
|
}
|
|
123
257
|
|
|
258
|
+
const slug = prefix ? `${prefix}-${id}` : String(id)
|
|
124
259
|
const bodyContent = ticketBody ? `\n${ticketBody}\n` : '\n'
|
|
125
260
|
const content = matter.stringify(bodyContent, frontmatter)
|
|
126
|
-
const filePath = path.join(ticketsDir, `${
|
|
261
|
+
const filePath = path.join(ticketsDir, `${slug}.md`)
|
|
127
262
|
fs.writeFileSync(filePath, content)
|
|
128
263
|
|
|
129
264
|
const ticket = {
|
|
130
265
|
...frontmatter,
|
|
131
266
|
body: ticketBody || '',
|
|
132
|
-
url: `/${dir || 'tickets'}/${
|
|
267
|
+
url: `/${dir || 'tickets'}/${slug}.html`,
|
|
133
268
|
}
|
|
134
269
|
|
|
135
270
|
res.setHeader('Content-Type', 'application/json')
|