@life-and-dev/mdsite 0.5.0 → 0.5.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/mdsite-nuxt/app/components/AppTableOfContents.vue +11 -17
- package/mdsite-nuxt/app/composables/useTableOfContents.test.ts +5 -5
- package/mdsite-nuxt/app/composables/useTableOfContents.ts +1 -1
- package/mdsite-nuxt/nuxt.config.ts +19 -1
- package/mdsite-nuxt/scripts/generate-indices.ts +11 -8
- package/mdsite-nuxt/utils/mdsite-config.ts +25 -1
- package/package.json +1 -1
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
2
|
+
<!--
|
|
3
|
+
Guard at the root: the consuming composable's `shouldShowTOC` already
|
|
4
|
+
requires `tocItems.length >= TOC_MIN_HEADINGS (3)`, so by the time we
|
|
5
|
+
reach this template a usable TOC is guaranteed. Render nothing otherwise.
|
|
6
|
+
-->
|
|
7
|
+
<div
|
|
8
|
+
v-if="tocItems.length >= 3"
|
|
9
|
+
class="toc-sidebar"
|
|
10
|
+
ref="tocContainer"
|
|
11
|
+
>
|
|
3
12
|
<!-- Header -->
|
|
4
13
|
<h3 v-if="showHeader" class="toc-header">On This Page</h3>
|
|
5
14
|
<v-divider v-if="showHeader" class="mb-2" />
|
|
6
15
|
|
|
7
16
|
<!-- TOC Items -->
|
|
8
|
-
<nav
|
|
17
|
+
<nav class="toc-nav">
|
|
9
18
|
<TocItem
|
|
10
19
|
v-for="item in tocItems"
|
|
11
20
|
:key="item.id"
|
|
@@ -15,11 +24,6 @@
|
|
|
15
24
|
/>
|
|
16
25
|
</nav>
|
|
17
26
|
|
|
18
|
-
<!-- Empty state -->
|
|
19
|
-
<div v-else class="toc-empty">
|
|
20
|
-
<p class="text-caption text-center">No table of contents available</p>
|
|
21
|
-
</div>
|
|
22
|
-
|
|
23
27
|
<!-- Fade gradient for overflow -->
|
|
24
28
|
<div v-if="showFade" class="fade-gradient" />
|
|
25
29
|
</div>
|
|
@@ -122,16 +126,6 @@ watch(() => props.tocItems, () => {
|
|
|
122
126
|
scrollbar-width: none;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
.toc-empty {
|
|
126
|
-
flex: 1;
|
|
127
|
-
display: flex;
|
|
128
|
-
align-items: center;
|
|
129
|
-
justify-content: center;
|
|
130
|
-
padding: 32px 16px;
|
|
131
|
-
color: rgb(var(--v-theme-on-surface-rail));
|
|
132
|
-
opacity: 0.6;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
129
|
.fade-gradient {
|
|
136
130
|
position: absolute;
|
|
137
131
|
bottom: 0;
|
|
@@ -8,7 +8,7 @@ import { computeShouldShowTOC, TOC_MIN_HEADINGS, TOC_MIN_LINES } from './useTabl
|
|
|
8
8
|
describe('useTableOfContents threshold', () => {
|
|
9
9
|
it('exposes the expected minimum constants', () => {
|
|
10
10
|
expect(TOC_MIN_HEADINGS).toBe(3)
|
|
11
|
-
expect(TOC_MIN_LINES).toBe(
|
|
11
|
+
expect(TOC_MIN_LINES).toBe(15)
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
describe('computeShouldShowTOC', () => {
|
|
@@ -18,13 +18,13 @@ describe('useTableOfContents threshold', () => {
|
|
|
18
18
|
expect(computeShouldShowTOC(2, 100)).toBe(false)
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
it('returns false when lines <
|
|
21
|
+
it('returns false when lines < 15 (and headings >= 3)', () => {
|
|
22
22
|
expect(computeShouldShowTOC(3, 0)).toBe(false)
|
|
23
|
-
expect(computeShouldShowTOC(5,
|
|
23
|
+
expect(computeShouldShowTOC(5, 14)).toBe(false)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
it('returns true when headings >= 3 and lines >=
|
|
27
|
-
expect(computeShouldShowTOC(3,
|
|
26
|
+
it('returns true when headings >= 3 and lines >= 15', () => {
|
|
27
|
+
expect(computeShouldShowTOC(3, 15)).toBe(true)
|
|
28
28
|
expect(computeShouldShowTOC(5, 100)).toBe(true)
|
|
29
29
|
})
|
|
30
30
|
|
|
@@ -8,7 +8,7 @@ export interface TocItem {
|
|
|
8
8
|
/** Minimum number of headings required to show the TOC. */
|
|
9
9
|
export const TOC_MIN_HEADINGS = 3
|
|
10
10
|
/** Minimum number of non-empty lines in the rendered content required to show the TOC. */
|
|
11
|
-
export const TOC_MIN_LINES =
|
|
11
|
+
export const TOC_MIN_LINES = 15
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Pure helper that decides whether the TOC should be shown.
|
|
@@ -18,7 +18,25 @@ export default defineNuxtConfig({
|
|
|
18
18
|
public: {
|
|
19
19
|
contentDomain: path.basename(mdsite.contentDir),
|
|
20
20
|
contentPath: mdsite.contentDir,
|
|
21
|
-
|
|
21
|
+
// `mdsite.config` is a valid `MdsiteConfig` at runtime, but
|
|
22
|
+
// Nuxt's runtime-config type generator collapses every complex
|
|
23
|
+
// field of `siteConfig` to a degenerate shape — `menu` becomes
|
|
24
|
+
// `Array<{}>` (regardless of whether the source type is recursive
|
|
25
|
+
// or contains `Array<any>`) and `footer` becomes `Array<any>`.
|
|
26
|
+
// We verified this by:
|
|
27
|
+
// 1. Tightening `MdsiteConfig.menu` to a proper recursive
|
|
28
|
+
// `MdsiteMenuItem` (no `any`) and clearing `.nuxt` cache:
|
|
29
|
+
// the generated `menu` was still `Array<{}>`.
|
|
30
|
+
// 2. Trying `declare module 'nuxt/schema'` augmentations of
|
|
31
|
+
// `PublicRuntimeConfig.siteConfig`: the augmentation
|
|
32
|
+
// *intersects* with the broken generated type rather than
|
|
33
|
+
// overriding it, producing an even narrower target.
|
|
34
|
+
// Since the runtime value is unchanged, `as any` is the most
|
|
35
|
+
// honest pragmatic escape hatch. The recursive `MdsiteMenuItem`
|
|
36
|
+
// type is still useful: it gives the rest of the codebase
|
|
37
|
+
// (notably `scripts/generate-indices.ts`) a single source of
|
|
38
|
+
// truth and proper types.
|
|
39
|
+
siteConfig: siteConfig as any
|
|
22
40
|
}
|
|
23
41
|
},
|
|
24
42
|
|
|
@@ -4,6 +4,7 @@ import fs from 'fs-extra'
|
|
|
4
4
|
import path from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
6
|
import { parse as parseYaml } from 'yaml'
|
|
7
|
+
import type { MdsiteMenuItem } from '../utils/mdsite-config'
|
|
7
8
|
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url)
|
|
9
10
|
const __dirname = path.dirname(__filename)
|
|
@@ -108,8 +109,10 @@ export interface MinimalTreeNode {
|
|
|
108
109
|
isPrimary?: boolean
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
// `MdsiteMenuItem` is imported from `~/utils/mdsite-config` (see top of file).
|
|
113
|
+
// It is the same recursive shape that was previously defined locally as
|
|
114
|
+
// `MdsiteMenuItem`. Single source of truth lives in `utils/mdsite-config.ts`
|
|
115
|
+
// so the Nuxt runtime-config type generator can infer it correctly.
|
|
113
116
|
/**
|
|
114
117
|
* Normalize URL path so a trailing /index resolves to its parent.
|
|
115
118
|
* Mirrors filePathToUrlPath behavior so menu paths match content routes.
|
|
@@ -161,7 +164,7 @@ function resolvePath(menuPath: string, contextPath: string): string {
|
|
|
161
164
|
* Process menu items recursively and build minimal tree structure
|
|
162
165
|
*/
|
|
163
166
|
async function processMenuItems(
|
|
164
|
-
items:
|
|
167
|
+
items: MdsiteMenuItem[],
|
|
165
168
|
contextPath: string,
|
|
166
169
|
order: number = 0
|
|
167
170
|
): Promise<{ nodes: MinimalTreeNode[], nextOrder: number }> {
|
|
@@ -371,14 +374,14 @@ async function buildFallbackNavigationTree(sourceDir: string): Promise<MinimalTr
|
|
|
371
374
|
* Load the menu array from a candidate config file (legacy _menu.yml/yaml or mdsite.yml).
|
|
372
375
|
* Returns null if the file is missing, unreadable, has no menu key, or has an empty menu.
|
|
373
376
|
*/
|
|
374
|
-
async function tryReadMenuFromConfig(configPath: string): Promise<
|
|
377
|
+
async function tryReadMenuFromConfig(configPath: string): Promise<MdsiteMenuItem[] | null> {
|
|
375
378
|
if (!await fs.pathExists(configPath)) {
|
|
376
379
|
return null
|
|
377
380
|
}
|
|
378
381
|
|
|
379
382
|
try {
|
|
380
383
|
const content = await fs.readFile(configPath, 'utf-8')
|
|
381
|
-
const parsed = parseYaml(content) as { menu?:
|
|
384
|
+
const parsed = parseYaml(content) as { menu?: MdsiteMenuItem[] } | null
|
|
382
385
|
if (parsed && Array.isArray(parsed.menu) && parsed.menu.length > 0) {
|
|
383
386
|
return parsed.menu
|
|
384
387
|
}
|
|
@@ -393,14 +396,14 @@ async function tryReadMenuFromConfig(configPath: string): Promise<MenuItemType[]
|
|
|
393
396
|
* Try to read menu items from a plain (non-wrapped) legacy _menu.yml/yaml file.
|
|
394
397
|
* Returns null if the file is missing, unreadable, or doesn't contain a non-empty array.
|
|
395
398
|
*/
|
|
396
|
-
async function tryReadLegacyMenuFile(menuPath: string): Promise<
|
|
399
|
+
async function tryReadLegacyMenuFile(menuPath: string): Promise<MdsiteMenuItem[] | null> {
|
|
397
400
|
if (!await fs.pathExists(menuPath)) {
|
|
398
401
|
return null
|
|
399
402
|
}
|
|
400
403
|
|
|
401
404
|
try {
|
|
402
405
|
const content = await fs.readFile(menuPath, 'utf-8')
|
|
403
|
-
const parsed = parseYaml(content) as
|
|
406
|
+
const parsed = parseYaml(content) as MdsiteMenuItem[] | null
|
|
404
407
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
405
408
|
return parsed
|
|
406
409
|
}
|
|
@@ -420,7 +423,7 @@ async function tryReadLegacyMenuFile(menuPath: string): Promise<MenuItemType[] |
|
|
|
420
423
|
* 5. <sourceDir>/mdsite.yml
|
|
421
424
|
* Returns the first non-empty menu array, or null if none resolve to a menu.
|
|
422
425
|
*/
|
|
423
|
-
async function loadMenuConfig(sourceDir: string): Promise<
|
|
426
|
+
async function loadMenuConfig(sourceDir: string): Promise<MdsiteMenuItem[] | null> {
|
|
424
427
|
const candidates: { path: string, isLegacy: boolean }[] = [
|
|
425
428
|
{ path: path.join(sourceDir, '_menu.yml'), isLegacy: true },
|
|
426
429
|
{ path: path.join(sourceDir, '_menu.yaml'), isLegacy: true }
|
|
@@ -2,6 +2,30 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import YAML from 'yaml';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Recursive shape of a single `mdsite.yml` `menu:` entry.
|
|
7
|
+
*
|
|
8
|
+
* - `string` → flat link to a markdown slug
|
|
9
|
+
* (e.g. `- genesis`, `- resurrections`)
|
|
10
|
+
* - `null` → visual separator (`===` in YAML)
|
|
11
|
+
* - `Record<string, MdsiteMenuValue>` → group: object whose keys are group
|
|
12
|
+
* labels (e.g. `"Members of the Trinity":`) and whose
|
|
13
|
+
* values are `MdsiteMenuValue` items
|
|
14
|
+
* - `MdsiteMenuValue` (the value side of a group) can be:
|
|
15
|
+
* - `string` → alias link with custom title
|
|
16
|
+
* (e.g. `"Job": https://...`, `"Homepage": index`)
|
|
17
|
+
* - `null` → group label only (renders as a heading, no link)
|
|
18
|
+
* - `MdsiteMenuItem[]` → nested submenu (recursive)
|
|
19
|
+
*
|
|
20
|
+
* Keeping this a `type` alias (not an `interface`) and avoiding `any` lets
|
|
21
|
+
* Nuxt's runtime-config type generator infer `runtimeConfig.public.siteConfig`
|
|
22
|
+
* without collapsing `menu` to `{}[]`. See `nuxt.config.ts` for the cast
|
|
23
|
+
* history this replaces.
|
|
24
|
+
*/
|
|
25
|
+
export type MdsiteMenuItem = string | null | MdsiteMenuGroup
|
|
26
|
+
export type MdsiteMenuGroup = { [key: string]: MdsiteMenuValue }
|
|
27
|
+
export type MdsiteMenuValue = string | null | MdsiteMenuItem[]
|
|
28
|
+
|
|
5
29
|
export interface MdsiteConfig {
|
|
6
30
|
content?: {
|
|
7
31
|
path?: string
|
|
@@ -11,7 +35,7 @@ export interface MdsiteConfig {
|
|
|
11
35
|
bibleTooltips: boolean
|
|
12
36
|
sourceEdit: boolean
|
|
13
37
|
}
|
|
14
|
-
menu:
|
|
38
|
+
menu: MdsiteMenuItem[]
|
|
15
39
|
footer: string[]
|
|
16
40
|
server: {
|
|
17
41
|
output: string
|