@litodocs/cli 0.5.2 → 0.7.0

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.
@@ -2,6 +2,7 @@ import pkg from 'fs-extra';
2
2
  const { readFile, writeFile, pathExists } = pkg;
3
3
  import { join } from 'path';
4
4
  import { readdir } from 'fs/promises';
5
+ import { validateConfig } from './config-validator.js';
5
6
 
6
7
  /**
7
8
  * Auto-generates navigation structure from docs folder
@@ -70,8 +71,11 @@ export async function generateNavigationFromDocs(docsPath) {
70
71
 
71
72
  /**
72
73
  * Merges user config with auto-generated navigation
74
+ * @param {object} options - Sync options
75
+ * @param {boolean} options.validate - Whether to validate the config (default: true)
73
76
  */
74
- export async function syncDocsConfig(projectDir, docsPath, userConfigPath) {
77
+ export async function syncDocsConfig(projectDir, docsPath, userConfigPath, options = {}) {
78
+ const { validate = true } = options;
75
79
  const configPath = join(projectDir, 'docs-config.json');
76
80
 
77
81
  // Read base config from template
@@ -80,6 +84,16 @@ export async function syncDocsConfig(projectDir, docsPath, userConfigPath) {
80
84
  // If user has a custom config, merge it
81
85
  if (userConfigPath && await pathExists(userConfigPath)) {
82
86
  const userConfig = JSON.parse(await readFile(userConfigPath, 'utf-8'));
87
+
88
+ // Validate user config before merging (only core config is validated)
89
+ if (validate) {
90
+ const validationResult = validateConfig(userConfig, projectDir);
91
+ if (!validationResult.valid) {
92
+ const errorMessages = validationResult.errors.map(e => `${e.path}: ${e.message}`).join('\n ');
93
+ throw new Error(`Configuration validation failed:\n ${errorMessages}`);
94
+ }
95
+ }
96
+
83
97
  config = deepMerge(config, userConfig);
84
98
  }
85
99
 
@@ -90,6 +104,8 @@ export async function syncDocsConfig(projectDir, docsPath, userConfigPath) {
90
104
 
91
105
  // Write merged config
92
106
  await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
107
+
108
+ return { config };
93
109
  }
94
110
 
95
111
  function formatLabel(str) {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Configuration Validator
3
+ *
4
+ * Validates user docs-config.json against the core schema.
5
+ * Core config is strictly validated - extensions are always allowed
6
+ * (templates simply ignore what they don't support).
7
+ */
8
+
9
+ import { readFileSync, existsSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { fileURLToPath } from 'url';
12
+ import pc from 'picocolors';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+
16
+ // Core schema defines portable config across all templates
17
+ const CORE_SCHEMA_PATH = join(__dirname, '../schema/core-schema.json');
18
+
19
+ // Core config keys that all templates must support
20
+ const CORE_CONFIG_KEYS = [
21
+ 'metadata',
22
+ 'branding',
23
+ 'navigation',
24
+ 'search',
25
+ 'seo',
26
+ 'i18n',
27
+ 'assets'
28
+ ];
29
+
30
+ // Extension keys that are template-specific (optional, never cause errors)
31
+ const EXTENSION_KEYS = [
32
+ 'footer',
33
+ 'theme',
34
+ 'landing',
35
+ 'integrations',
36
+ 'versioning'
37
+ ];
38
+
39
+ /**
40
+ * Load the core schema
41
+ */
42
+ function loadCoreSchema() {
43
+ if (!existsSync(CORE_SCHEMA_PATH)) {
44
+ return null;
45
+ }
46
+ return JSON.parse(readFileSync(CORE_SCHEMA_PATH, 'utf-8'));
47
+ }
48
+
49
+ /**
50
+ * Load template manifest from project directory
51
+ */
52
+ export function loadTemplateManifest(projectDir) {
53
+ const manifestPath = join(projectDir, 'template.json');
54
+ if (!existsSync(manifestPath)) {
55
+ return null;
56
+ }
57
+ try {
58
+ return JSON.parse(readFileSync(manifestPath, 'utf-8'));
59
+ } catch (e) {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Validate configuration against core schema (basic validation)
66
+ * Only validates REQUIRED fields and types - extensions are always allowed.
67
+ */
68
+ function validateCoreConfig(config, schema) {
69
+ const errors = [];
70
+
71
+ // Check required fields
72
+ if (schema.required) {
73
+ for (const field of schema.required) {
74
+ if (!(field in config)) {
75
+ errors.push({
76
+ path: field,
77
+ message: `Required field '${field}' is missing`
78
+ });
79
+ }
80
+ }
81
+ }
82
+
83
+ // Check metadata.name is required
84
+ if (config.metadata && !config.metadata.name) {
85
+ errors.push({
86
+ path: 'metadata.name',
87
+ message: "Required field 'metadata.name' is missing"
88
+ });
89
+ }
90
+
91
+ // Validate types for core fields
92
+ if (config.metadata && typeof config.metadata !== 'object') {
93
+ errors.push({
94
+ path: 'metadata',
95
+ message: "'metadata' must be an object"
96
+ });
97
+ }
98
+
99
+ if (config.navigation?.sidebar && !Array.isArray(config.navigation.sidebar)) {
100
+ errors.push({
101
+ path: 'navigation.sidebar',
102
+ message: "'navigation.sidebar' must be an array"
103
+ });
104
+ }
105
+
106
+ if (config.navigation?.navbar?.links && !Array.isArray(config.navigation.navbar.links)) {
107
+ errors.push({
108
+ path: 'navigation.navbar.links',
109
+ message: "'navigation.navbar.links' must be an array"
110
+ });
111
+ }
112
+
113
+ // Validate sidebar items structure
114
+ if (config.navigation?.sidebar && Array.isArray(config.navigation.sidebar)) {
115
+ config.navigation.sidebar.forEach((group, i) => {
116
+ if (!group.label) {
117
+ errors.push({
118
+ path: `navigation.sidebar[${i}].label`,
119
+ message: `Sidebar group at index ${i} is missing required 'label' field`
120
+ });
121
+ }
122
+ if (group.items && !Array.isArray(group.items)) {
123
+ errors.push({
124
+ path: `navigation.sidebar[${i}].items`,
125
+ message: `Sidebar group '${group.label}' items must be an array`
126
+ });
127
+ }
128
+ });
129
+ }
130
+
131
+ return errors;
132
+ }
133
+
134
+ /**
135
+ * Validate user configuration
136
+ *
137
+ * Only validates core config structure. Extensions are always allowed -
138
+ * templates simply ignore what they don't support.
139
+ *
140
+ * @param {object} config - User's docs-config.json content
141
+ * @param {string} projectDir - Path to the project directory
142
+ * @param {object} options - Validation options
143
+ * @param {boolean} options.silent - If true, don't print anything
144
+ * @returns {{ valid: boolean, errors: Array }}
145
+ */
146
+ export function validateConfig(config, projectDir, options = {}) {
147
+ const { silent = false } = options;
148
+
149
+ const coreSchema = loadCoreSchema();
150
+
151
+ // Only validate core config - extensions are always allowed
152
+ const coreErrors = coreSchema ? validateCoreConfig(config, coreSchema) : [];
153
+
154
+ const result = {
155
+ valid: coreErrors.length === 0,
156
+ errors: coreErrors,
157
+ manifest: loadTemplateManifest(projectDir)
158
+ };
159
+
160
+ if (!silent && coreErrors.length > 0) {
161
+ printValidationErrors(coreErrors);
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Print validation errors to console
169
+ */
170
+ function printValidationErrors(errors) {
171
+ console.log(pc.red('\n✗ Configuration validation failed:\n'));
172
+ for (const error of errors) {
173
+ console.log(pc.red(` • ${error.path}: ${error.message}`));
174
+ }
175
+ console.log('');
176
+ }
177
+
178
+ /**
179
+ * Get list of portable (core) config keys
180
+ */
181
+ export function getCoreConfigKeys() {
182
+ return [...CORE_CONFIG_KEYS];
183
+ }
184
+
185
+ /**
186
+ * Get list of extension config keys
187
+ */
188
+ export function getExtensionKeys() {
189
+ return [...EXTENSION_KEYS];
190
+ }
191
+
192
+ /**
193
+ * Extract only core config from a full config object
194
+ */
195
+ export function extractCoreConfig(config) {
196
+ const coreConfig = {};
197
+ for (const key of CORE_CONFIG_KEYS) {
198
+ if (key in config) {
199
+ coreConfig[key] = config[key];
200
+ }
201
+ }
202
+ return coreConfig;
203
+ }
204
+
205
+ /**
206
+ * Extract only extension config from a full config object
207
+ */
208
+ export function extractExtensionConfig(config) {
209
+ const extensionConfig = {};
210
+ for (const key of EXTENSION_KEYS) {
211
+ if (key in config) {
212
+ extensionConfig[key] = config[key];
213
+ }
214
+ }
215
+ return extensionConfig;
216
+ }
217
+
218
+ /**
219
+ * Check if config is portable (uses only core config)
220
+ * Useful for users who want to ensure their config works with any template.
221
+ */
222
+ export function isPortableConfig(config) {
223
+ for (const key of EXTENSION_KEYS) {
224
+ if (key in config) {
225
+ return false;
226
+ }
227
+ }
228
+ return true;
229
+ }
@@ -1,9 +1,15 @@
1
1
  import pkg from "fs-extra";
2
- const { readFile, writeFile, ensureDir } = pkg;
2
+ const { readFile, writeFile, ensureDir, pathExists } = pkg;
3
3
  import { join } from "path";
4
4
  import { generateThemeStyles } from "./colors.js";
5
5
 
6
- export async function generateConfig(projectDir, options) {
6
+ /**
7
+ * Generate configuration for the project
8
+ * @param {string} projectDir - The scaffolded project directory
9
+ * @param {object} options - CLI options
10
+ * @param {object|null} frameworkConfig - Framework configuration (optional)
11
+ */
12
+ export async function generateConfig(projectDir, options, frameworkConfig = null) {
7
13
  const {
8
14
  baseUrl,
9
15
  name,
@@ -68,27 +74,65 @@ export async function generateConfig(projectDir, options) {
68
74
  await writeFile(join(stylesDir, "generated-theme.css"), "/* No generated theme styles */", "utf-8");
69
75
  }
70
76
 
71
- // Update astro.config.mjs with base URL and site URL
72
- if ((baseUrl && baseUrl !== "/") || config.metadata?.url) {
77
+ // Update astro.config.mjs with base URL and site URL (only for Astro)
78
+ const frameworkName = frameworkConfig?.name || 'astro';
79
+
80
+ if (frameworkName === 'astro' && ((baseUrl && baseUrl !== "/") || config.metadata?.url)) {
73
81
  const astroConfigPath = join(projectDir, "astro.config.mjs");
74
- let content = await readFile(astroConfigPath, "utf-8");
82
+ if (await pathExists(astroConfigPath)) {
83
+ let content = await readFile(astroConfigPath, "utf-8");
84
+
85
+ let injection = "export default defineConfig({\n";
86
+
87
+ if (baseUrl && baseUrl !== "/") {
88
+ injection += ` base: '${baseUrl}',\n`;
89
+ }
90
+
91
+ if (config.metadata?.url) {
92
+ injection += ` site: '${config.metadata.url}',\n`;
93
+ }
94
+
95
+ // Add base/site option to defineConfig
96
+ content = content.replace(
97
+ "export default defineConfig({",
98
+ injection
99
+ );
75
100
 
76
- let injection = "export default defineConfig({\n";
77
-
78
- if (baseUrl && baseUrl !== "/") {
79
- injection += ` base: '${baseUrl}',\n`;
101
+ await writeFile(astroConfigPath, content, "utf-8");
80
102
  }
81
-
82
- if (config.metadata?.url) {
83
- injection += ` site: '${config.metadata.url}',\n`;
103
+ }
104
+
105
+ // Update vite.config.js for React/Vue frameworks
106
+ if (['react', 'vue'].includes(frameworkName) && baseUrl && baseUrl !== "/") {
107
+ const viteConfigPath = join(projectDir, "vite.config.js");
108
+ if (await pathExists(viteConfigPath)) {
109
+ let content = await readFile(viteConfigPath, "utf-8");
110
+
111
+ // Add base option to defineConfig
112
+ if (content.includes("defineConfig({")) {
113
+ content = content.replace(
114
+ "defineConfig({",
115
+ `defineConfig({\n base: '${baseUrl}',`
116
+ );
117
+ await writeFile(viteConfigPath, content, "utf-8");
118
+ }
84
119
  }
120
+ }
85
121
 
86
- // Add base/site option to defineConfig
87
- content = content.replace(
88
- "export default defineConfig({",
89
- injection
90
- );
122
+ // Update next.config.js for Next.js
123
+ if (frameworkName === 'next' && baseUrl && baseUrl !== "/") {
124
+ const nextConfigPath = join(projectDir, "next.config.js");
125
+ if (await pathExists(nextConfigPath)) {
126
+ let content = await readFile(nextConfigPath, "utf-8");
91
127
 
92
- await writeFile(astroConfigPath, content, "utf-8");
128
+ // Add basePath to Next.js config
129
+ if (!content.includes("basePath")) {
130
+ content = content.replace(
131
+ "module.exports = {",
132
+ `module.exports = {\n basePath: '${baseUrl}',`
133
+ );
134
+ await writeFile(nextConfigPath, content, "utf-8");
135
+ }
136
+ }
93
137
  }
94
138
  }
@@ -0,0 +1,208 @@
1
+ import { runBinary } from './package-manager.js';
2
+ import { join } from 'path';
3
+ import pkg from 'fs-extra';
4
+ const { pathExists, readJson } = pkg;
5
+
6
+ /**
7
+ * Framework configuration schema
8
+ * Templates can define their framework in lito-manifest.json or template-manifest.json
9
+ */
10
+ const DEFAULT_FRAMEWORK_CONFIGS = {
11
+ astro: {
12
+ name: 'astro',
13
+ commands: {
14
+ dev: ['astro', ['dev']],
15
+ build: ['astro', ['build']],
16
+ },
17
+ contentDir: 'src/pages',
18
+ publicDir: 'public',
19
+ configFile: 'astro.config.mjs',
20
+ layoutInjection: true,
21
+ layoutPath: 'layouts/MarkdownLayout.astro',
22
+ apiLayoutPath: 'layouts/APILayout.astro',
23
+ },
24
+ react: {
25
+ name: 'react',
26
+ commands: {
27
+ dev: ['vite', ['--host']],
28
+ build: ['vite', ['build']],
29
+ },
30
+ contentDir: 'src/content',
31
+ publicDir: 'public',
32
+ configFile: 'vite.config.js',
33
+ layoutInjection: false,
34
+ useMDX: true,
35
+ // HMR trigger file that will be updated when content changes
36
+ hmrTriggerFile: 'src/.lito-hmr-trigger.js',
37
+ },
38
+ next: {
39
+ name: 'next',
40
+ commands: {
41
+ dev: ['next', ['dev']],
42
+ build: ['next', ['build']],
43
+ },
44
+ contentDir: 'content',
45
+ publicDir: 'public',
46
+ configFile: 'next.config.js',
47
+ layoutInjection: false,
48
+ useMDX: true,
49
+ },
50
+ vue: {
51
+ name: 'vue',
52
+ commands: {
53
+ dev: ['vite', ['--host']],
54
+ build: ['vite', ['build']],
55
+ },
56
+ contentDir: 'src/content',
57
+ publicDir: 'public',
58
+ configFile: 'vite.config.js',
59
+ layoutInjection: false,
60
+ // HMR trigger file that will be updated when content changes
61
+ hmrTriggerFile: 'src/.lito-hmr-trigger.js',
62
+ },
63
+ };
64
+
65
+ /**
66
+ * Detect framework from template directory
67
+ * Checks for lito-manifest.json, template-manifest.json, or infers from config files
68
+ */
69
+ export async function detectFramework(projectDir) {
70
+ // Check for lito-manifest.json first (preferred)
71
+ const litoManifestPath = join(projectDir, 'lito-manifest.json');
72
+ if (await pathExists(litoManifestPath)) {
73
+ try {
74
+ const manifest = await readJson(litoManifestPath);
75
+ if (manifest.framework) {
76
+ return mergeFrameworkConfig(manifest.framework, manifest);
77
+ }
78
+ } catch {
79
+ // Fall through to other detection methods
80
+ }
81
+ }
82
+
83
+ // Check for template-manifest.json (legacy)
84
+ const templateManifestPath = join(projectDir, 'template-manifest.json');
85
+ if (await pathExists(templateManifestPath)) {
86
+ try {
87
+ const manifest = await readJson(templateManifestPath);
88
+ if (manifest.framework) {
89
+ return mergeFrameworkConfig(manifest.framework, manifest);
90
+ }
91
+ } catch {
92
+ // Fall through to inference
93
+ }
94
+ }
95
+
96
+ // Infer from config files
97
+ if (await pathExists(join(projectDir, 'astro.config.mjs')) ||
98
+ await pathExists(join(projectDir, 'astro.config.js'))) {
99
+ return DEFAULT_FRAMEWORK_CONFIGS.astro;
100
+ }
101
+
102
+ if (await pathExists(join(projectDir, 'next.config.js')) ||
103
+ await pathExists(join(projectDir, 'next.config.mjs')) ||
104
+ await pathExists(join(projectDir, 'next.config.ts'))) {
105
+ return DEFAULT_FRAMEWORK_CONFIGS.next;
106
+ }
107
+
108
+ // Check package.json for framework hints
109
+ const packageJsonPath = join(projectDir, 'package.json');
110
+ if (await pathExists(packageJsonPath)) {
111
+ try {
112
+ const packageJson = await readJson(packageJsonPath);
113
+ const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
114
+
115
+ if (deps['next']) return DEFAULT_FRAMEWORK_CONFIGS.next;
116
+ if (deps['astro']) return DEFAULT_FRAMEWORK_CONFIGS.astro;
117
+ if (deps['vue'] && deps['vite']) return DEFAULT_FRAMEWORK_CONFIGS.vue;
118
+ if (deps['react'] && deps['vite']) return DEFAULT_FRAMEWORK_CONFIGS.react;
119
+ } catch {
120
+ // Fall through to default
121
+ }
122
+ }
123
+
124
+ // Default to Astro (backward compatible)
125
+ return DEFAULT_FRAMEWORK_CONFIGS.astro;
126
+ }
127
+
128
+ /**
129
+ * Merge custom framework config with defaults
130
+ */
131
+ function mergeFrameworkConfig(frameworkName, manifest) {
132
+ const baseConfig = DEFAULT_FRAMEWORK_CONFIGS[frameworkName] || {
133
+ name: frameworkName,
134
+ contentDir: 'src/content',
135
+ publicDir: 'public',
136
+ layoutInjection: false,
137
+ };
138
+
139
+ return {
140
+ ...baseConfig,
141
+ ...manifest.frameworkConfig,
142
+ name: frameworkName,
143
+ // Allow manifest to override commands
144
+ commands: {
145
+ ...baseConfig.commands,
146
+ ...manifest.commands,
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Run the framework's dev server
153
+ */
154
+ export async function runFrameworkDev(projectDir, frameworkConfig, port = '4321') {
155
+ const { commands } = frameworkConfig;
156
+ const [binary, args] = commands.dev;
157
+
158
+ const devArgs = [...args];
159
+
160
+ // Add port based on framework
161
+ if (frameworkConfig.name === 'astro') {
162
+ devArgs.push('--port', port);
163
+ } else if (frameworkConfig.name === 'next') {
164
+ devArgs.push('-p', port);
165
+ } else {
166
+ // Vite-based frameworks
167
+ devArgs.push('--port', port);
168
+ }
169
+
170
+ await runBinary(projectDir, binary, devArgs);
171
+ }
172
+
173
+ /**
174
+ * Run the framework's build command
175
+ */
176
+ export async function runFrameworkBuild(projectDir, frameworkConfig) {
177
+ const { commands } = frameworkConfig;
178
+ const [binary, args] = commands.build;
179
+
180
+ await runBinary(projectDir, binary, args);
181
+ }
182
+
183
+ /**
184
+ * Get the output directory for the built site
185
+ */
186
+ export function getOutputDir(frameworkConfig) {
187
+ switch (frameworkConfig.name) {
188
+ case 'astro':
189
+ return 'dist';
190
+ case 'next':
191
+ return '.next';
192
+ case 'react':
193
+ case 'vue':
194
+ return 'dist';
195
+ default:
196
+ return 'dist';
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check if framework needs search indexing (pagefind)
202
+ */
203
+ export function needsSearchIndex(frameworkConfig) {
204
+ // Only static-output frameworks need pagefind
205
+ return ['astro', 'react', 'vue'].includes(frameworkConfig.name);
206
+ }
207
+
208
+ export { DEFAULT_FRAMEWORK_CONFIGS };