@raggle-ai/kennel-core-components 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Raggle AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@raggle-ai/kennel-core-components",
3
+ "version": "0.1.1",
4
+ "description": "Shared VitePress components for Kennel-backed documentation sites.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/raggle-ai/kennel.git",
10
+ "directory": "packages/core-components"
11
+ },
12
+ "publishConfig": {
13
+ "registry": "https://registry.npmjs.org",
14
+ "access": "public"
15
+ },
16
+ "files": [
17
+ "vitepress"
18
+ ],
19
+ "exports": {
20
+ ".": "./vitepress/todos.js",
21
+ "./todos": "./vitepress/todos.js",
22
+ "./todos-data": {
23
+ "import": "./vitepress/todos-data.js",
24
+ "require": "./vitepress/todos-data.cjs"
25
+ },
26
+ "./theme/TodoTracker": "./vitepress/theme/TodoTracker.vue",
27
+ "./theme/TodoTracker.vue": "./vitepress/theme/TodoTracker.vue",
28
+ "./theme/todo-tracker.css": "./vitepress/theme/todo-tracker.css"
29
+ },
30
+ "scripts": {
31
+ "pack:check": "npm pack --dry-run",
32
+ "publish:public": "npm --@raggle-ai:registry=https://registry.npmjs.org publish --access public --registry=https://registry.npmjs.org",
33
+ "publish:public:dry-run": "npm --@raggle-ai:registry=https://registry.npmjs.org publish --dry-run --access public --registry=https://registry.npmjs.org",
34
+ "test": "node --test && npm run pack:check"
35
+ },
36
+ "dependencies": {
37
+ "yaml": "^2.9.0"
38
+ },
39
+ "peerDependencies": {
40
+ "vitepress": "^1.6.4",
41
+ "vue": "^3.5.0"
42
+ },
43
+ "devDependencies": {
44
+ "vitepress": "^1.6.4",
45
+ "vue": "^3.5.13"
46
+ }
47
+ }
@@ -0,0 +1,133 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from 'vue'
3
+
4
+ const props = defineProps({
5
+ data: {
6
+ type: Object,
7
+ required: true,
8
+ },
9
+ })
10
+
11
+ const showDone = ref(false)
12
+ const collapsed = ref(new Set())
13
+
14
+ const activeFiles = computed(() =>
15
+ props.data.files
16
+ .map((file) => ({
17
+ ...file,
18
+ openTasks: file.tasks.filter((task) => task.status !== 'done'),
19
+ visibleTasks: showDone.value ? file.tasks : file.tasks.filter((task) => task.status !== 'done'),
20
+ }))
21
+ .filter((file) => file.visibleTasks.length > 0),
22
+ )
23
+
24
+ const hasTasks = computed(() => props.data.totals.total > 0)
25
+ const allCollapsed = computed(() => activeFiles.value.every((file) => collapsed.value.has(file.path)))
26
+ const openCount = computed(() => props.data.totals.todo + props.data.totals.in_progress + props.data.totals.blocked)
27
+ const visibleCount = computed(() => activeFiles.value.reduce((count, file) => count + file.visibleTasks.length, 0))
28
+
29
+ watch(
30
+ () => props.data.files.map((file) => file.path).join('\n'),
31
+ () => {
32
+ const paths = new Set(props.data.files.map((file) => file.path))
33
+ collapsed.value = new Set([...collapsed.value].filter((path) => paths.has(path)))
34
+ },
35
+ { immediate: true },
36
+ )
37
+
38
+ function statusLabel(status) {
39
+ if (status === 'in_progress') return 'In progress'
40
+ if (status === 'blocked') return 'Blocked'
41
+ if (status === 'done') return 'Done'
42
+
43
+ return 'Open'
44
+ }
45
+
46
+ function toggleFile(path) {
47
+ const next = new Set(collapsed.value)
48
+
49
+ if (next.has(path)) {
50
+ next.delete(path)
51
+ } else {
52
+ next.add(path)
53
+ }
54
+
55
+ collapsed.value = next
56
+ }
57
+
58
+ function toggleAll() {
59
+ collapsed.value = allCollapsed.value ? new Set() : new Set(activeFiles.value.map((file) => file.path))
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <section class="todo-tracker">
65
+ <header class="todo-tracker__header">
66
+ <div class="todo-tracker__title">
67
+ <h1>Todo Tracker</h1>
68
+ <p>{{ visibleCount }} visible tasks from {{ data.files.length }} todo files</p>
69
+ </div>
70
+ <div class="todo-tracker__header-actions">
71
+ <dl class="todo-tracker__stats" aria-label="Task summary">
72
+ <div>
73
+ <dt>Open</dt>
74
+ <dd>{{ openCount }}</dd>
75
+ </div>
76
+ <div>
77
+ <dt>Done</dt>
78
+ <dd>{{ data.totals.done }}</dd>
79
+ </div>
80
+ </dl>
81
+ <div class="todo-tracker__controls" aria-label="Todo tracker controls">
82
+ <button type="button" :aria-pressed="showDone" @click="showDone = !showDone">
83
+ {{ showDone ? "Hide done" : "Show done" }}
84
+ </button>
85
+ <button type="button" @click="toggleAll">
86
+ {{ allCollapsed ? "Expand all" : "Collapse all" }}
87
+ </button>
88
+ </div>
89
+ </div>
90
+ </header>
91
+
92
+ <p v-if="!hasTasks" class="todo-tracker__empty">No todo files with tasks were found.</p>
93
+
94
+ <template v-else>
95
+ <p v-if="activeFiles.length === 0" class="todo-tracker__empty">No open tasks. Show done to review completed work.</p>
96
+
97
+ <div v-else class="todo-tracker__files">
98
+ <article v-for="file in activeFiles" :key="file.path" class="todo-tracker__file">
99
+ <button
100
+ type="button"
101
+ class="todo-tracker__file-header"
102
+ :aria-expanded="!collapsed.has(file.path)"
103
+ @click="toggleFile(file.path)"
104
+ >
105
+ <div>
106
+ <h2>{{ file.title }}</h2>
107
+ <p>{{ file.path }}</p>
108
+ </div>
109
+ <span>
110
+ {{ file.openTasks.length }} open
111
+ <span aria-hidden="true">{{ collapsed.has(file.path) ? "+" : "-" }}</span>
112
+ </span>
113
+ </button>
114
+
115
+ <ul v-if="!collapsed.has(file.path)" class="todo-tracker__tasks">
116
+ <li v-for="task in file.visibleTasks" :key="`${file.path}:${task.title}`" :class="`is-${task.status}`">
117
+ <div class="todo-tracker__task-main">
118
+ <span class="todo-tracker__status">{{ statusLabel(task.status) }}</span>
119
+ <strong>{{ task.title }}</strong>
120
+ <p v-if="task.description">{{ task.description }}</p>
121
+ </div>
122
+ <div v-if="task.priority || task.due || task.tags.length" class="todo-tracker__meta">
123
+ <span v-if="task.priority">P{{ task.priority }}</span>
124
+ <span v-if="task.due">{{ task.due }}</span>
125
+ <span v-for="tag in task.tags" :key="tag">{{ tag }}</span>
126
+ </div>
127
+ </li>
128
+ </ul>
129
+ </article>
130
+ </div>
131
+ </template>
132
+ </section>
133
+ </template>
@@ -0,0 +1,294 @@
1
+ .todo-tracker {
2
+ margin: 24px 0 36px;
3
+ }
4
+
5
+ .todo-tracker h1,
6
+ .todo-tracker h2,
7
+ .todo-tracker p,
8
+ .todo-tracker dl {
9
+ margin: 0;
10
+ }
11
+
12
+ .todo-tracker__header {
13
+ align-items: flex-start;
14
+ border-bottom: 1px solid var(--vp-c-divider);
15
+ display: grid;
16
+ gap: 18px;
17
+ grid-template-columns: minmax(0, 1fr) auto;
18
+ padding: 0 0 18px;
19
+ }
20
+
21
+ .todo-tracker__title {
22
+ min-width: 0;
23
+ }
24
+
25
+ .todo-tracker__title h1 {
26
+ border: 0;
27
+ font-size: 28px;
28
+ letter-spacing: 0;
29
+ line-height: 1.18;
30
+ padding: 0;
31
+ }
32
+
33
+ .todo-tracker__title p {
34
+ color: var(--vp-c-text-2);
35
+ font-size: 14px;
36
+ margin-top: 6px;
37
+ }
38
+
39
+ .todo-tracker__header-actions {
40
+ align-items: flex-start;
41
+ display: flex;
42
+ gap: 12px;
43
+ }
44
+
45
+ .todo-tracker__stats {
46
+ display: grid;
47
+ gap: 6px;
48
+ grid-template-columns: repeat(2, 68px);
49
+ }
50
+
51
+ .todo-tracker__stats div {
52
+ border: 1px solid var(--vp-c-divider);
53
+ border-radius: 8px;
54
+ min-height: 54px;
55
+ padding: 8px 10px;
56
+ }
57
+
58
+ .todo-tracker__stats dt {
59
+ color: var(--vp-c-text-2);
60
+ font-size: 11px;
61
+ line-height: 1;
62
+ }
63
+
64
+ .todo-tracker__stats dd {
65
+ color: var(--vp-c-text-1);
66
+ font-size: 20px;
67
+ font-weight: 700;
68
+ line-height: 1.15;
69
+ margin-top: 5px;
70
+ }
71
+
72
+ .todo-tracker__controls {
73
+ border: 1px solid var(--vp-c-divider);
74
+ border-radius: 8px;
75
+ display: grid;
76
+ overflow: hidden;
77
+ }
78
+
79
+ .todo-tracker__controls button {
80
+ background: var(--vp-c-bg);
81
+ border: 0;
82
+ border-top: 1px solid var(--vp-c-divider);
83
+ color: var(--vp-c-text-1);
84
+ cursor: pointer;
85
+ font: inherit;
86
+ font-size: 13px;
87
+ font-weight: 600;
88
+ height: 34px;
89
+ line-height: 1;
90
+ min-width: 102px;
91
+ padding: 0 11px;
92
+ }
93
+
94
+ .todo-tracker__controls button:first-child {
95
+ border-top: 0;
96
+ }
97
+
98
+ .todo-tracker__controls button:hover,
99
+ .todo-tracker__controls button[aria-pressed="true"] {
100
+ background: var(--vp-c-bg-soft);
101
+ color: var(--vp-c-brand-1);
102
+ }
103
+
104
+ .todo-tracker__controls button:focus-visible,
105
+ .todo-tracker__file-header:focus-visible {
106
+ outline: 2px solid var(--vp-c-brand-1);
107
+ outline-offset: 2px;
108
+ }
109
+
110
+ .todo-tracker__empty {
111
+ color: var(--vp-c-text-2);
112
+ padding: 22px 0;
113
+ }
114
+
115
+ .todo-tracker__files {
116
+ display: grid;
117
+ gap: 12px;
118
+ margin-top: 18px;
119
+ }
120
+
121
+ .todo-tracker__file {
122
+ border: 1px solid var(--vp-c-divider);
123
+ border-radius: 8px;
124
+ overflow: hidden;
125
+ }
126
+
127
+ .todo-tracker__file-header {
128
+ align-items: center;
129
+ background: var(--vp-c-bg-soft);
130
+ border: 0;
131
+ color: inherit;
132
+ cursor: pointer;
133
+ display: grid;
134
+ font: inherit;
135
+ gap: 14px;
136
+ grid-template-columns: minmax(0, 1fr) auto;
137
+ padding: 12px 14px;
138
+ text-align: left;
139
+ width: 100%;
140
+ }
141
+
142
+ .todo-tracker__file-header:hover {
143
+ background: var(--vp-c-brand-soft);
144
+ }
145
+
146
+ .todo-tracker__file-header h2 {
147
+ border: 0;
148
+ font-size: 15px;
149
+ letter-spacing: 0;
150
+ line-height: 1.3;
151
+ padding: 0;
152
+ }
153
+
154
+ .todo-tracker__file-header p {
155
+ color: var(--vp-c-text-2);
156
+ font-size: 12px;
157
+ margin-top: 2px;
158
+ }
159
+
160
+ .todo-tracker__file-header span {
161
+ align-items: center;
162
+ color: var(--vp-c-text-2);
163
+ display: inline-flex;
164
+ flex: 0 0 auto;
165
+ font-size: 12px;
166
+ gap: 8px;
167
+ white-space: nowrap;
168
+ }
169
+
170
+ .todo-tracker__file-header span span {
171
+ border: 1px solid var(--vp-c-divider);
172
+ border-radius: 999px;
173
+ color: var(--vp-c-text-1);
174
+ display: inline-grid;
175
+ font-size: 14px;
176
+ font-weight: 700;
177
+ height: 22px;
178
+ line-height: 1;
179
+ place-items: center;
180
+ width: 22px;
181
+ }
182
+
183
+ .todo-tracker__tasks {
184
+ display: grid;
185
+ list-style: none;
186
+ margin: 0;
187
+ padding: 0;
188
+ }
189
+
190
+ .todo-tracker__tasks li {
191
+ align-items: flex-start;
192
+ border-top: 1px solid var(--vp-c-divider);
193
+ display: grid;
194
+ gap: 16px;
195
+ grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
196
+ padding: 12px 14px;
197
+ }
198
+
199
+ .todo-tracker__task-main {
200
+ min-width: 0;
201
+ }
202
+
203
+ .todo-tracker__task-main strong {
204
+ display: block;
205
+ font-size: 14px;
206
+ line-height: 1.35;
207
+ }
208
+
209
+ .todo-tracker__task-main p {
210
+ color: var(--vp-c-text-2);
211
+ font-size: 13px;
212
+ line-height: 1.45;
213
+ margin-top: 3px;
214
+ }
215
+
216
+ .todo-tracker__status,
217
+ .todo-tracker__meta span {
218
+ border: 1px solid var(--vp-c-divider);
219
+ border-radius: 999px;
220
+ color: var(--vp-c-text-2);
221
+ display: inline-flex;
222
+ font-size: 11px;
223
+ font-weight: 650;
224
+ line-height: 1;
225
+ padding: 4px 7px;
226
+ }
227
+
228
+ .todo-tracker__status {
229
+ margin-bottom: 6px;
230
+ }
231
+
232
+ .todo-tracker__meta {
233
+ align-items: flex-start;
234
+ display: flex;
235
+ flex-wrap: wrap;
236
+ gap: 5px;
237
+ justify-content: flex-end;
238
+ max-width: 230px;
239
+ }
240
+
241
+ .todo-tracker__tasks .is-done {
242
+ opacity: 0.58;
243
+ }
244
+
245
+ .todo-tracker__tasks .is-done strong {
246
+ text-decoration: line-through;
247
+ }
248
+
249
+ .todo-tracker__tasks .is-blocked .todo-tracker__status {
250
+ border-color: rgba(183, 76, 76, 0.42);
251
+ color: #b74c4c;
252
+ }
253
+
254
+ .todo-tracker__tasks .is-in_progress .todo-tracker__status,
255
+ .todo-tracker__tasks .is-todo .todo-tracker__status {
256
+ border-color: rgba(47, 93, 80, 0.42);
257
+ color: var(--vp-c-brand-1);
258
+ }
259
+
260
+ @media (max-width: 760px) {
261
+ .todo-tracker__header,
262
+ .todo-tracker__header-actions,
263
+ .todo-tracker__file-header,
264
+ .todo-tracker__tasks li {
265
+ grid-template-columns: 1fr;
266
+ }
267
+
268
+ .todo-tracker__header-actions {
269
+ display: grid;
270
+ }
271
+
272
+ .todo-tracker__stats {
273
+ grid-template-columns: repeat(2, minmax(0, 1fr));
274
+ }
275
+
276
+ .todo-tracker__controls {
277
+ grid-template-columns: repeat(2, minmax(0, 1fr));
278
+ }
279
+
280
+ .todo-tracker__controls button {
281
+ border-left: 1px solid var(--vp-c-divider);
282
+ border-top: 0;
283
+ min-width: 0;
284
+ }
285
+
286
+ .todo-tracker__controls button:first-child {
287
+ border-left: 0;
288
+ }
289
+
290
+ .todo-tracker__meta {
291
+ justify-content: flex-start;
292
+ max-width: none;
293
+ }
294
+ }
@@ -0,0 +1,152 @@
1
+ const { existsSync, lstatSync, readdirSync, readFileSync } = require('node:fs')
2
+ const { join, relative } = require('node:path')
3
+ const { parse } = require('yaml')
4
+
5
+ const defaultTodoFiles = new Set(['todo.yml', 'todo.yaml', '2do.yml', '2do.yaml'])
6
+ const defaultIgnoredDirectories = new Set([
7
+ '.git',
8
+ '.gtl',
9
+ '.turbo',
10
+ '.vitepress/cache',
11
+ '.vitepress/dist',
12
+ 'dist',
13
+ 'node_modules',
14
+ ])
15
+
16
+ function posix(path) {
17
+ return path.replace(/\\/g, '/')
18
+ }
19
+
20
+ function isIgnored(relativePath, ignoredDirectories) {
21
+ if (!relativePath) return false
22
+
23
+ return [...ignoredDirectories].some((ignored) => relativePath === ignored || relativePath.startsWith(`${ignored}/`))
24
+ }
25
+
26
+ function listTodoFiles(root, options = {}) {
27
+ const fileNames = new Set(options.fileNames ?? defaultTodoFiles)
28
+ const ignoredDirectories = new Set([...(options.ignoredDirectories ?? []), ...defaultIgnoredDirectories])
29
+ const files = []
30
+
31
+ function walk(dirPath) {
32
+ for (const entry of readdirSync(dirPath).sort((a, b) => a.localeCompare(b))) {
33
+ const fullPath = join(dirPath, entry)
34
+ const relativePath = posix(relative(root, fullPath))
35
+ const stat = lstatSync(fullPath, { throwIfNoEntry: false })
36
+
37
+ if (!stat) continue
38
+ if (stat.isSymbolicLink?.()) continue
39
+
40
+ if (stat.isDirectory()) {
41
+ if (!isIgnored(relativePath, ignoredDirectories)) walk(fullPath)
42
+
43
+ continue
44
+ }
45
+
46
+ if (fileNames.has(entry)) files.push(fullPath)
47
+ }
48
+ }
49
+
50
+ if (existsSync(root)) walk(root)
51
+
52
+ return files
53
+ }
54
+
55
+ function valueText(value) {
56
+ if (value === undefined || value === null) return undefined
57
+ if (typeof value === 'string') return value
58
+
59
+ return String(value)
60
+ }
61
+
62
+ function valueList(value) {
63
+ if (!Array.isArray(value)) return []
64
+
65
+ return value.map(valueText).filter(Boolean)
66
+ }
67
+
68
+ function taskStatus(task) {
69
+ if (task.done === true || task.completed === true || task.status === 'done' || task.status === 'completed') return 'done'
70
+ if (task.blocked === true || task.status === 'blocked') return 'blocked'
71
+ if (task.status === 'in_progress' || task.status === 'doing') return 'in_progress'
72
+
73
+ return 'todo'
74
+ }
75
+
76
+ function taskTitle(task, index) {
77
+ return valueText(task.name ?? task.title ?? task.task ?? task.text) ?? `Task ${index + 1}`
78
+ }
79
+
80
+ function normalizeTask(task, index) {
81
+ if (typeof task === 'string') {
82
+ return {
83
+ title: task,
84
+ status: 'todo',
85
+ tags: [],
86
+ }
87
+ }
88
+
89
+ const status = taskStatus(task)
90
+
91
+ return {
92
+ description: valueText(task.description ?? task.notes ?? task.note),
93
+ due: valueText(task.due ?? task.dueDate ?? task.date),
94
+ priority: valueText(task.priority),
95
+ status,
96
+ tags: valueList(task.tags),
97
+ title: taskTitle(task, index),
98
+ }
99
+ }
100
+
101
+ function normalizeTodoFile(filePath, root) {
102
+ const parsed = parse(readFileSync(filePath, 'utf8')) ?? {}
103
+ const rawTasks = Array.isArray(parsed) ? parsed : parsed.tasks
104
+ const tasks = Array.isArray(rawTasks) ? rawTasks.map(normalizeTask) : []
105
+ const relativePath = posix(relative(root, filePath))
106
+ const folder = relativePath.split('/').slice(0, -1).join('/') || '.'
107
+
108
+ return {
109
+ folder,
110
+ path: relativePath,
111
+ tasks,
112
+ title: valueText(parsed.title) ?? (folder === '.' ? 'Root' : folder),
113
+ }
114
+ }
115
+
116
+ function buildTodoTrackerData(root = process.cwd(), options = {}) {
117
+ const files = listTodoFiles(root, options).map((filePath) => normalizeTodoFile(filePath, root))
118
+ const totals = {
119
+ blocked: 0,
120
+ done: 0,
121
+ in_progress: 0,
122
+ todo: 0,
123
+ total: 0,
124
+ }
125
+
126
+ for (const file of files) {
127
+ for (const task of file.tasks) {
128
+ totals.total += 1
129
+ totals[task.status] += 1
130
+ }
131
+ }
132
+
133
+ return {
134
+ files,
135
+ generatedAt: new Date().toISOString(),
136
+ totals,
137
+ }
138
+ }
139
+
140
+ function defineTodoTrackerData(options = {}) {
141
+ return {
142
+ watch: options.watch ?? ['../**/todo.yml', '../**/todo.yaml', '../**/2do.yml', '../**/2do.yaml'],
143
+ load() {
144
+ return buildTodoTrackerData(options.root ?? process.cwd(), options)
145
+ },
146
+ }
147
+ }
148
+
149
+ module.exports = {
150
+ buildTodoTrackerData,
151
+ defineTodoTrackerData,
152
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { parse } from 'yaml'
4
+
5
+ const defaultTodoFiles = new Set(['todo.yml', 'todo.yaml', '2do.yml', '2do.yaml'])
6
+ const defaultIgnoredDirectories = new Set([
7
+ '.git',
8
+ '.gtl',
9
+ '.turbo',
10
+ '.vitepress/cache',
11
+ '.vitepress/dist',
12
+ 'dist',
13
+ 'node_modules',
14
+ ])
15
+
16
+ function posix(path) {
17
+ return path.replace(/\\/g, '/')
18
+ }
19
+
20
+ function isIgnored(relativePath, ignoredDirectories) {
21
+ if (!relativePath) return false
22
+
23
+ return [...ignoredDirectories].some((ignored) => relativePath === ignored || relativePath.startsWith(`${ignored}/`))
24
+ }
25
+
26
+ function listTodoFiles(root, options = {}) {
27
+ const fileNames = new Set(options.fileNames ?? defaultTodoFiles)
28
+ const ignoredDirectories = new Set([...(options.ignoredDirectories ?? []), ...defaultIgnoredDirectories])
29
+ const files = []
30
+
31
+ function walk(dirPath) {
32
+ for (const entry of readdirSync(dirPath).sort((a, b) => a.localeCompare(b))) {
33
+ const fullPath = join(dirPath, entry)
34
+ const relativePath = posix(relative(root, fullPath))
35
+ const stat = lstatSync(fullPath, { throwIfNoEntry: false })
36
+
37
+ if (!stat) continue
38
+ if (stat.isSymbolicLink?.()) continue
39
+
40
+ if (stat.isDirectory()) {
41
+ if (!isIgnored(relativePath, ignoredDirectories)) walk(fullPath)
42
+
43
+ continue
44
+ }
45
+
46
+ if (fileNames.has(entry)) files.push(fullPath)
47
+ }
48
+ }
49
+
50
+ if (existsSync(root)) walk(root)
51
+
52
+ return files
53
+ }
54
+
55
+ function valueText(value) {
56
+ if (value === undefined || value === null) return undefined
57
+ if (typeof value === 'string') return value
58
+
59
+ return String(value)
60
+ }
61
+
62
+ function valueList(value) {
63
+ if (!Array.isArray(value)) return []
64
+
65
+ return value.map(valueText).filter(Boolean)
66
+ }
67
+
68
+ function taskStatus(task) {
69
+ if (task.done === true || task.completed === true || task.status === 'done' || task.status === 'completed') return 'done'
70
+ if (task.blocked === true || task.status === 'blocked') return 'blocked'
71
+ if (task.status === 'in_progress' || task.status === 'doing') return 'in_progress'
72
+
73
+ return 'todo'
74
+ }
75
+
76
+ function taskTitle(task, index) {
77
+ return valueText(task.name ?? task.title ?? task.task ?? task.text) ?? `Task ${index + 1}`
78
+ }
79
+
80
+ function normalizeTask(task, index) {
81
+ if (typeof task === 'string') {
82
+ return {
83
+ title: task,
84
+ status: 'todo',
85
+ tags: [],
86
+ }
87
+ }
88
+
89
+ const status = taskStatus(task)
90
+
91
+ return {
92
+ description: valueText(task.description ?? task.notes ?? task.note),
93
+ due: valueText(task.due ?? task.dueDate ?? task.date),
94
+ priority: valueText(task.priority),
95
+ status,
96
+ tags: valueList(task.tags),
97
+ title: taskTitle(task, index),
98
+ }
99
+ }
100
+
101
+ function normalizeTodoFile(filePath, root) {
102
+ const parsed = parse(readFileSync(filePath, 'utf8')) ?? {}
103
+ const rawTasks = Array.isArray(parsed) ? parsed : parsed.tasks
104
+ const tasks = Array.isArray(rawTasks) ? rawTasks.map(normalizeTask) : []
105
+ const relativePath = posix(relative(root, filePath))
106
+ const folder = relativePath.split('/').slice(0, -1).join('/') || '.'
107
+
108
+ return {
109
+ folder,
110
+ path: relativePath,
111
+ tasks,
112
+ title: valueText(parsed.title) ?? (folder === '.' ? 'Root' : folder),
113
+ }
114
+ }
115
+
116
+ export function buildTodoTrackerData(root = process.cwd(), options = {}) {
117
+ const files = listTodoFiles(root, options).map((filePath) => normalizeTodoFile(filePath, root))
118
+ const totals = {
119
+ blocked: 0,
120
+ done: 0,
121
+ in_progress: 0,
122
+ todo: 0,
123
+ total: 0,
124
+ }
125
+
126
+ for (const file of files) {
127
+ for (const task of file.tasks) {
128
+ totals.total += 1
129
+ totals[task.status] += 1
130
+ }
131
+ }
132
+
133
+ return {
134
+ files,
135
+ generatedAt: new Date().toISOString(),
136
+ totals,
137
+ }
138
+ }
139
+
140
+ export function defineTodoTrackerData(options = {}) {
141
+ return {
142
+ watch: options.watch ?? ['../**/todo.yml', '../**/todo.yaml', '../**/2do.yml', '../**/2do.yaml'],
143
+ load() {
144
+ return buildTodoTrackerData(options.root ?? process.cwd(), options)
145
+ },
146
+ }
147
+ }
@@ -0,0 +1 @@
1
+ export { buildTodoTrackerData, defineTodoTrackerData } from './todos-data.js'