andy-note-nuxt 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.
@@ -0,0 +1,838 @@
1
+ <script setup lang="ts">
2
+ // `minimark` ships with @nuxt/content as a transitive dep — its body field is
3
+ // minimark AST. `stringify` converts that AST back to markdown faithfully, so we
4
+ // can produce a copy-friendly markdown blob without forcing consumers to enable
5
+ // `rawbody` in their collection schema. See https://content.nuxt.com/docs/integrations/llms.
6
+ import { stringify as stringifyMinimark } from 'minimark/stringify'
7
+ import { toast } from 'vue-sonner'
8
+ import { useFloating, offset, flip, shift, autoUpdate } from '@floating-ui/vue'
9
+
10
+ interface ContentNode {
11
+ path: string
12
+ title?: string
13
+ description?: string
14
+ document_type?: string
15
+ [key: string]: any
16
+ }
17
+
18
+ interface SectionGroup {
19
+ key: string
20
+ path: string
21
+ title: string
22
+ description?: string
23
+ count: number
24
+ documentType?: string
25
+ }
26
+
27
+ const props = defineProps<{
28
+ path: string
29
+ noThrow?: boolean
30
+ }>()
31
+
32
+ const path = computed(() => props.path)
33
+
34
+ // Stacked-column "trail" highlight: a list item is considered "drilled" when
35
+ // its path appears in the full column stack (i.e. it has been clicked open as
36
+ // a deeper column). The current column's own path is excluded so a section
37
+ // listing doesn't highlight itself. On mobile / standalone render `fullStack`
38
+ // only contains the current path, so nothing highlights — correct.
39
+ const { fullStack } = useStack()
40
+ function isDrilled(itemPath: string): boolean {
41
+ return itemPath !== path.value && fullStack.value.includes(itemPath)
42
+ }
43
+
44
+ function normalizePath(rawPath: string) {
45
+ const normalized = rawPath
46
+ .replace(/\/+/g, '/')
47
+ .replace(/\/$/, '')
48
+ return normalized || '/'
49
+ }
50
+
51
+ function slugToTitle(slug: string) {
52
+ return slug
53
+ .replace(/[-_]/g, ' ')
54
+ .replace(/\b\w/g, char => char.toUpperCase())
55
+ }
56
+
57
+ // Nuxt Content v3 rejects queries containing `--` as SQL comments (`assertSafeQuery`).
58
+ // Some wiki content has malformed empty links pointing to encoded frontmatter strings,
59
+ // which produce route paths containing `---`. Reject those early as 404 instead of
60
+ // letting them blow up the prerender with an unhandled 500.
61
+ const malformedPath = computed(() => /--|\s/.test(path.value))
62
+
63
+ if (malformedPath.value && !props.noThrow) {
64
+ throw createError({ statusCode: 404, message: 'Page not found' })
65
+ }
66
+
67
+ // Guard all queries: when path is malformed, skip them entirely (queryCollection
68
+ // would crash with assertSafeQuery before we could handle the error).
69
+ const { data: page } = await useAsyncData(`content-${path.value}`, () => {
70
+ if (malformedPath.value) return Promise.resolve(null)
71
+ return queryCollection('content')
72
+ .where('path', '=', path.value)
73
+ .first()
74
+ })
75
+
76
+ // Query descendants regardless of whether `_index.md` exists for the path — that lets
77
+ // section roots like `/builds`, `/`, `/wiki` render a listing of their children even
78
+ // without an explicit index file. Path = '/' must use prefix '/' (not '//') to match all.
79
+ const childrenPrefix = path.value === '/' ? '/' : `${path.value}/`
80
+ const { data: allChildren } = await useAsyncData(`children-${path.value}`, () => {
81
+ if (malformedPath.value) return Promise.resolve([])
82
+ return queryCollection('content')
83
+ .where('path', 'LIKE', `${childrenPrefix}%`)
84
+ .where('path', '<>', path.value)
85
+ .where('path', 'NOT LIKE', '%/_index')
86
+ // The convention filter used to live here as a SQL `where`, but SQL's
87
+ // three-valued logic treats `NULL <> 'convention'` as NULL (not true),
88
+ // so any row whose schema doesn't set document_type — i.e. most rows —
89
+ // got silently filtered out and listings rendered as empty article
90
+ // views. The client-side hierarchy filter (`!== 'convention'`) handles
91
+ // this correctly in JS where `undefined !== 'convention'` is true.
92
+ .select('path', 'title', 'description', 'document_type', 'updated', 'created')
93
+ .all()
94
+ })
95
+
96
+ // "Not found" only when path is malformed OR there's no page AND no children to list.
97
+ // Pure section paths (`/builds`, `/`) are valid even without `_index.md` if children exist.
98
+ const notFound = computed(() => {
99
+ if (malformedPath.value) return true
100
+ if (page.value) return false
101
+ return (allChildren.value?.length ?? 0) === 0
102
+ })
103
+
104
+ if (notFound.value && !props.noThrow) {
105
+ throw createError({ statusCode: 404, message: 'Page not found' })
106
+ }
107
+
108
+ // Recency timestamp for sort: prefer `updated`, fallback `created`, fallback 0 (oldest).
109
+ // Returns ms since epoch so a single numeric compare works for desc sort.
110
+ function nodeRecency(node: ContentNode | undefined): number {
111
+ if (!node) return 0
112
+ const updated = node.updated as string | undefined
113
+ const created = node.created as string | undefined
114
+ const raw = updated || created
115
+ if (!raw) return 0
116
+ const ts = Date.parse(raw)
117
+ return Number.isFinite(ts) ? ts : 0
118
+ }
119
+
120
+ // Creation timestamp only — distinct from `nodeRecency` which folds in `updated`.
121
+ // The LATEST list ranks strictly by `created` (a recently *edited* old note is not
122
+ // "new"). Returns 0 when `created` is missing/unparseable so those nodes can be
123
+ // dropped — a node with no creation date has no claim to being among the newest.
124
+ function createdTime(node: ContentNode | undefined): number {
125
+ const created = node?.created as string | undefined
126
+ if (!created) return 0
127
+ const ts = Date.parse(created)
128
+ return Number.isFinite(ts) ? ts : 0
129
+ }
130
+
131
+ const hierarchy = computed(() => {
132
+ const nodes = (allChildren.value ?? []) as ContentNode[]
133
+ const prefix = path.value === '/' ? '/' : `${path.value}/`
134
+
135
+ const descendants = nodes
136
+ .filter(node => node.document_type !== 'convention' && node.path.startsWith(prefix))
137
+ .map((node) => {
138
+ const relative = node.path.slice(prefix.length)
139
+ const segments = relative.split('/').filter(Boolean)
140
+ return { node, relative, segments }
141
+ })
142
+ .filter(entry => entry.segments.length > 0)
143
+
144
+ const direct = descendants.filter(entry => entry.segments.length === 1)
145
+ const directByPath = new Map(direct.map(entry => [entry.node.path, entry.node]))
146
+
147
+ const nestedCountsBySection = new Map<string, number>()
148
+ // Track max recency per section (across the section's index page + all nested descendants)
149
+ // so sections with the most recently updated content surface to the top.
150
+ const sectionRecency = new Map<string, number>()
151
+ const recordSectionRecency = (key: string, ts: number) => {
152
+ const prev = sectionRecency.get(key) ?? 0
153
+ if (ts > prev) sectionRecency.set(key, ts)
154
+ }
155
+
156
+ for (const entry of descendants) {
157
+ const key = entry.segments[0]!
158
+ const ts = nodeRecency(entry.node)
159
+ if (entry.segments.length >= 2) {
160
+ nestedCountsBySection.set(key, (nestedCountsBySection.get(key) || 0) + 1)
161
+ recordSectionRecency(key, ts)
162
+ }
163
+ else if (nestedCountsBySection.has(key) || ts > 0) {
164
+ // Direct index page of a section also contributes its own recency.
165
+ recordSectionRecency(key, ts)
166
+ }
167
+ }
168
+
169
+ const sectionKeys = Array.from(nestedCountsBySection.keys()).sort((a, b) => {
170
+ const diff = (sectionRecency.get(b) ?? 0) - (sectionRecency.get(a) ?? 0)
171
+ if (diff !== 0) return diff
172
+ return a.localeCompare(b)
173
+ })
174
+ const sections: SectionGroup[] = sectionKeys.map((key) => {
175
+ const sectionPath = normalizePath(`${path.value}/${key}`)
176
+ const indexPage = directByPath.get(sectionPath)
177
+ const nestedCount = nestedCountsBySection.get(key) || 0
178
+
179
+ return {
180
+ key,
181
+ path: sectionPath,
182
+ title: indexPage?.title || slugToTitle(key),
183
+ description: indexPage?.description,
184
+ count: nestedCount,
185
+ documentType: indexPage?.document_type,
186
+ }
187
+ })
188
+
189
+ const rootFiles = direct
190
+ .filter(entry => !nestedCountsBySection.has(entry.segments[0]!))
191
+ .map(entry => entry.node)
192
+ .sort((a, b) => {
193
+ const diff = nodeRecency(b) - nodeRecency(a)
194
+ if (diff !== 0) return diff
195
+ return (a.title || '').localeCompare(b.title || '')
196
+ })
197
+
198
+ return {
199
+ sections,
200
+ rootFiles,
201
+ immediateCount: sections.length + rootFiles.length,
202
+ }
203
+ })
204
+
205
+ // LATEST: the 5 most recently *created* leaf articles anywhere in the current
206
+ // column's subtree (including nested sub-folders), newest first. Excludes:
207
+ // - folder/section index pages (only actual articles are "new content")
208
+ // - articles already listed in this column's ARTICLES section (dedup — the
209
+ // direct rootFiles are visible right below, so re-listing them adds noise;
210
+ // this makes LATEST surface the newest *nested* articles instead)
211
+ // - nodes with no parseable `created` (can't rank them by recency)
212
+ // Result: at a flat leaf folder every article is a rootFile → all deduped → list
213
+ // empty → section auto-hides. At /mechanics or / it surfaces what's new deep in
214
+ // the tree without the reader having to drill each sub-folder.
215
+ const latest = computed<ContentNode[]>(() => {
216
+ const nodes = (allChildren.value ?? []) as ContentNode[]
217
+ const prefix = path.value === '/' ? '/' : `${path.value}/`
218
+ const inSubtree = nodes.filter(node => node.path.startsWith(prefix))
219
+
220
+ // A path is a folder iff some descendant lives under it. Collect every
221
+ // ancestor prefix within the subtree into a set; anything in that set is a
222
+ // folder index, not a leaf article.
223
+ const folderPaths = new Set<string>()
224
+ for (const node of inSubtree) {
225
+ const segments = node.path.slice(prefix.length).split('/').filter(Boolean)
226
+ let acc = prefix.replace(/\/$/, '')
227
+ for (let i = 0; i < segments.length - 1; i++) {
228
+ acc = normalizePath(`${acc}/${segments[i]}`)
229
+ folderPaths.add(acc)
230
+ }
231
+ }
232
+
233
+ const rootFilePaths = new Set(hierarchy.value.rootFiles.map(file => file.path))
234
+
235
+ return inSubtree
236
+ .filter(node => !folderPaths.has(node.path))
237
+ .filter(node => !rootFilePaths.has(node.path))
238
+ .filter(node => createdTime(node) > 0)
239
+ .sort((a, b) => createdTime(b) - createdTime(a))
240
+ .slice(0, 5)
241
+ })
242
+
243
+ const isList = computed(() => {
244
+ return hierarchy.value.sections.length > 0 || hierarchy.value.rootFiles.length > 0
245
+ })
246
+
247
+ useHead({
248
+ title: page.value?.title,
249
+ meta: [
250
+ { name: 'description', content: (page.value as any)?.description || '' },
251
+ { property: 'og:title', content: page.value?.title || '' },
252
+ ],
253
+ })
254
+
255
+ function toKebab(str: string) {
256
+ return str.trim().toLowerCase().replace(/\s+/g, '-')
257
+ }
258
+
259
+ function toTitleCase(str: string) {
260
+ return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
261
+ }
262
+
263
+ // Tag list rendered above the article body. Duplicates are normalized via
264
+ // `toKebab` so callers can write tags freely without worrying about case /
265
+ // whitespace collisions. The `important` channel is kept on the shape for
266
+ // consumers who want to mark a subset of tags as high-emphasis — they can
267
+ // extend this computed in their own renderer fork; the layer itself doesn't
268
+ // promote any tag by default.
269
+ const allTags = computed(() => {
270
+ const p = page.value as any
271
+ const seen = new Set<string>()
272
+ const result: Array<{ value: string; important: boolean }> = []
273
+
274
+ const add = (val: string | undefined | null, important = false) => {
275
+ if (!val) return
276
+ const k = toKebab(val)
277
+ if (k && !seen.has(k)) { seen.add(k); result.push({ value: k, important }) }
278
+ }
279
+
280
+ for (const tag of (p?.tags || [])) add(tag)
281
+
282
+ return result
283
+ })
284
+
285
+ // Smart H1 dedup: Nuxt Content v3 stores body in minimark format (`[tag, props, ...children]`
286
+ // tuples in `body.value`). When authors write a leading `# Heading` AND set a frontmatter
287
+ // `title`, both render as H1 → duplicate. We detect the body's leading H1, prefer its text
288
+ // for the visible heading, and strip it from the body before passing to ContentRenderer.
289
+ // Frontmatter `title` still drives `<title>` / `og:title` for SEO consistency.
290
+ type MinimarkNode = string | [string, Record<string, any>, ...any[]]
291
+
292
+ function flattenMinimarkText(node: any): string {
293
+ if (typeof node === 'string') return node
294
+ if (!Array.isArray(node)) return ''
295
+ // Tuple shape: [tag, props, ...children] — text starts at index 2.
296
+ return node.slice(2).map(flattenMinimarkText).join('')
297
+ }
298
+
299
+ function isMinimarkBody(body: any): boolean {
300
+ // Cover both legacy 'minimal' and current 'minimark' formats; both expose `body.value` array.
301
+ return !!body && (body.type === 'minimark' || body.type === 'minimal') && Array.isArray(body.value)
302
+ }
303
+
304
+ const bodyLeadingH1 = computed<string | null>(() => {
305
+ const body = (page.value as any)?.body
306
+ if (!isMinimarkBody(body)) return null
307
+ const first = body.value[0] as MinimarkNode | undefined
308
+ if (!Array.isArray(first) || first[0] !== 'h1') return null
309
+ const text = flattenMinimarkText(first).trim()
310
+ return text || null
311
+ })
312
+
313
+ // Cloned page with leading H1 removed when present — passed to ContentRenderer to avoid
314
+ // rendering the same heading twice (once as our `<h1>`, once from the body).
315
+ const renderedPage = computed(() => {
316
+ if (!page.value) return null
317
+ if (!bodyLeadingH1.value) return page.value
318
+ const body = (page.value as any).body
319
+ return {
320
+ ...(page.value as any),
321
+ body: {
322
+ ...body,
323
+ value: body.value.slice(1),
324
+ },
325
+ }
326
+ })
327
+
328
+ // Truthy `page.body` is not enough — Nuxt Content v3 always emits a body object
329
+ // (`{ type: 'minimark', value: [] }`) even for frontmatter-only pages, and
330
+ // `renderedPage` may further strip the leading H1, leaving an empty value array.
331
+ // We must check the post-strip array length to decide whether to render the wrapper.
332
+ const hasRenderedBody = computed(() => {
333
+ const body = (renderedPage.value as any)?.body
334
+ if (!isMinimarkBody(body)) return false
335
+ return body.value.length > 0
336
+ })
337
+
338
+ // Title priority: body H1 (closest to author intent in markdown) → frontmatter title (template
339
+ // metadata) → derived from path slug (for index-less section roots).
340
+ const displayTitle = computed(() => {
341
+ if (bodyLeadingH1.value) return bodyLeadingH1.value
342
+ if (page.value?.title) return page.value.title
343
+ const last = path.value.split('/').filter(Boolean).pop()
344
+ return last ? slugToTitle(last) : 'Home'
345
+ })
346
+
347
+ const sectionIndex = computed(() => {
348
+ const segments = path.value.split('/').filter(Boolean)
349
+ return segments.length === 0 ? 0 : segments.length
350
+ })
351
+
352
+ // Copy-as-markdown for the column's content.
353
+ //
354
+ // Strategy: produce the *fullest* markdown the column has access to.
355
+ //
356
+ // 1. If `page.rawbody` is present (consumer opted into it via collection
357
+ // schema — see https://content.nuxt.com/docs/integrations/llms), prefer
358
+ // it: it's a byte-faithful copy of the original `.md` source.
359
+ // 2. Otherwise compose: `# Title` + description + stringified body
360
+ // (minimark AST → markdown) + listing blocks (Latest / Folders / Articles).
361
+ // Stringify is lossy at the edges (custom MDC components may not round-trip
362
+ // perfectly), but matches the rendered content closely enough for LLM
363
+ // ingestion and clipboard sharing.
364
+
365
+ type CopyState = 'idle' | 'copied' | 'error'
366
+ const copyState = ref<CopyState>('idle')
367
+ let copyResetTimer: ReturnType<typeof setTimeout> | null = null
368
+
369
+ function buildMarkdown(): string {
370
+ const raw = (page.value as any)?.rawbody as string | undefined
371
+ if (typeof raw === 'string' && raw.trim().length > 0) {
372
+ const trimmed = raw.trimStart()
373
+ // Author already wrote a leading `# Heading` → trust it. Otherwise prepend
374
+ // displayTitle so the copy never lands on the clipboard headless.
375
+ return trimmed.startsWith('# ') ? raw : `# ${displayTitle.value}\n\n${raw}`
376
+ }
377
+
378
+ const lines: string[] = [`# ${displayTitle.value}`, '']
379
+
380
+ const desc = (page.value as any)?.description
381
+ if (desc) {
382
+ lines.push(String(desc).trim(), '')
383
+ }
384
+
385
+ const body = (page.value as any)?.body
386
+ if (isMinimarkBody(body) && body.value.length > 0) {
387
+ // If the body opens with the same H1 as displayTitle, drop it — we already
388
+ // emitted one above; otherwise leading H2/etc. should be preserved.
389
+ const skipFirst = !!bodyLeadingH1.value && bodyLeadingH1.value === displayTitle.value
390
+ const value = skipFirst ? body.value.slice(1) : body.value
391
+ if (value.length > 0) {
392
+ try {
393
+ const md = stringifyMinimark({ type: 'minimark', value }).trim()
394
+ if (md) lines.push(md, '')
395
+ }
396
+ catch {
397
+ // Stringify can throw on malformed minimark — degrade gracefully and
398
+ // skip the body rather than poisoning the entire copy.
399
+ }
400
+ }
401
+ }
402
+
403
+ if (latest.value.length) {
404
+ lines.push('## Latest', '')
405
+ for (const file of latest.value) {
406
+ const title = file.title || slugToTitle(file.path.split('/').pop() || '')
407
+ lines.push(`- [${title}](${file.path})`)
408
+ }
409
+ lines.push('')
410
+ }
411
+ if (hierarchy.value.sections.length) {
412
+ lines.push('## Folders', '')
413
+ for (const section of hierarchy.value.sections) {
414
+ lines.push(`- [${section.title}](${section.path}) — ${section.count}`)
415
+ }
416
+ lines.push('')
417
+ }
418
+ if (hierarchy.value.rootFiles.length) {
419
+ lines.push('## Articles', '')
420
+ for (const file of hierarchy.value.rootFiles) {
421
+ const title = file.title || slugToTitle(file.path.split('/').pop() || '')
422
+ lines.push(`- [${title}](${file.path})`)
423
+ }
424
+ lines.push('')
425
+ }
426
+
427
+ return lines.join('\n').trimEnd() + '\n'
428
+ }
429
+
430
+ async function copyMarkdown() {
431
+ if (!import.meta.client) return
432
+ const markdown = buildMarkdown()
433
+
434
+ try {
435
+ if (navigator.clipboard?.writeText) {
436
+ await navigator.clipboard.writeText(markdown)
437
+ }
438
+ else {
439
+ // Legacy fallback for non-secure contexts (e.g. plain-http preview).
440
+ const ta = document.createElement('textarea')
441
+ ta.value = markdown
442
+ ta.style.position = 'fixed'
443
+ ta.style.left = '-9999px'
444
+ document.body.appendChild(ta)
445
+ ta.select()
446
+ document.execCommand('copy')
447
+ document.body.removeChild(ta)
448
+ }
449
+ copyState.value = 'copied'
450
+ // Byte count makes the toast useful at a glance — confirms the copy isn't
451
+ // empty (e.g. on a stub section) and gives the user a quick sanity-check.
452
+ const bytes = new Blob([markdown]).size
453
+ toast.success('Copied as Markdown', {
454
+ description: `${displayTitle.value} · ${formatBytes(bytes)}`,
455
+ })
456
+ }
457
+ catch (err) {
458
+ copyState.value = 'error'
459
+ toast.error('Copy failed', {
460
+ description: err instanceof Error ? err.message : 'Clipboard unavailable',
461
+ })
462
+ }
463
+
464
+ if (copyResetTimer) clearTimeout(copyResetTimer)
465
+ copyResetTimer = setTimeout(() => {
466
+ copyState.value = 'idle'
467
+ copyResetTimer = null
468
+ }, 1500)
469
+ }
470
+
471
+ function formatBytes(n: number): string {
472
+ if (n < 1024) return `${n} B`
473
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
474
+ return `${(n / (1024 * 1024)).toFixed(2)} MB`
475
+ }
476
+
477
+ const copyTitle = computed(() => {
478
+ if (copyState.value === 'copied') return 'Copied to clipboard'
479
+ if (copyState.value === 'error') return 'Copy failed'
480
+ return 'Copy as Markdown'
481
+ })
482
+
483
+ // Visible button label. Plain text beat the icon iterations (sparkle was
484
+ // too abstract; clipboard+MD wordmark was illegible at the available
485
+ // pixel budget). Three short states — same character class so the row
486
+ // width stays stable across transitions when paired with a min-width on
487
+ // the button.
488
+ const copyLabel = computed(() => {
489
+ if (copyState.value === 'copied') return 'Copied'
490
+ if (copyState.value === 'error') return 'Failed'
491
+ return 'Copy'
492
+ })
493
+
494
+ // "Open in AI" dropdown — deep-links the *page URL* (not the markdown
495
+ // body) into AI chat hosts via their `?q=` parameter. The hosts then
496
+ // fetch and reason about the page themselves, which sidesteps the URL-
497
+ // length cap that would truncate a markdown payload and keeps the AI's
498
+ // view authoritative (it sees the live page, not a frozen snapshot).
499
+ //
500
+ // `useRequestURL()` is SSR-safe and resolves to the canonical absolute
501
+ // URL on both server and client.
502
+ const menuOpen = ref(false)
503
+ const triggerEl = useTemplateRef<HTMLElement>('triggerEl')
504
+ const menuEl = useTemplateRef<HTMLElement>('menuEl')
505
+
506
+ function toggleMenu() {
507
+ menuOpen.value = !menuOpen.value
508
+ }
509
+ function closeMenu() {
510
+ menuOpen.value = false
511
+ }
512
+
513
+ const requestURL = useRequestURL()
514
+ const pageURL = computed(() => {
515
+ const u = new URL(path.value, requestURL.origin)
516
+ return u.toString()
517
+ })
518
+ const claudeUrl = computed(() =>
519
+ `https://claude.ai/new?q=${encodeURIComponent(pageURL.value)}`,
520
+ )
521
+ const chatgptUrl = computed(() =>
522
+ `https://chatgpt.com/?q=${encodeURIComponent(pageURL.value)}`,
523
+ )
524
+
525
+ // Floating-UI positioning. `bottom-end` anchors the menu to the right
526
+ // edge of the caret button; `flip` mirrors to `top-end` when the column
527
+ // has no room below; `shift` keeps the menu inside the viewport.
528
+ // `autoUpdate` re-runs on scroll/resize/layout changes — important
529
+ // because stacked columns scroll horizontally and the trigger can
530
+ // reposition mid-scroll.
531
+ const { floatingStyles } = useFloating(triggerEl, menuEl, {
532
+ placement: 'bottom-end',
533
+ middleware: [offset(6), flip(), shift({ padding: 8 })],
534
+ whileElementsMounted: autoUpdate,
535
+ })
536
+
537
+ // Click-outside / Esc dismissal. Floating-UI/vue ships positioning only,
538
+ // so dismissal is hand-rolled. Cheap: handler bails immediately when the
539
+ // menu is closed.
540
+ function handleClickOutside(event: MouseEvent) {
541
+ if (!menuOpen.value) return
542
+ const target = event.target as Node
543
+ if (triggerEl.value?.contains(target)) return
544
+ if (menuEl.value?.contains(target)) return
545
+ closeMenu()
546
+ }
547
+ function handleKeydown(event: KeyboardEvent) {
548
+ if (event.key === 'Escape' && menuOpen.value) closeMenu()
549
+ }
550
+
551
+ onMounted(() => {
552
+ if (!import.meta.client) return
553
+ document.addEventListener('click', handleClickOutside)
554
+ document.addEventListener('keydown', handleKeydown)
555
+ })
556
+
557
+ onBeforeUnmount(() => {
558
+ if (copyResetTimer) clearTimeout(copyResetTimer)
559
+ if (import.meta.client) {
560
+ document.removeEventListener('click', handleClickOutside)
561
+ document.removeEventListener('keydown', handleKeydown)
562
+ }
563
+ })
564
+ </script>
565
+
566
+ <template>
567
+ <div v-if="notFound" class="p-12 text-center text-terminal-text-muted">
568
+ <p class="font-display font-bold uppercase tracking-tight text-lg text-terminal-text mb-2">
569
+ Not Found
570
+ </p>
571
+ <p class="text-sm font-mono">
572
+ <code class="bg-terminal-surface-0 border border-terminal-border px-2 py-0.5">{{ path }}</code>
573
+ </p>
574
+ <p class="text-sm mt-2">This page doesn't exist or has been moved.</p>
575
+ </div>
576
+
577
+ <div v-else class="flex flex-col h-full">
578
+ <!-- Column header — sits outside the scroll container so the scrollbar
579
+ never overlaps it. flex-none keeps it at fixed height. -->
580
+ <div class="section-card-header flex-none">
581
+ <div class="flex items-center justify-between gap-3">
582
+ <h2 class="text-sm font-bold uppercase tracking-tight flex items-center gap-2 truncate">
583
+ <span class="text-primary">/</span>
584
+ <span class="font-mono text-terminal-text-muted text-xs shrink-0">
585
+ {{ String(sectionIndex).padStart(2, '0') }}.
586
+ </span>
587
+ <span class="truncate">{{ displayTitle }}</span>
588
+ </h2>
589
+ <!-- Split-button: primary half copies markdown; the chevron half is
590
+ the Floating-UI anchor for the AI-deep-link menu. Visually fused
591
+ (negative margin merges the shared border) so it reads as one
592
+ control with two affordances. The menu's `ref="menuEl"` is bound
593
+ to `<Teleport to="body">` so it escapes the column's overflow
594
+ clip; positioning is computed by `useFloating()`. -->
595
+ <div class="copy-actions">
596
+ <button
597
+ type="button"
598
+ class="copy-btn"
599
+ :aria-label="`Copy ${displayTitle} as markdown`"
600
+ :title="copyTitle"
601
+ :data-state="copyState"
602
+ @click.stop="copyMarkdown"
603
+ >
604
+ <!-- Plain text label. Icon iterations (sparkle, clipboard+MD)
605
+ either misread or were illegible at the available pixel
606
+ budget; a literal word is unambiguous and fits the
607
+ brutalist-terminal surface where the rest of the header
608
+ is already typographic. State-flipping is purely textual
609
+ so there is nothing to layout-thrash on success. -->
610
+ <span class="copy-btn__label">{{ copyLabel }}</span>
611
+ </button>
612
+ <button
613
+ ref="triggerEl"
614
+ type="button"
615
+ class="copy-btn copy-btn--menu"
616
+ :aria-expanded="menuOpen"
617
+ aria-haspopup="menu"
618
+ aria-label="Open in AI assistant"
619
+ title="Open in AI"
620
+ @click.stop="toggleMenu"
621
+ >
622
+ <svg
623
+ class="copy-btn__caret"
624
+ :class="{ 'copy-btn__caret--open': menuOpen }"
625
+ viewBox="0 0 12 8"
626
+ fill="none"
627
+ stroke="currentColor"
628
+ stroke-width="2"
629
+ stroke-linecap="square"
630
+ stroke-linejoin="miter"
631
+ aria-hidden="true"
632
+ >
633
+ <path d="M2 2 L6 6 L10 2" />
634
+ </svg>
635
+ </button>
636
+ </div>
637
+ <Teleport to="body">
638
+ <div
639
+ v-if="menuOpen"
640
+ ref="menuEl"
641
+ class="copy-menu"
642
+ role="menu"
643
+ :style="floatingStyles"
644
+ @click.stop
645
+ >
646
+ <a
647
+ :href="claudeUrl"
648
+ target="_blank"
649
+ rel="noopener noreferrer"
650
+ class="copy-menu__item"
651
+ role="menuitem"
652
+ @click="closeMenu"
653
+ >
654
+ <span class="copy-menu__arrow">→</span>
655
+ <span>Claude.ai</span>
656
+ </a>
657
+ <a
658
+ :href="chatgptUrl"
659
+ target="_blank"
660
+ rel="noopener noreferrer"
661
+ class="copy-menu__item"
662
+ role="menuitem"
663
+ @click="closeMenu"
664
+ >
665
+ <span class="copy-menu__arrow">→</span>
666
+ <span>ChatGPT</span>
667
+ </a>
668
+ </div>
669
+ </Teleport>
670
+ </div>
671
+ </div>
672
+
673
+ <!-- Scrollable content area — grows to fill remaining height. Scrollbar
674
+ is scoped here, so it never intrudes into the header above. -->
675
+ <div class="flex-1 overflow-y-auto min-h-0">
676
+ <!-- LIST VIEW: any path that has children renders as a section listing. -->
677
+ <template v-if="isList">
678
+ <div v-if="hasRenderedBody" class="content px-5 pt-6">
679
+ <ContentRenderer :value="renderedPage" />
680
+ </div>
681
+
682
+ <!-- Latest — newest-created articles across the subtree, deduped against
683
+ the column's own Articles list. Sits above Folders as a "what's new"
684
+ entry point. Auto-hides when empty (e.g. flat leaf folders). -->
685
+ <section v-if="latest.length > 0" aria-label="Latest">
686
+ <h3 class="section-heading mx-5">
687
+ <!-- Clock — connotes recency, the section's defining axis. -->
688
+ <svg
689
+ class="section-heading__icon"
690
+ viewBox="0 0 24 24"
691
+ fill="none"
692
+ stroke="currentColor"
693
+ stroke-width="2"
694
+ stroke-linecap="square"
695
+ stroke-linejoin="miter"
696
+ aria-hidden="true"
697
+ >
698
+ <circle cx="12" cy="12" r="9" />
699
+ <path d="M12 7 L12 12 L16 14" />
700
+ </svg>
701
+ <span>Latest</span>
702
+ </h3>
703
+ <ul class="flex flex-col py-2">
704
+ <li
705
+ v-for="(file, index) in latest"
706
+ :key="file.path"
707
+ :class="['terminal-item min-w-0', isDrilled(file.path) && 'terminal-item--active']"
708
+ >
709
+ <NuxtLink
710
+ :to="file.path"
711
+ class="flex items-baseline min-w-0 w-full"
712
+ >
713
+ <span class="title-text font-bold uppercase whitespace-nowrap py-2 px-3 ml-2 transition-all overflow-hidden text-ellipsis flex-shrink min-w-0 text-sm">
714
+ {{ file.title || slugToTitle(file.path.split('/').pop() || '') }}
715
+ </span>
716
+ <span class="dotted-leader flex-shrink" />
717
+ <!-- Rank 01 = newest. Distinct from Articles' count-down numbering
718
+ because this list is explicitly time-ordered. -->
719
+ <span class="tabular-nums font-bold font-mono text-[10px] flex-shrink-0 text-terminal-text-faint mr-4">
720
+ {{ String(index + 1).padStart(2, '0') }}
721
+ </span>
722
+ </NuxtLink>
723
+ </li>
724
+ </ul>
725
+ </section>
726
+
727
+ <!-- Sections sub-grouping (folders) -->
728
+ <section v-if="hierarchy.sections.length > 0" aria-label="Sections">
729
+ <h3 class="section-heading mx-5">
730
+ <!-- Folder glyph — sharp-cornered tab + body, matches the
731
+ brutalist surface (no rounded folder corners). -->
732
+ <svg
733
+ class="section-heading__icon"
734
+ viewBox="0 0 24 24"
735
+ fill="none"
736
+ stroke="currentColor"
737
+ stroke-width="2"
738
+ stroke-linecap="square"
739
+ stroke-linejoin="miter"
740
+ aria-hidden="true"
741
+ >
742
+ <path d="M3 6 L10 6 L12 9 L21 9 L21 20 L3 20 Z" />
743
+ </svg>
744
+ <span>Folders</span>
745
+ </h3>
746
+ <ul class="flex flex-col py-2">
747
+ <li
748
+ v-for="(section, index) in hierarchy.sections"
749
+ :key="section.path"
750
+ :class="['terminal-item min-w-0', isDrilled(section.path) && 'terminal-item--active']"
751
+ >
752
+ <NuxtLink
753
+ :to="section.path"
754
+ class="flex items-baseline min-w-0 w-full"
755
+ >
756
+ <span class="title-text font-bold uppercase whitespace-nowrap py-2 px-3 ml-2 transition-all overflow-hidden text-ellipsis flex-shrink min-w-0 text-sm">
757
+ {{ section.title }}
758
+ </span>
759
+ <span class="dotted-leader flex-shrink" />
760
+ <span class="tabular-nums font-bold font-mono text-[10px] flex-shrink-0 text-terminal-text-faint mr-4">
761
+ {{ String(section.count).padStart(2, '0') }}
762
+ </span>
763
+ </NuxtLink>
764
+ </li>
765
+ </ul>
766
+ </section>
767
+
768
+ <!-- Root file listing (flat articles within current section) -->
769
+ <section v-if="hierarchy.rootFiles.length > 0" aria-label="Articles">
770
+ <h3 class="section-heading mx-5">
771
+ <!-- Document with folded corner + body lines — universal
772
+ "article / file" affordance. -->
773
+ <svg
774
+ class="section-heading__icon"
775
+ viewBox="0 0 24 24"
776
+ fill="none"
777
+ stroke="currentColor"
778
+ stroke-width="2"
779
+ stroke-linecap="square"
780
+ stroke-linejoin="miter"
781
+ aria-hidden="true"
782
+ >
783
+ <path d="M6 3 L15 3 L20 8 L20 21 L6 21 Z" />
784
+ <path d="M15 3 L15 8 L20 8" />
785
+ <path d="M9 13 L16 13" />
786
+ <path d="M9 17 L16 17" />
787
+ </svg>
788
+ <span>Articles</span>
789
+ </h3>
790
+ <ul class="flex flex-col py-2">
791
+ <li
792
+ v-for="(file, index) in hierarchy.rootFiles"
793
+ :key="file.path"
794
+ :class="['terminal-item min-w-0', isDrilled(file.path) && 'terminal-item--active']"
795
+ >
796
+ <NuxtLink
797
+ :to="file.path"
798
+ class="flex items-baseline min-w-0 w-full"
799
+ >
800
+ <span class="title-text font-bold uppercase whitespace-nowrap py-2 px-3 ml-2 transition-all overflow-hidden text-ellipsis flex-shrink min-w-0 text-sm">
801
+ {{ file.title || slugToTitle(file.path.split('/').pop() || '') }}
802
+ </span>
803
+ <span class="dotted-leader flex-shrink" />
804
+ <span class="tabular-nums font-bold font-mono text-[10px] flex-shrink-0 text-terminal-text-faint mr-4">
805
+ {{ String(hierarchy.rootFiles.length - index).padStart(2, '0') }}
806
+ </span>
807
+ </NuxtLink>
808
+ </li>
809
+ </ul>
810
+ </section>
811
+ </template>
812
+
813
+ <!-- ARTICLE VIEW: only reached when a page exists with content body and no children. -->
814
+ <article v-else-if="page" class="px-5 py-6 md:py-8 max-w-[75ch]">
815
+ <header class="mb-8 pb-5 border-b-2 border-dashed border-terminal-border">
816
+ <h1 class="text-2xl md:text-3xl font-display font-bold uppercase tracking-tight leading-tight text-terminal-text mb-3">
817
+ {{ displayTitle }}
818
+ </h1>
819
+
820
+ <ul v-if="allTags.length" class="flex flex-wrap gap-2 mt-3">
821
+ <li v-for="tag in allTags" :key="tag.value">
822
+ <NuxtLink
823
+ :to="`/tags/${tag.value}`"
824
+ :class="['tag-badge', tag.important && 'tag-badge--active']"
825
+ >
826
+ {{ toTitleCase(tag.value) }}
827
+ </NuxtLink>
828
+ </li>
829
+ </ul>
830
+ </header>
831
+
832
+ <div class="content">
833
+ <ContentRenderer :value="renderedPage" />
834
+ </div>
835
+ </article>
836
+ </div>
837
+ </div>
838
+ </template>