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 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, `${id}.md`);
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"}/${id}.html`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitepress-theme-pm",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A VitePress theme that adds a kanban board for project management with markdown tickets",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 content = matter.stringify('\n', frontmatter)
95
- const filePath = path.join(ticketsDir, `${id}.md`)
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}`)
@@ -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
- // Load tickets from dev plugin or static JSON
26
- if (typeof window !== 'undefined') {
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[]) => { tickets.value = data })
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: maxId + 1,
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
- if (!filter.value) return tickets.value
116
- const q = filter.value.toLowerCase()
117
- return tickets.value.filter(t =>
118
- t.title.toLowerCase().includes(q) ||
119
- t.tags.some(tag => tag.includes(q)) ||
120
- formatId(t.id).toLowerCase().includes(q)
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: 5px 10px; background: #171923; border: 1px solid #2d3748; border-radius: 5px; color: #e2e8f0; outline: none; width: 200px"
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
+ >&#9888; 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
- style="font-size: 12px; color: #e6a817; background: none; border: 1px solid rgba(230, 168, 23, 0.27); border-radius: 4px; padding: 3px 12px; cursor: pointer"
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
- >Edit</button>
173
+ >&#9998;</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
- <!-- Ticket ID -->
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">ID</label>
237
- <span style="font-size: 13px; color: #4a5568; font-family: monospace">{{ displayId }}</span>
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">&#9888; 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
+ >&times;</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">&rarr;</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, `${id}.md`)
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'}/${id}.html`,
267
+ url: `/${dir || 'tickets'}/${slug}.html`,
133
268
  }
134
269
 
135
270
  res.setHeader('Content-Type', 'application/json')