@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.
@@ -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
+ };
@@ -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
+ }