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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/app/.claude/skills/ai-annotator/SKILL.md +31 -0
- package/app/app.config.ts +20 -0
- package/app/app.vue +7 -0
- package/app/assets/css/main.css +609 -0
- package/app/components/ContentView.vue +838 -0
- package/app/components/LocalStorageChecklist.vue +372 -0
- package/app/components/StackedColumn.vue +81 -0
- package/app/components/StackedColumns.vue +216 -0
- package/app/composables/useStack.ts +331 -0
- package/app/layouts/default.vue +22 -0
- package/app/pages/[...slug].vue +3 -0
- package/app/types/app-config.d.ts +19 -0
- package/content/index.md +25 -0
- package/content/license.md +62 -0
- package/nuxt.config.ts +76 -0
- package/package.json +55 -0
- package/tailwind.config.js +64 -0
- package/tsconfig.json +18 -0
|
@@ -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>
|