@life-and-dev/mdsite 0.5.0 → 0.5.2

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.
@@ -1,11 +1,20 @@
1
1
  <template>
2
- <div class="toc-sidebar" ref="tocContainer">
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 v-if="tocItems.length >= 3" class="toc-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;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Unit tests for the pure `mapSiteConfig` helper extracted from
3
+ * `useSiteConfig`. The `sourceEdit` Edit-on-GitHub link in `AppBar` and
4
+ * `AppFooter` is only rendered when `getEditUrl()` produces a URL, which
5
+ * in turn requires `contentGitRepo` to be populated from `server.repo`.
6
+ * These tests pin that mapping down so a future refactor cannot regress
7
+ * it back to the empty-string default.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest'
11
+ import { mapSiteConfig } from './useSiteConfig'
12
+
13
+ describe('mapSiteConfig', () => {
14
+ it('returns safe defaults when siteConfig is undefined', () => {
15
+ const result = mapSiteConfig(undefined, undefined)
16
+
17
+ expect(result).toEqual({
18
+ siteName: '',
19
+ siteCanonical: '',
20
+ contentGitRepo: '',
21
+ contentGitBranch: 'main',
22
+ contentGitPath: '.',
23
+ contentPath: '.',
24
+ features: {
25
+ bibleTooltips: false,
26
+ sourceEdit: false
27
+ },
28
+ themeColorLight: '#000000',
29
+ themeColorDark: '#ffffff'
30
+ })
31
+ })
32
+
33
+ it('reads site metadata from site.name and site.canonical', () => {
34
+ const result = mapSiteConfig({
35
+ site: { name: 'My Site', canonical: 'https://example.test' }
36
+ }, undefined)
37
+
38
+ expect(result.siteName).toBe('My Site')
39
+ expect(result.siteCanonical).toBe('https://example.test')
40
+ })
41
+
42
+ describe('contentGitRepo (Edit on GitHub source)', () => {
43
+ it('reads server.repo into contentGitRepo', () => {
44
+ const result = mapSiteConfig({
45
+ server: { repo: 'https://github.com/life-and-dev/mdsite' }
46
+ }, undefined)
47
+
48
+ expect(result.contentGitRepo).toBe('https://github.com/life-and-dev/mdsite')
49
+ })
50
+
51
+ it('defaults to empty string when server.repo is missing', () => {
52
+ const result = mapSiteConfig({ server: {} }, undefined)
53
+
54
+ expect(result.contentGitRepo).toBe('')
55
+ })
56
+
57
+ it('defaults to empty string when server is missing entirely', () => {
58
+ const result = mapSiteConfig({}, undefined)
59
+
60
+ expect(result.contentGitRepo).toBe('')
61
+ })
62
+ })
63
+
64
+ describe('features', () => {
65
+ it('defaults both feature flags to false when features is missing', () => {
66
+ const result = mapSiteConfig({}, undefined)
67
+
68
+ expect(result.features.bibleTooltips).toBe(false)
69
+ expect(result.features.sourceEdit).toBe(false)
70
+ })
71
+
72
+ it('reads sourceEdit from features.sourceEdit', () => {
73
+ const result = mapSiteConfig({
74
+ features: { sourceEdit: true, bibleTooltips: false }
75
+ }, undefined)
76
+
77
+ expect(result.features.sourceEdit).toBe(true)
78
+ expect(result.features.bibleTooltips).toBe(false)
79
+ })
80
+
81
+ it('reads bibleTooltips from features.bibleTooltips', () => {
82
+ const result = mapSiteConfig({
83
+ features: { sourceEdit: false, bibleTooltips: true }
84
+ }, undefined)
85
+
86
+ expect(result.features.sourceEdit).toBe(false)
87
+ expect(result.features.bibleTooltips).toBe(true)
88
+ })
89
+ })
90
+
91
+ describe('theme colors', () => {
92
+ it('uses the configured light/dark primary colors when present', () => {
93
+ const result = mapSiteConfig({
94
+ themes: {
95
+ light: { colors: { primary: '#111111' } },
96
+ dark: { colors: { primary: '#eeeeee' } }
97
+ }
98
+ }, undefined)
99
+
100
+ expect(result.themeColorLight).toBe('#111111')
101
+ expect(result.themeColorDark).toBe('#eeeeee')
102
+ })
103
+
104
+ it('falls back to defaults when theme colors are missing', () => {
105
+ const result = mapSiteConfig({ themes: { light: {}, dark: {} } }, undefined)
106
+
107
+ expect(result.themeColorLight).toBe('#000000')
108
+ expect(result.themeColorDark).toBe('#ffffff')
109
+ })
110
+ })
111
+
112
+ it('passes through the contentPath argument with a dot default', () => {
113
+ expect(mapSiteConfig({}, '/abs/docs').contentPath).toBe('/abs/docs')
114
+ expect(mapSiteConfig({}, undefined).contentPath).toBe('.')
115
+ expect(mapSiteConfig({}, '').contentPath).toBe('.')
116
+ })
117
+ })
@@ -1,4 +1,28 @@
1
- interface SiteConfig {
1
+ /**
2
+ * Subset of the runtime `mdsite.yml` config shape that `useSiteConfig`
3
+ * consumes. The runtime value is the full `MdsiteConfig`, but we type only
4
+ * the fields this composable reads so the pure mapper below can be unit
5
+ * tested without pulling in the entire config schema.
6
+ */
7
+ export interface RawSiteConfig {
8
+ site?: {
9
+ name?: string
10
+ canonical?: string
11
+ }
12
+ server?: {
13
+ repo?: string
14
+ }
15
+ features?: {
16
+ bibleTooltips?: boolean
17
+ sourceEdit?: boolean
18
+ }
19
+ themes?: {
20
+ light?: { colors?: { primary?: string } }
21
+ dark?: { colors?: { primary?: string } }
22
+ }
23
+ }
24
+
25
+ export interface SiteConfig {
2
26
  siteName: string
3
27
  siteCanonical: string
4
28
  contentGitRepo: string
@@ -14,19 +38,23 @@ interface SiteConfig {
14
38
  }
15
39
 
16
40
  /**
17
- * Get site configuration based on runtime config
41
+ * Pure helper that maps the raw runtime `siteConfig` object into the shape
42
+ * the renderer actually consumes. Extracted from `useSiteConfig` so the
43
+ * field mapping (notably `contentGitRepo` ← `server.repo`, which is what
44
+ * powers the Edit on GitHub link in `AppBar` and `AppFooter`) can be unit
45
+ * tested independently of the Nuxt runtime.
18
46
  */
19
- export function useSiteConfig(): SiteConfig {
20
- const config = useRuntimeConfig()
21
- const siteConfig = config.public.siteConfig as any
22
-
47
+ export function mapSiteConfig(
48
+ siteConfig: RawSiteConfig | undefined,
49
+ contentPath: string | undefined,
50
+ ): SiteConfig {
23
51
  return {
24
52
  siteName: siteConfig?.site?.name || '',
25
53
  siteCanonical: siteConfig?.site?.canonical || '',
26
- contentGitRepo: '',
54
+ contentGitRepo: siteConfig?.server?.repo || '',
27
55
  contentGitBranch: 'main',
28
56
  contentGitPath: '.',
29
- contentPath: config.public.contentPath || '.',
57
+ contentPath: contentPath || '.',
30
58
  features: {
31
59
  bibleTooltips: siteConfig?.features?.bibleTooltips ?? false,
32
60
  sourceEdit: siteConfig?.features?.sourceEdit ?? false
@@ -35,3 +63,13 @@ export function useSiteConfig(): SiteConfig {
35
63
  themeColorDark: siteConfig?.themes?.dark?.colors?.primary || '#ffffff'
36
64
  }
37
65
  }
66
+
67
+ /**
68
+ * Get site configuration based on runtime config
69
+ */
70
+ export function useSiteConfig(): SiteConfig {
71
+ const config = useRuntimeConfig()
72
+ const siteConfig = config.public.siteConfig as RawSiteConfig | undefined
73
+
74
+ return mapSiteConfig(siteConfig, config.public.contentPath)
75
+ }
@@ -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(40)
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 < 40 (and headings >= 3)', () => {
21
+ it('returns false when lines < 15 (and headings >= 3)', () => {
22
22
  expect(computeShouldShowTOC(3, 0)).toBe(false)
23
- expect(computeShouldShowTOC(5, 39)).toBe(false)
23
+ expect(computeShouldShowTOC(5, 14)).toBe(false)
24
24
  })
25
25
 
26
- it('returns true when headings >= 3 and lines >= 40', () => {
27
- expect(computeShouldShowTOC(3, 40)).toBe(true)
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 = 40
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
- siteConfig
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
- type MenuItemType = string | { [key: string]: string | null | MenuItemType[] } | null
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: MenuItemType[],
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<MenuItemType[] | null> {
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?: MenuItemType[] } | null
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<MenuItemType[] | null> {
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 MenuItemType[] | null
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<MenuItemType[] | null> {
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: Array<string | null | Record<string, string | null | Array<any>>>
38
+ menu: MdsiteMenuItem[]
15
39
  footer: string[]
16
40
  server: {
17
41
  output: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@life-and-dev/mdsite",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",