vitepress-theme-pm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/plugin.mjs +184 -0
- package/package.json +45 -0
- package/src/Layout.vue +15 -0
- package/src/cli.mjs +103 -0
- package/src/components/Board.vue +200 -0
- package/src/components/BoardCard.vue +73 -0
- package/src/components/BoardColumn.vue +90 -0
- package/src/components/MarkdownBody.vue +105 -0
- package/src/components/ProgressBar.vue +30 -0
- package/src/components/TagEditor.vue +65 -0
- package/src/components/TicketDetail.vue +257 -0
- package/src/composables/useDragDrop.ts +47 -0
- package/src/composables/useMarkdown.ts +24 -0
- package/src/composables/useTicketWriter.ts +32 -0
- package/src/index.ts +12 -0
- package/src/plugins/markdownWriter.ts +223 -0
- package/src/styles/board.css +40 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function countCheckboxes(body: string): { done: number; total: number } {
|
|
2
|
+
const matches = body.match(/- \[([ x])\]/g) || []
|
|
3
|
+
return {
|
|
4
|
+
done: matches.filter(m => m.includes('[x]')).length,
|
|
5
|
+
total: matches.length,
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toggleCheckbox(body: string, index: number): string {
|
|
10
|
+
let ci = -1
|
|
11
|
+
return body
|
|
12
|
+
.split('\n')
|
|
13
|
+
.map(line => {
|
|
14
|
+
const m = line.match(/^(\s*- \[)([ x])(\] .+)$/)
|
|
15
|
+
if (m) {
|
|
16
|
+
ci++
|
|
17
|
+
if (ci === index) {
|
|
18
|
+
return m[1] + (m[2] === 'x' ? ' ' : 'x') + m[3]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return line
|
|
22
|
+
})
|
|
23
|
+
.join('\n')
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useTicketWriter() {
|
|
4
|
+
const saving = ref(false)
|
|
5
|
+
const error = ref<string | null>(null)
|
|
6
|
+
|
|
7
|
+
const isDev = import.meta.env.DEV
|
|
8
|
+
|
|
9
|
+
async function writeTicket(url: string, updates: Record<string, unknown>) {
|
|
10
|
+
if (!isDev) return
|
|
11
|
+
|
|
12
|
+
saving.value = true
|
|
13
|
+
error.value = null
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch('/__vitepress_pm_update', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify({ url, updates }),
|
|
20
|
+
})
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
error.value = await res.text()
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
error.value = String(e)
|
|
26
|
+
} finally {
|
|
27
|
+
saving.value = false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { saving, error, writeTicket }
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Theme } from 'vitepress'
|
|
2
|
+
import DefaultTheme from 'vitepress/theme'
|
|
3
|
+
import Layout from './Layout.vue'
|
|
4
|
+
|
|
5
|
+
export type { Ticket, Column, BoardConfig } from './types'
|
|
6
|
+
|
|
7
|
+
const theme: Theme = {
|
|
8
|
+
extends: DefaultTheme,
|
|
9
|
+
Layout,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default theme
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import matter from 'gray-matter'
|
|
5
|
+
|
|
6
|
+
interface ScannedTicket {
|
|
7
|
+
id: number
|
|
8
|
+
title: string
|
|
9
|
+
status: string
|
|
10
|
+
priority: string
|
|
11
|
+
tags: string[]
|
|
12
|
+
body: string
|
|
13
|
+
url: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Scan a tickets directory and return all tickets as JSON-serializable objects. */
|
|
17
|
+
export function scanTickets(ticketsDir: string, dirRelative: string): ScannedTicket[] {
|
|
18
|
+
if (!fs.existsSync(ticketsDir)) return []
|
|
19
|
+
|
|
20
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'))
|
|
21
|
+
return files.map(file => {
|
|
22
|
+
const raw = fs.readFileSync(path.join(ticketsDir, file), 'utf-8')
|
|
23
|
+
const parsed = matter(raw)
|
|
24
|
+
return {
|
|
25
|
+
id: Number(parsed.data.id) || 0,
|
|
26
|
+
title: parsed.data.title || path.basename(file, '.md'),
|
|
27
|
+
status: parsed.data.status || 'backlog',
|
|
28
|
+
priority: parsed.data.priority || 'medium',
|
|
29
|
+
tags: parsed.data.tags || [],
|
|
30
|
+
body: parsed.content.trim(),
|
|
31
|
+
url: `/${dirRelative}/${path.basename(file, '.md')}.html`,
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Scan a tickets directory and return the highest numeric `id` found in frontmatter. */
|
|
37
|
+
export function getMaxTicketId(ticketsDir: string): number {
|
|
38
|
+
if (!fs.existsSync(ticketsDir)) return 0
|
|
39
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'))
|
|
40
|
+
let max = 0
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
const raw = fs.readFileSync(path.join(ticketsDir, file), 'utf-8')
|
|
43
|
+
const parsed = matter(raw)
|
|
44
|
+
const id = Number(parsed.data.id)
|
|
45
|
+
if (id > max) max = id
|
|
46
|
+
}
|
|
47
|
+
return max
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findMarkdownFiles(dir: string): string[] {
|
|
51
|
+
const results: string[] = []
|
|
52
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
const full = path.join(dir, entry.name)
|
|
54
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
55
|
+
results.push(...findMarkdownFiles(full))
|
|
56
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
57
|
+
results.push(full)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return results
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function markdownWriterPlugin(): Plugin {
|
|
64
|
+
let srcDir = ''
|
|
65
|
+
let nextId = 0
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: 'vitepress-pm-markdown-writer',
|
|
69
|
+
|
|
70
|
+
configResolved(config) {
|
|
71
|
+
srcDir = config.root
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
configureServer(server) {
|
|
75
|
+
// Serve ticket data as JSON
|
|
76
|
+
server.middlewares.use('/__vitepress_pm_tickets', (req, res) => {
|
|
77
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
78
|
+
const dir = url.searchParams.get('dir') || 'tickets'
|
|
79
|
+
const ticketsDir = path.resolve(srcDir, dir)
|
|
80
|
+
|
|
81
|
+
const tickets = scanTickets(ticketsDir, dir)
|
|
82
|
+
|
|
83
|
+
// Keep server counter in sync
|
|
84
|
+
let maxId = 0
|
|
85
|
+
for (const t of tickets) { if (t.id > maxId) maxId = t.id }
|
|
86
|
+
nextId = maxId + 1
|
|
87
|
+
|
|
88
|
+
res.setHeader('Content-Type', 'application/json')
|
|
89
|
+
res.end(JSON.stringify(tickets))
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// Create a new ticket (server-assigned sequential ID)
|
|
93
|
+
server.middlewares.use('/__vitepress_pm_create', (req, res) => {
|
|
94
|
+
if (req.method !== 'POST') {
|
|
95
|
+
res.statusCode = 405
|
|
96
|
+
res.end('Method not allowed')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let body = ''
|
|
101
|
+
req.on('data', chunk => { body += chunk })
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
try {
|
|
104
|
+
const { dir, status, title, priority, tags, body: ticketBody } = JSON.parse(body)
|
|
105
|
+
const ticketsDir = path.resolve(srcDir, dir || 'tickets')
|
|
106
|
+
|
|
107
|
+
if (!fs.existsSync(ticketsDir)) {
|
|
108
|
+
fs.mkdirSync(ticketsDir, { recursive: true })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Recalculate to be safe (another process may have created files)
|
|
112
|
+
const currentMax = getMaxTicketId(ticketsDir)
|
|
113
|
+
if (nextId <= currentMax) nextId = currentMax + 1
|
|
114
|
+
|
|
115
|
+
const id = nextId++
|
|
116
|
+
const frontmatter = {
|
|
117
|
+
id,
|
|
118
|
+
title: title || 'New ticket',
|
|
119
|
+
status: status || 'backlog',
|
|
120
|
+
priority: priority || 'medium',
|
|
121
|
+
tags: tags || [],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const bodyContent = ticketBody ? `\n${ticketBody}\n` : '\n'
|
|
125
|
+
const content = matter.stringify(bodyContent, frontmatter)
|
|
126
|
+
const filePath = path.join(ticketsDir, `${id}.md`)
|
|
127
|
+
fs.writeFileSync(filePath, content)
|
|
128
|
+
|
|
129
|
+
const ticket = {
|
|
130
|
+
...frontmatter,
|
|
131
|
+
body: ticketBody || '',
|
|
132
|
+
url: `/${dir || 'tickets'}/${id}.html`,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
res.setHeader('Content-Type', 'application/json')
|
|
136
|
+
res.end(JSON.stringify(ticket))
|
|
137
|
+
} catch (e) {
|
|
138
|
+
res.statusCode = 500
|
|
139
|
+
res.end(String(e))
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Update an existing ticket
|
|
145
|
+
server.middlewares.use('/__vitepress_pm_update', (req, res) => {
|
|
146
|
+
if (req.method !== 'POST') {
|
|
147
|
+
res.statusCode = 405
|
|
148
|
+
res.end('Method not allowed')
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let body = ''
|
|
153
|
+
req.on('data', chunk => { body += chunk })
|
|
154
|
+
req.on('end', () => {
|
|
155
|
+
try {
|
|
156
|
+
const { url, updates } = JSON.parse(body)
|
|
157
|
+
if (!url || typeof url !== 'string') {
|
|
158
|
+
res.statusCode = 400
|
|
159
|
+
res.end('Missing url')
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// url is like /tickets/1.html -> tickets/1.md
|
|
164
|
+
const mdPath = url.replace(/\.html$/, '.md').replace(/^\//, '')
|
|
165
|
+
const filePath = path.resolve(srcDir, mdPath)
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(filePath)) {
|
|
168
|
+
res.statusCode = 404
|
|
169
|
+
res.end(`File not found: ${mdPath}`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
174
|
+
const parsed = matter(raw)
|
|
175
|
+
|
|
176
|
+
// Merge updates into frontmatter
|
|
177
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
178
|
+
if (key === 'body') {
|
|
179
|
+
parsed.content = '\n' + String(value) + '\n'
|
|
180
|
+
} else {
|
|
181
|
+
parsed.data[key] = value
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const output = matter.stringify(parsed.content, parsed.data)
|
|
186
|
+
fs.writeFileSync(filePath, output)
|
|
187
|
+
|
|
188
|
+
res.statusCode = 200
|
|
189
|
+
res.end('ok')
|
|
190
|
+
} catch (e) {
|
|
191
|
+
res.statusCode = 500
|
|
192
|
+
res.end(String(e))
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
generateBundle() {
|
|
199
|
+
// Find .md files with board: true frontmatter and emit static ticket JSON
|
|
200
|
+
const mdFiles = findMarkdownFiles(srcDir)
|
|
201
|
+
const seen = new Set<string>()
|
|
202
|
+
|
|
203
|
+
for (const file of mdFiles) {
|
|
204
|
+
const raw = fs.readFileSync(file, 'utf-8')
|
|
205
|
+
const parsed = matter(raw)
|
|
206
|
+
if (!parsed.data.board) continue
|
|
207
|
+
|
|
208
|
+
const dir = parsed.data.ticketsDir || 'tickets'
|
|
209
|
+
if (seen.has(dir)) continue
|
|
210
|
+
seen.add(dir)
|
|
211
|
+
|
|
212
|
+
const ticketsDir = path.resolve(srcDir, dir)
|
|
213
|
+
const tickets = scanTickets(ticketsDir, dir)
|
|
214
|
+
|
|
215
|
+
this.emitFile({
|
|
216
|
+
type: 'asset',
|
|
217
|
+
fileName: `__vitepress_pm_tickets/${dir}.json`,
|
|
218
|
+
source: JSON.stringify(tickets),
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--board-bg: #0d1117;
|
|
3
|
+
--board-surface: #1a202c;
|
|
4
|
+
--board-surface-hover: #2d3748;
|
|
5
|
+
--board-border: #2d3748;
|
|
6
|
+
--board-text: #e2e8f0;
|
|
7
|
+
--board-text-secondary: #a0aec0;
|
|
8
|
+
--board-text-muted: #718096;
|
|
9
|
+
--board-accent: #e6a817;
|
|
10
|
+
--board-priority-critical: #f56565;
|
|
11
|
+
--board-priority-high: #ed8936;
|
|
12
|
+
--board-priority-medium: #ecc94b;
|
|
13
|
+
--board-priority-low: #68d391;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.board-columns::-webkit-scrollbar {
|
|
17
|
+
height: 6px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.board-columns::-webkit-scrollbar-track {
|
|
21
|
+
background: transparent;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.board-columns::-webkit-scrollbar-thumb {
|
|
25
|
+
background: var(--board-border);
|
|
26
|
+
border-radius: 3px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.board-column-cards::-webkit-scrollbar {
|
|
30
|
+
width: 4px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.board-column-cards::-webkit-scrollbar-track {
|
|
34
|
+
background: transparent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.board-column-cards::-webkit-scrollbar-thumb {
|
|
38
|
+
background: var(--board-border);
|
|
39
|
+
border-radius: 2px;
|
|
40
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Ticket {
|
|
2
|
+
id: number
|
|
3
|
+
title: string
|
|
4
|
+
status: string
|
|
5
|
+
priority: 'critical' | 'high' | 'medium' | 'low'
|
|
6
|
+
tags: string[]
|
|
7
|
+
body: string
|
|
8
|
+
url: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Column {
|
|
12
|
+
key: string
|
|
13
|
+
label: string
|
|
14
|
+
color: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoardConfig {
|
|
18
|
+
columns: Column[]
|
|
19
|
+
ticketsDir: string
|
|
20
|
+
ticketPrefix: string
|
|
21
|
+
}
|