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.
@@ -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
+ }