@zenithbuild/plugins 0.3.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.
- package/.github/workflows/release.yml +254 -0
- package/.releaserc.json +73 -0
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/bun.lock +17 -0
- package/content/README.md +116 -0
- package/content/enhancers.ts +39 -0
- package/content/hooks/useZenOrder.ts +292 -0
- package/content/index.ts +209 -0
- package/content/loader.ts +165 -0
- package/content/markdown.ts +244 -0
- package/content/query.ts +100 -0
- package/content/schema.ts +30 -0
- package/content/types.ts +53 -0
- package/package.json +36 -0
- package/scripts/release.ts +554 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useZenOrder Hook
|
|
3
|
+
*
|
|
4
|
+
* A plugin-provided hook for organizing and navigating documentation.
|
|
5
|
+
* Provides dynamic folder ordering, slug generation, and navigation helpers.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Dynamic folder ordering (meta.order → intro tag → alphabetical)
|
|
9
|
+
* - Slug generation for sections and docs
|
|
10
|
+
* - State management for selected section/doc
|
|
11
|
+
* - Navigation helpers (next/prev) with cross-section support
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ContentItem } from '../types';
|
|
15
|
+
|
|
16
|
+
export interface DocItem extends ContentItem {
|
|
17
|
+
slug: string;
|
|
18
|
+
sectionSlug: string;
|
|
19
|
+
isIntro?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Section {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
slug: string;
|
|
26
|
+
order?: number;
|
|
27
|
+
hasIntro: boolean;
|
|
28
|
+
items: DocItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ZenOrderState {
|
|
32
|
+
sections: Section[];
|
|
33
|
+
selectedSection: Section | null;
|
|
34
|
+
selectedDoc: DocItem | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ZenOrderActions {
|
|
38
|
+
selectSection: (section: Section) => void;
|
|
39
|
+
selectDoc: (doc: DocItem) => void;
|
|
40
|
+
getNextDoc: (currentDoc: DocItem) => DocItem | null;
|
|
41
|
+
getPrevDoc: (currentDoc: DocItem) => DocItem | null;
|
|
42
|
+
getDocBySlug: (sectionSlug: string, docSlug: string) => DocItem | null;
|
|
43
|
+
getSectionBySlug: (sectionSlug: string) => Section | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ZenOrderReturn = ZenOrderState & ZenOrderActions;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a URL-safe slug from a string
|
|
50
|
+
*/
|
|
51
|
+
function slugify(text: string): string {
|
|
52
|
+
return text
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^\w\s-]/g, '') // Remove special characters
|
|
55
|
+
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
|
56
|
+
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract slug from file path or title
|
|
62
|
+
*/
|
|
63
|
+
function getDocSlug(doc: ContentItem): string {
|
|
64
|
+
// Try to use the filename from the slug/id
|
|
65
|
+
const slugOrId = String(doc.slug || doc.id || '');
|
|
66
|
+
const parts = slugOrId.split('/');
|
|
67
|
+
const filename = parts[parts.length - 1];
|
|
68
|
+
|
|
69
|
+
// If filename exists and isn't empty, use it
|
|
70
|
+
if (filename && filename.length > 0) {
|
|
71
|
+
return slugify(filename);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Fallback to title
|
|
75
|
+
return slugify(doc.title || 'untitled');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sort sections dynamically based on:
|
|
80
|
+
* 1. meta.order (absolute priority if defined)
|
|
81
|
+
* 2. Presence of intro doc (soft priority)
|
|
82
|
+
* 3. Alphabetical fallback
|
|
83
|
+
*/
|
|
84
|
+
function sortSections(sections: Section[]): Section[] {
|
|
85
|
+
return [...sections].sort((a, b) => {
|
|
86
|
+
// 1. Order priority (lower is first)
|
|
87
|
+
if (a.order !== undefined && b.order !== undefined) {
|
|
88
|
+
return a.order - b.order;
|
|
89
|
+
}
|
|
90
|
+
if (a.order !== undefined) return -1;
|
|
91
|
+
if (b.order !== undefined) return 1;
|
|
92
|
+
|
|
93
|
+
// 2. Intro priority (sections with intro come first)
|
|
94
|
+
if (a.hasIntro && !b.hasIntro) return -1;
|
|
95
|
+
if (!a.hasIntro && b.hasIntro) return 1;
|
|
96
|
+
|
|
97
|
+
// 3. Alphabetical fallback
|
|
98
|
+
return a.title.localeCompare(b.title);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sort docs within a section
|
|
104
|
+
* - Intro docs come first
|
|
105
|
+
* - Then by order if defined
|
|
106
|
+
* - Then alphabetical
|
|
107
|
+
*/
|
|
108
|
+
function sortDocs(docs: DocItem[]): DocItem[] {
|
|
109
|
+
return [...docs].sort((a, b) => {
|
|
110
|
+
// Intro docs first
|
|
111
|
+
if (a.isIntro && !b.isIntro) return -1;
|
|
112
|
+
if (!a.isIntro && b.isIntro) return 1;
|
|
113
|
+
|
|
114
|
+
// Order priority
|
|
115
|
+
const orderA = (a as any).order;
|
|
116
|
+
const orderB = (b as any).order;
|
|
117
|
+
if (orderA !== undefined && orderB !== undefined) {
|
|
118
|
+
return orderA - orderB;
|
|
119
|
+
}
|
|
120
|
+
if (orderA !== undefined) return -1;
|
|
121
|
+
if (orderB !== undefined) return 1;
|
|
122
|
+
|
|
123
|
+
// Alphabetical
|
|
124
|
+
return (a.title || '').localeCompare(b.title || '');
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Process raw sections from zenCollection into structured sections with slugs
|
|
130
|
+
*/
|
|
131
|
+
export function processRawSections(rawSections: any[]): Section[] {
|
|
132
|
+
const sections: Section[] = rawSections.map(rawSection => {
|
|
133
|
+
const sectionSlug = slugify(rawSection.title || rawSection.id || 'section');
|
|
134
|
+
|
|
135
|
+
// Process items with slugs and section reference
|
|
136
|
+
const items: DocItem[] = (rawSection.items || []).map((item: ContentItem) => ({
|
|
137
|
+
...item,
|
|
138
|
+
slug: getDocSlug(item),
|
|
139
|
+
sectionSlug,
|
|
140
|
+
isIntro: (item as any).intro === true || (item as any).tags?.includes('intro')
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
// Sort items within section
|
|
144
|
+
const sortedItems = sortDocs(items);
|
|
145
|
+
|
|
146
|
+
// Check if section has intro doc
|
|
147
|
+
const hasIntro = sortedItems.some(item => item.isIntro);
|
|
148
|
+
|
|
149
|
+
// Get section order from first item's folder meta or undefined
|
|
150
|
+
const order = (rawSection as any).order ?? (rawSection as any).meta?.order;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
id: rawSection.id || sectionSlug,
|
|
154
|
+
title: rawSection.title || 'Untitled Section',
|
|
155
|
+
slug: sectionSlug,
|
|
156
|
+
order,
|
|
157
|
+
hasIntro,
|
|
158
|
+
items: sortedItems
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return sortSections(sections);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create the useZenOrder hook
|
|
167
|
+
*
|
|
168
|
+
* This function creates the hook state and actions.
|
|
169
|
+
* In the Zenith runtime, this would be called during component initialization.
|
|
170
|
+
*/
|
|
171
|
+
export function createZenOrder(rawSections: any[]): ZenOrderReturn {
|
|
172
|
+
// Process and sort sections
|
|
173
|
+
const sections = processRawSections(rawSections);
|
|
174
|
+
|
|
175
|
+
// Initialize state
|
|
176
|
+
let state: ZenOrderState = {
|
|
177
|
+
sections,
|
|
178
|
+
selectedSection: sections[0] || null,
|
|
179
|
+
selectedDoc: sections[0]?.items[0] || null
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Actions
|
|
183
|
+
const selectSection = (section: Section): void => {
|
|
184
|
+
state.selectedSection = section;
|
|
185
|
+
state.selectedDoc = section.items[0] || null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const selectDoc = (doc: DocItem): void => {
|
|
189
|
+
state.selectedDoc = doc;
|
|
190
|
+
// Also update selected section if doc is from a different section
|
|
191
|
+
const docSection = sections.find(s => s.slug === doc.sectionSlug);
|
|
192
|
+
if (docSection && state.selectedSection?.slug !== doc.sectionSlug) {
|
|
193
|
+
state.selectedSection = docSection;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const getSectionBySlug = (sectionSlug: string): Section | null => {
|
|
198
|
+
return sections.find(s => s.slug === sectionSlug) || null;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const getDocBySlug = (sectionSlug: string, docSlug: string): DocItem | null => {
|
|
202
|
+
const section = getSectionBySlug(sectionSlug);
|
|
203
|
+
if (!section) return null;
|
|
204
|
+
return section.items.find(d => d.slug === docSlug) || null;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const getNextDoc = (currentDoc: DocItem): DocItem | null => {
|
|
208
|
+
const currentSection = sections.find(s => s.slug === currentDoc.sectionSlug);
|
|
209
|
+
if (!currentSection) return null;
|
|
210
|
+
|
|
211
|
+
const currentIndex = currentSection.items.findIndex(d => d.slug === currentDoc.slug);
|
|
212
|
+
|
|
213
|
+
// Try next doc in same section
|
|
214
|
+
if (currentIndex < currentSection.items.length - 1) {
|
|
215
|
+
return currentSection.items[currentIndex + 1];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Try first doc of next section
|
|
219
|
+
const sectionIndex = sections.findIndex(s => s.slug === currentSection.slug);
|
|
220
|
+
if (sectionIndex < sections.length - 1) {
|
|
221
|
+
const nextSection = sections[sectionIndex + 1];
|
|
222
|
+
return nextSection.items[0] || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getPrevDoc = (currentDoc: DocItem): DocItem | null => {
|
|
229
|
+
const currentSection = sections.find(s => s.slug === currentDoc.sectionSlug);
|
|
230
|
+
if (!currentSection) return null;
|
|
231
|
+
|
|
232
|
+
const currentIndex = currentSection.items.findIndex(d => d.slug === currentDoc.slug);
|
|
233
|
+
|
|
234
|
+
// Try previous doc in same section
|
|
235
|
+
if (currentIndex > 0) {
|
|
236
|
+
return currentSection.items[currentIndex - 1];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Try last doc of previous section
|
|
240
|
+
const sectionIndex = sections.findIndex(s => s.slug === currentSection.slug);
|
|
241
|
+
if (sectionIndex > 0) {
|
|
242
|
+
const prevSection = sections[sectionIndex - 1];
|
|
243
|
+
return prevSection.items[prevSection.items.length - 1] || null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
...state,
|
|
251
|
+
sections,
|
|
252
|
+
selectSection,
|
|
253
|
+
selectDoc,
|
|
254
|
+
getNextDoc,
|
|
255
|
+
getPrevDoc,
|
|
256
|
+
getDocBySlug,
|
|
257
|
+
getSectionBySlug
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build a documentation URL from section and doc slugs
|
|
263
|
+
*/
|
|
264
|
+
export function buildDocUrl(sectionSlug: string, docSlug?: string): string {
|
|
265
|
+
if (!docSlug || docSlug === 'index') {
|
|
266
|
+
return `/documentation/${sectionSlug}`;
|
|
267
|
+
}
|
|
268
|
+
return `/documentation/${sectionSlug}/${docSlug}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse slugs from a URL path
|
|
273
|
+
*/
|
|
274
|
+
export function parseDocUrl(path: string): { sectionSlug: string | null; docSlug: string | null } {
|
|
275
|
+
const match = path.match(/^\/documentation\/([^/]+)(?:\/([^/]+))?$/);
|
|
276
|
+
if (!match) {
|
|
277
|
+
return { sectionSlug: null, docSlug: null };
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
sectionSlug: match[1] || null,
|
|
281
|
+
docSlug: match[2] || null
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Default export for easy importing
|
|
286
|
+
export default {
|
|
287
|
+
createZenOrder,
|
|
288
|
+
processRawSections,
|
|
289
|
+
buildDocUrl,
|
|
290
|
+
parseDocUrl,
|
|
291
|
+
slugify
|
|
292
|
+
};
|
package/content/index.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zenith Content Plugin
|
|
3
|
+
*
|
|
4
|
+
* A data provider plugin for loading content from markdown and JSON files.
|
|
5
|
+
*
|
|
6
|
+
* Usage in zenith.config.ts:
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { defineConfig } from 'zenith/config';
|
|
9
|
+
* import content from 'zenith-content';
|
|
10
|
+
*
|
|
11
|
+
* export default defineConfig({
|
|
12
|
+
* plugins: [
|
|
13
|
+
* content({
|
|
14
|
+
* sources: {
|
|
15
|
+
* docs: {
|
|
16
|
+
* root: '../zenith-docs',
|
|
17
|
+
* include: ['documentation']
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* })
|
|
21
|
+
* ]
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ContentItem, ContentPluginOptions, ContentSourceConfig } from "./types";
|
|
27
|
+
import { loadContent, loadFromSources } from "./loader";
|
|
28
|
+
import { ZenCollection } from "./query";
|
|
29
|
+
import { defineSchema } from "./schema";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
// Re-exports for convenience
|
|
33
|
+
export { defineSchema } from "./schema";
|
|
34
|
+
export { ZenCollection } from "./query";
|
|
35
|
+
export type { ContentItem, ContentPluginOptions, ContentSourceConfig } from "./types";
|
|
36
|
+
|
|
37
|
+
// Export useZenOrder hook and utilities
|
|
38
|
+
export {
|
|
39
|
+
createZenOrder,
|
|
40
|
+
processRawSections,
|
|
41
|
+
buildDocUrl,
|
|
42
|
+
parseDocUrl
|
|
43
|
+
} from "./hooks/useZenOrder";
|
|
44
|
+
export type {
|
|
45
|
+
DocItem,
|
|
46
|
+
Section,
|
|
47
|
+
ZenOrderState,
|
|
48
|
+
ZenOrderActions,
|
|
49
|
+
ZenOrderReturn
|
|
50
|
+
} from "./hooks/useZenOrder";
|
|
51
|
+
|
|
52
|
+
// Store for loaded content (used by legacy zenQuery)
|
|
53
|
+
let allContent: ContentItem[] = [];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Legacy query function - use zenCollection in templates instead
|
|
57
|
+
*/
|
|
58
|
+
export const zenQuery = (collection: string) => {
|
|
59
|
+
const items = allContent.filter(item => item.collection === collection);
|
|
60
|
+
return new ZenCollection(items);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Plugin interface expected by core
|
|
65
|
+
*/
|
|
66
|
+
export interface ZenithPlugin {
|
|
67
|
+
name: string;
|
|
68
|
+
setup: (ctx: PluginContext) => void | Promise<void>;
|
|
69
|
+
config?: unknown;
|
|
70
|
+
registerCLI?: (api: CLIBridgeAPI) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Context provided to plugins during setup (matches core generic signature)
|
|
75
|
+
*/
|
|
76
|
+
export interface PluginContext {
|
|
77
|
+
projectRoot: string;
|
|
78
|
+
setPluginData: (namespace: string, data: unknown[]) => void;
|
|
79
|
+
options?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* CLI Bridge API for hook registration
|
|
84
|
+
* Matches core's CLIBridgeAPI interface
|
|
85
|
+
*/
|
|
86
|
+
export interface CLIBridgeAPI {
|
|
87
|
+
on(hook: string, handler: (ctx: HookContext) => unknown | void): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Hook context passed to CLI hooks
|
|
92
|
+
*/
|
|
93
|
+
export interface HookContext {
|
|
94
|
+
projectRoot: string;
|
|
95
|
+
getPluginData: (namespace: string) => unknown;
|
|
96
|
+
[key: string]: unknown;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Content plugin factory function
|
|
101
|
+
*
|
|
102
|
+
* @param options - Plugin configuration with sources
|
|
103
|
+
* @returns A ZenithPlugin that loads content from configured sources
|
|
104
|
+
*/
|
|
105
|
+
export default function content(options: ContentPluginOptions = {}): ZenithPlugin {
|
|
106
|
+
return {
|
|
107
|
+
name: 'zenith-content',
|
|
108
|
+
config: options,
|
|
109
|
+
setup(ctx: PluginContext) {
|
|
110
|
+
let collections: Record<string, ContentItem[]> = {};
|
|
111
|
+
|
|
112
|
+
if (options.sources && Object.keys(options.sources).length > 0) {
|
|
113
|
+
// Use new sources configuration
|
|
114
|
+
collections = loadFromSources(options.sources, ctx.projectRoot);
|
|
115
|
+
} else if (options.contentDir) {
|
|
116
|
+
// Legacy: single content directory
|
|
117
|
+
const contentPath = path.resolve(ctx.projectRoot, options.contentDir);
|
|
118
|
+
const items = loadContent(contentPath);
|
|
119
|
+
|
|
120
|
+
// Group by collection
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
const collection = item.collection || 'default';
|
|
123
|
+
if (!collections[collection]) {
|
|
124
|
+
collections[collection] = [];
|
|
125
|
+
}
|
|
126
|
+
collections[collection].push(item);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
console.log(`[zenith:content] Loaded ${items.length} items from ${options.contentDir}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Pass to runtime using generic namespaced data store
|
|
133
|
+
const allItems = Object.values(collections).flat();
|
|
134
|
+
ctx.setPluginData('content', allItems);
|
|
135
|
+
|
|
136
|
+
// Update legacy storage
|
|
137
|
+
allContent = allItems;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* CLI Registration - Plugin owns its CLI behavior
|
|
142
|
+
*
|
|
143
|
+
* Registers namespaced hooks for CLI lifecycle events.
|
|
144
|
+
* The CLI never calls plugin logic directly - it dispatches hooks.
|
|
145
|
+
*/
|
|
146
|
+
registerCLI(api: CLIBridgeAPI) {
|
|
147
|
+
// Register for runtime data collection
|
|
148
|
+
// CLI collects payloads and serializes to window.__ZENITH_PLUGIN_DATA__
|
|
149
|
+
api.on('cli:runtime:collect', (ctx: HookContext) => {
|
|
150
|
+
const data = ctx.getPluginData('content');
|
|
151
|
+
if (!data) return;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
namespace: 'content',
|
|
155
|
+
payload: data
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Register for file change events (plugin decides what to do)
|
|
160
|
+
api.on('cli:dev:file-change', (ctx: HookContext) => {
|
|
161
|
+
const filename = ctx.filename as string | undefined;
|
|
162
|
+
if (!filename) return;
|
|
163
|
+
|
|
164
|
+
// Plugin owns knowledge of what files it cares about
|
|
165
|
+
if (filename.startsWith('content') || filename.endsWith('.md') || filename.endsWith('.mdx')) {
|
|
166
|
+
// Signal that content should be reloaded
|
|
167
|
+
// The actual reload happens via plugin re-initialization
|
|
168
|
+
console.log('[zenith:content] Content file changed:', filename);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Legacy plugin export for backward compatibility
|
|
177
|
+
* @deprecated Use the default export factory function instead
|
|
178
|
+
*/
|
|
179
|
+
export const plugin: ZenithPlugin = {
|
|
180
|
+
name: "zenith-content",
|
|
181
|
+
setup(ctx: PluginContext) {
|
|
182
|
+
const contentDir = path.resolve(ctx.projectRoot, "content");
|
|
183
|
+
const items = loadContent(contentDir);
|
|
184
|
+
|
|
185
|
+
// Group by collection
|
|
186
|
+
const collections: Record<string, ContentItem[]> = {};
|
|
187
|
+
for (const item of items) {
|
|
188
|
+
const collection = item.collection || 'default';
|
|
189
|
+
if (!collections[collection]) {
|
|
190
|
+
collections[collection] = [];
|
|
191
|
+
}
|
|
192
|
+
collections[collection].push(item);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
allContent = items;
|
|
196
|
+
ctx.setPluginData('content', items);
|
|
197
|
+
|
|
198
|
+
console.log(`[zenith:content] Loaded ${items.length} items from ${contentDir}`);
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
registerCLI(api: CLIBridgeAPI) {
|
|
202
|
+
api.on('cli:runtime:collect', (ctx: HookContext) => {
|
|
203
|
+
const data = ctx.getPluginData('content');
|
|
204
|
+
if (!data) return;
|
|
205
|
+
return { namespace: 'content', payload: data };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { ContentItem, ContentSourceConfig } from './types';
|
|
4
|
+
import { compileMarkdown, vnodesToHtml } from './markdown';
|
|
5
|
+
|
|
6
|
+
export function loadContent(contentDir: string): ContentItem[] {
|
|
7
|
+
if (!fs.existsSync(contentDir)) {
|
|
8
|
+
console.warn(`Content directory ${contentDir} does not exist.`);
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const items: ContentItem[] = [];
|
|
13
|
+
const files = getAllFiles(contentDir);
|
|
14
|
+
|
|
15
|
+
for (const filePath of files) {
|
|
16
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
17
|
+
const relativePath = path.relative(contentDir, filePath);
|
|
18
|
+
const collection = relativePath.split(path.sep)[0];
|
|
19
|
+
// Ensure slug is forward-slash based and has no leading/trailing slashes
|
|
20
|
+
const slug = relativePath
|
|
21
|
+
.replace(/\.(md|mdx|json)$/, '')
|
|
22
|
+
.split(path.sep)
|
|
23
|
+
.join('/')
|
|
24
|
+
.replace(/^\//, '');
|
|
25
|
+
const id = slug;
|
|
26
|
+
|
|
27
|
+
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
28
|
+
|
|
29
|
+
if (ext === '.json') {
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(rawContent);
|
|
32
|
+
items.push({
|
|
33
|
+
id,
|
|
34
|
+
slug,
|
|
35
|
+
collection,
|
|
36
|
+
content: '',
|
|
37
|
+
...data
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error(`Error parsing JSON file ${filePath}:`, e);
|
|
41
|
+
}
|
|
42
|
+
} else if (ext === '.md' || ext === '.mdx') {
|
|
43
|
+
const { metadata, content: markdownBody } = parseMarkdown(rawContent);
|
|
44
|
+
// Compile markdown to VNodes then to HTML for rendering
|
|
45
|
+
const vnodes = compileMarkdown(markdownBody);
|
|
46
|
+
const compiledContent = vnodesToHtml(vnodes);
|
|
47
|
+
items.push({
|
|
48
|
+
id,
|
|
49
|
+
slug,
|
|
50
|
+
collection,
|
|
51
|
+
content: compiledContent,
|
|
52
|
+
...metadata
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return items;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load content from configured sources
|
|
62
|
+
* Supports include/exclude filtering for folder selection
|
|
63
|
+
*/
|
|
64
|
+
export function loadFromSources(
|
|
65
|
+
sources: Record<string, ContentSourceConfig>,
|
|
66
|
+
projectRoot: string
|
|
67
|
+
): Record<string, ContentItem[]> {
|
|
68
|
+
const collections: Record<string, ContentItem[]> = {};
|
|
69
|
+
|
|
70
|
+
for (const [collectionName, config] of Object.entries(sources)) {
|
|
71
|
+
const rootPath = path.resolve(projectRoot, config.root);
|
|
72
|
+
|
|
73
|
+
if (!fs.existsSync(rootPath)) {
|
|
74
|
+
console.warn(`[zenith:content] Source root "${rootPath}" does not exist for collection "${collectionName}"`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Get folders to scan
|
|
79
|
+
let foldersToScan: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (config.include && config.include.length > 0) {
|
|
82
|
+
// Explicit include list
|
|
83
|
+
foldersToScan = config.include;
|
|
84
|
+
} else {
|
|
85
|
+
// Scan all subdirectories if no include specified
|
|
86
|
+
try {
|
|
87
|
+
foldersToScan = fs.readdirSync(rootPath)
|
|
88
|
+
.filter(f => fs.statSync(path.join(rootPath, f)).isDirectory());
|
|
89
|
+
} catch {
|
|
90
|
+
// If root is itself the content folder
|
|
91
|
+
foldersToScan = ['.'];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Apply excludes
|
|
96
|
+
const exclude = config.exclude || [];
|
|
97
|
+
foldersToScan = foldersToScan.filter(f => !exclude.includes(f));
|
|
98
|
+
|
|
99
|
+
// Load content from each folder
|
|
100
|
+
const items: ContentItem[] = [];
|
|
101
|
+
for (const folder of foldersToScan) {
|
|
102
|
+
const folderPath = folder === '.' ? rootPath : path.join(rootPath, folder);
|
|
103
|
+
|
|
104
|
+
if (!fs.existsSync(folderPath)) {
|
|
105
|
+
console.warn(`[zenith:content] Folder "${folderPath}" does not exist, skipping`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const folderItems = loadContent(folderPath);
|
|
110
|
+
|
|
111
|
+
// Override collection name to match the configured name
|
|
112
|
+
items.push(...folderItems.map(item => ({
|
|
113
|
+
...item,
|
|
114
|
+
collection: collectionName
|
|
115
|
+
})));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
collections[collectionName] = items;
|
|
119
|
+
console.log(`[zenith:content] Loaded ${items.length} items for collection "${collectionName}"`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return collections;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getAllFiles(dir: string, fileList: string[] = []): string[] {
|
|
126
|
+
const files = fs.readdirSync(dir);
|
|
127
|
+
files.forEach((file: string) => {
|
|
128
|
+
const name = path.join(dir, file);
|
|
129
|
+
if (fs.statSync(name).isDirectory()) {
|
|
130
|
+
getAllFiles(name, fileList);
|
|
131
|
+
} else {
|
|
132
|
+
fileList.push(name);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return fileList;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseMarkdown(content: string): { metadata: Record<string, any>, content: string } {
|
|
139
|
+
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
140
|
+
const match = content.match(frontmatterRegex);
|
|
141
|
+
|
|
142
|
+
if (!match) {
|
|
143
|
+
return { metadata: {}, content: content.trim() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [, yamlStr, body] = match;
|
|
147
|
+
const metadata: Record<string, any> = {};
|
|
148
|
+
|
|
149
|
+
yamlStr.split('\n').forEach(line => {
|
|
150
|
+
const [key, ...values] = line.split(':');
|
|
151
|
+
if (key && values.length > 0) {
|
|
152
|
+
const value = values.join(':').trim();
|
|
153
|
+
// Basic type conversion
|
|
154
|
+
if (value === 'true') metadata[key.trim()] = true;
|
|
155
|
+
else if (value === 'false') metadata[key.trim()] = false;
|
|
156
|
+
else if (!isNaN(Number(value))) metadata[key.trim()] = Number(value);
|
|
157
|
+
else if (value.startsWith('[') && value.endsWith(']')) {
|
|
158
|
+
metadata[key.trim()] = value.slice(1, -1).split(',').map(v => v.trim().replace(/^['"]|['"]$/g, ''));
|
|
159
|
+
}
|
|
160
|
+
else metadata[key.trim()] = value.replace(/^['"]|['"]$/g, '');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return { metadata, content: body.trim() };
|
|
165
|
+
}
|