@karaoke-cms/astro 0.9.8 → 0.10.3

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/src/types.ts CHANGED
@@ -1,155 +1,40 @@
1
- export type RegionComponent = 'header' | 'main-menu' | 'search' | 'recent-posts' | 'footer';
2
-
3
- export type CollectionMode = 'dev' | 'prod';
4
-
5
- export interface CollectionConfig {
6
- modes?: CollectionMode[];
7
- label?: string;
8
- }
9
-
10
- export interface ResolvedCollection {
11
- modes: CollectionMode[];
12
- label: string;
13
- enabled: boolean;
14
- }
15
-
16
- export type ResolvedCollections = Record<string, ResolvedCollection>;
17
-
18
- export interface CommentsConfig {
19
- enabled?: boolean;
20
- /** GitHub repo in "owner/repo" format */
21
- repo?: string;
22
- repoId?: string;
23
- category?: string;
24
- categoryId?: string;
25
- }
26
-
27
- export interface KaraokeConfig {
28
- /**
29
- * Path to the Obsidian vault root (where content/ lives).
30
- * Absolute, or relative to the Astro project root.
31
- * Defaults to the project root (vault and project are the same directory).
32
- * Typically set via KARAOKE_VAULT in .env (gitignored) with .env.default as fallback.
33
- */
34
- vault?: string;
35
- /** Per-collection mode overrides. Merges with collections.yaml; this field takes precedence. */
36
- collections?: Record<string, CollectionConfig>;
37
- /** Site title — displayed in the browser tab and nav bar. Defaults to 'Karaoke'. */
38
- title?: string;
39
- /** Site description — used in RSS feed and OG meta tags. */
40
- description?: string;
41
- /** Theme — package name string (legacy) or a ThemeInstance from defineTheme(). */
42
- theme?: string | ThemeInstance;
43
- /** Modules to activate. Each is a ModuleInstance from defineModule(). */
44
- modules?: ModuleInstance[];
45
- /** Giscus comments configuration. */
46
- comments?: CommentsConfig;
47
- layout?: {
48
- regions?: {
49
- top?: { components?: RegionComponent[] };
50
- left?: { components?: RegionComponent[] };
51
- right?: { components?: RegionComponent[] };
52
- bottom?: { components?: RegionComponent[] };
53
- };
54
- };
55
- }
56
-
57
- /** Resolved (defaults filled in) modules config — available at build time via virtual module. */
58
- export interface ResolvedModules {
59
- comments: {
60
- enabled: boolean;
61
- repo: string;
62
- repoId: string;
63
- category: string;
64
- categoryId: string;
65
- };
66
- }
67
-
68
- /** Resolved (defaults filled in) layout config — available at build time via virtual module. */
69
- export interface ResolvedLayout {
70
- regions: {
71
- top: { components: RegionComponent[] };
72
- left: { components: RegionComponent[] };
73
- right: { components: RegionComponent[] };
74
- bottom: { components: RegionComponent[] };
75
- };
76
- }
77
-
78
- // ── Menu types ─────────────────────────────────────────────────────────────────
79
-
80
- /** Raw shape of one entry in menus.yaml (used to type the parsed YAML). */
81
- export interface MenuEntryConfig {
82
- text: string;
83
- href?: string;
84
- weight?: number;
85
- /** Visibility condition — only 'collection:name' supported in v1. */
86
- when?: string;
87
- entries?: MenuEntryConfig[];
88
- }
89
-
90
- /** Raw shape of one menu block in menus.yaml. */
91
- export interface MenuConfig {
92
- orientation?: 'horizontal' | 'vertical';
93
- entries?: MenuEntryConfig[];
94
- }
95
-
96
- export interface ResolvedMenuEntry {
97
- text: string;
98
- href?: string;
99
- weight: number;
100
- when?: string;
101
- entries: ResolvedMenuEntry[];
102
- }
103
-
104
- export interface ResolvedMenu {
105
- name: string;
106
- orientation: 'horizontal' | 'vertical';
107
- entries: ResolvedMenuEntry[];
108
- }
109
-
110
- export type ResolvedMenus = Record<string, ResolvedMenu>;
111
-
112
- // ── Module system types ────────────────────────────────────────────────────────
113
-
114
- /** A menu entry registered by a module instance. */
115
- export interface ModuleMenuEntry {
116
- id: string;
117
- name: string;
118
- path: string;
119
- section: string;
120
- weight: number;
121
- }
122
-
123
- /** A resolved module instance — returned by a defineModule() factory. */
124
- export interface ModuleInstance {
125
- _type: 'module-instance';
126
- id: string;
127
- mount: string;
128
- /** When false, karaoke() skips route injection and menu entries for this module. Defaults to true. */
129
- enabled: boolean;
130
- routes: Array<{ pattern: string; entrypoint: string }>;
131
- menuEntries: ModuleMenuEntry[];
132
- cssContract: readonly string[];
133
- hasDefaultCss: boolean;
134
- /**
135
- * Page files to copy into the user's src/pages/ on first dev run.
136
- * Only copied if the destination does not already exist.
137
- * src: absolute path to the source .astro file in the npm package.
138
- * dest: path relative to src/pages/ (e.g. "blog/index.astro").
139
- */
140
- scaffoldPages?: Array<{ src: string; dest: string }>;
141
- /**
142
- * Absolute path to the module's default CSS file.
143
- * On first dev run, copied to src/styles/{id}.css and imported into global.css.
144
- */
145
- defaultCssPath?: string;
146
- }
147
-
148
- /** A resolved theme instance — returned by a defineTheme() factory. */
149
- export interface ThemeInstance {
150
- _type: 'theme-instance';
151
- id: string;
152
- implementedModuleIds: string[];
153
- implementedModules: ModuleInstance[];
154
- toAstroIntegration: (activeModules?: ModuleInstance[]) => import('astro').AstroIntegration;
155
- }
1
+ /**
2
+ * All shared types now live in @karaoke-cms/contracts.
3
+ *
4
+ * This file re-exports them for backwards compatibility — internal callers
5
+ * that import from './types.js' continue to work unchanged.
6
+ */
7
+ export type {
8
+ // CSS contract
9
+ CssContractSlot,
10
+ CssContract,
11
+ // Primitive named types
12
+ RouteDefinition,
13
+ CollectionSpec,
14
+ ScaffoldPage,
15
+ // Module system
16
+ ModuleMenuEntry,
17
+ ModuleInstance,
18
+ // Theme system
19
+ ThemeInstance,
20
+ ThemeFactoryConfig,
21
+ // Factory definitions
22
+ ModuleDefinition,
23
+ ThemeDefinition,
24
+ // Configuration
25
+ RegionComponent,
26
+ CollectionMode,
27
+ CollectionConfig,
28
+ ResolvedCollection,
29
+ ResolvedCollections,
30
+ CommentsConfig,
31
+ KaraokeConfig,
32
+ ResolvedModules,
33
+ ResolvedLayout,
34
+ // Menu
35
+ MenuEntryConfig,
36
+ MenuConfig,
37
+ ResolvedMenuEntry,
38
+ ResolvedMenu,
39
+ ResolvedMenus,
40
+ } from '@karaoke-cms/contracts';
@@ -38,10 +38,13 @@ function defaultMain(modules: ModuleInstance[]): ResolvedMenu {
38
38
  }))
39
39
  );
40
40
 
41
- // Theme-owned entries (Docs, Tags) are always present.
41
+ // Only inject the hardcoded Docs fallback when no module owns a docs collection.
42
+ // When modules supply their own docs sections (via docs() factory), those appear
43
+ // in moduleMainEntries and the hardcoded entry would produce a duplicate.
44
+ const hasDocsModule = modules.some(m => m.collection != null);
42
45
  const themeEntries: ResolvedMenuEntry[] = [
43
- { text: 'Docs', href: '/docs', weight: 20, when: 'collection:docs', entries: [] },
44
- { text: 'Tags', href: '/tags', weight: 30, entries: [] },
46
+ ...(!hasDocsModule ? [{ text: 'Docs', href: '/docs', weight: 20, when: 'collection:docs', entries: [] as ResolvedMenuEntry[] }] : []),
47
+ { text: 'Tags', href: '/tags', weight: 30, entries: [] as ResolvedMenuEntry[] },
45
48
  ];
46
49
 
47
50
  // If no modules provide main entries, fall back to hardcoded Blog default.
@@ -6,20 +6,96 @@
6
6
 
7
7
  /**
8
8
  * Validate module config at build time.
9
- * Throws a clear error if comments is enabled but required fields are missing.
9
+ * Throws a clear error if comments is enabled but required fields are missing,
10
+ * or if multiple docs instances share the same id, mount, folder, or collection name.
10
11
  *
11
12
  * @param {import('../karaoke.config').KaraokeConfig | null | undefined} config
12
13
  */
13
14
  export function validateModules(config) {
14
15
  const comments = config?.comments
15
- if (!comments?.enabled) return
16
+ if (comments?.enabled) {
17
+ const required = ['repo', 'repoId', 'category', 'categoryId']
18
+ const missing = required.filter(k => !comments[k])
19
+ if (missing.length > 0) {
20
+ throw new Error(
21
+ `karaoke-cms: comments.enabled is true but the following required fields are missing: ` +
22
+ `${missing.join(', ')}. Get these values from https://giscus.app`
23
+ )
24
+ }
25
+ }
26
+
27
+ // Gather all active docs instances from config.modules and theme.implementedModules.
28
+ const configModules = config?.modules ?? []
29
+ const themeModules =
30
+ config?.theme?._type === 'theme-instance'
31
+ ? (config.theme.implementedModules ?? [])
32
+ : []
33
+
34
+ const configDocs = configModules.filter(m => m.collection != null && m.enabled !== false)
35
+ const themeDocs = themeModules.filter(m => m.collection != null && m.enabled !== false)
36
+
37
+ // Check for duplicate ids WITHIN each source — an id appearing twice in config.modules
38
+ // (or twice in theme.implementedModules) is always an error.
39
+ function checkDupIds(arr, source) {
40
+ const ids = arr.map(m => m.id)
41
+ const dups = ids.filter((v, i) => ids.indexOf(v) !== i)
42
+ if (dups.length > 0) {
43
+ throw new Error(
44
+ `karaoke-cms: duplicate docs module id(s) in ${source}: ${[...new Set(dups)].join(', ')}. ` +
45
+ `Each docs() instance must have a unique id.`
46
+ )
47
+ }
48
+ }
49
+ checkDupIds(configDocs, 'config.modules')
50
+ checkDupIds(themeDocs, 'theme.implementedModules')
51
+
52
+ // Combine with dedup by id — config wins (same as runtime).
53
+ // Cross-source duplicates (same id in config AND theme) are silently merged.
54
+ const seenIds = new Set()
55
+ const allDocsInstances = [...configDocs, ...themeDocs].filter(m => {
56
+ if (seenIds.has(m.id)) return false
57
+ seenIds.add(m.id)
58
+ return true
59
+ })
60
+
61
+ // Validate mount paths for all instances (single or multiple).
62
+ const MOUNT_RE = /^\/[a-z0-9\-_/]*$/i
63
+ for (const inst of allDocsInstances) {
64
+ if (!MOUNT_RE.test(inst.mount)) {
65
+ throw new Error(
66
+ `karaoke-cms: docs() mount "${inst.mount}" contains invalid characters. ` +
67
+ `Use a simple path like "/docs" or "/api-docs" (no Astro route syntax, no trailing slash).`
68
+ )
69
+ }
70
+ }
71
+
72
+ if (allDocsInstances.length < 2) return
73
+
74
+ const mounts = allDocsInstances.map(m => m.mount)
75
+ const folders = allDocsInstances.map(m => m.collection.folder)
76
+ const collectionNames = allDocsInstances.map(m => m.collection.name)
77
+
78
+ const dupMounts = mounts.filter((v, i) => mounts.indexOf(v) !== i)
79
+ if (dupMounts.length > 0) {
80
+ throw new Error(
81
+ `karaoke-cms: duplicate docs module mount(s): ${[...new Set(dupMounts)].join(', ')}. ` +
82
+ `Each docs() instance must have a unique mount path.`
83
+ )
84
+ }
85
+
86
+ const dupFolders = folders.filter((v, i) => folders.indexOf(v) !== i)
87
+ if (dupFolders.length > 0) {
88
+ throw new Error(
89
+ `karaoke-cms: duplicate docs module folder(s): ${[...new Set(dupFolders)].join(', ')}. ` +
90
+ `Each docs() instance must read from a different vault folder.`
91
+ )
92
+ }
16
93
 
17
- const required = ['repo', 'repoId', 'category', 'categoryId']
18
- const missing = required.filter(k => !comments[k])
19
- if (missing.length > 0) {
94
+ const dupNames = collectionNames.filter((v, i) => collectionNames.indexOf(v) !== i)
95
+ if (dupNames.length > 0) {
20
96
  throw new Error(
21
- `karaoke-cms: comments.enabled is true but the following required fields are missing: ` +
22
- `${missing.join(', ')}. Get these values from https://giscus.app`
97
+ `karaoke-cms: duplicate docs collection name(s): ${[...new Set(dupNames)].join(', ')}. ` +
98
+ `Each docs() instance must use a unique collection name.`
23
99
  )
24
100
  }
25
101
  }