@litodocs/cli 1.2.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litodocs/cli",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Beautiful documentation sites from Markdown. Fast, simple, and open-source.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { devCommand } from "./commands/dev.js";
5
5
  import { ejectCommand } from "./commands/eject.js";
6
6
  import { initCommand } from "./commands/init.js";
7
7
  import { validateCommand } from "./commands/validate.js";
8
+ import { checkLinksCommand } from "./commands/check-links.js";
8
9
  import { previewCommand } from "./commands/preview.js";
9
10
  import { doctorCommand } from "./commands/doctor.js";
10
11
  import { infoCommand } from "./commands/info.js";
@@ -116,8 +117,21 @@ export async function cli() {
116
117
  .description("Validate docs-config.json configuration")
117
118
  .option("-i, --input <path>", "Path to the docs folder")
118
119
  .option("-q, --quiet", "Quiet mode for CI (exit code only)")
120
+ .option("--content", "Run content quality checks")
121
+ .option("--links", "Run broken link detection")
122
+ .option("--all", "Run all checks (config + content + links)")
123
+ .option("--strict", "Fail on warnings too (for CI)")
119
124
  .action(validateCommand);
120
125
 
126
+ // Check links
127
+ program
128
+ .command("check-links")
129
+ .description("Scan documentation for broken internal links")
130
+ .requiredOption("-i, --input <path>", "Path to the docs folder")
131
+ .option("--strict", "Exit with code 1 on broken links (CI)")
132
+ .option("-q, --quiet", "Quiet mode for CI (exit code only)")
133
+ .action(checkLinksCommand);
134
+
121
135
  // Preview production build
122
136
  program
123
137
  .command("preview")
@@ -0,0 +1,75 @@
1
+ import { resolve } from 'path';
2
+ import { intro, outro, log, spinner } from '@clack/prompts';
3
+ import pc from 'picocolors';
4
+ import { checkLinks } from '../core/link-checker.js';
5
+
6
+ /**
7
+ * Check links command — scan docs for broken internal links.
8
+ */
9
+ export async function checkLinksCommand(options) {
10
+ try {
11
+ const inputPath = options.input ? resolve(options.input) : process.cwd();
12
+
13
+ // Quiet mode for CI
14
+ if (options.quiet) {
15
+ const result = await checkLinks(inputPath);
16
+ process.exit(result.brokenLinks.length > 0 ? 1 : 0);
17
+ }
18
+
19
+ console.clear();
20
+ intro(pc.inverse(pc.cyan(' Lito - Check Links ')));
21
+
22
+ const s = spinner();
23
+ s.start('Scanning documentation for broken links...');
24
+
25
+ const result = await checkLinks(inputPath);
26
+
27
+ if (result.brokenLinks.length === 0) {
28
+ s.stop(pc.green('All links are valid!'));
29
+ log.message('');
30
+ log.success(`Checked ${pc.bold(result.totalLinks)} links across ${pc.bold(result.checkedFiles)} files`);
31
+ log.message('');
32
+ outro(pc.green('No broken links found!'));
33
+ return;
34
+ }
35
+
36
+ s.stop(pc.yellow(`Found ${result.brokenLinks.length} broken link(s)`));
37
+ log.message('');
38
+
39
+ // Group broken links by file
40
+ const byFile = new Map();
41
+ for (const bl of result.brokenLinks) {
42
+ if (!byFile.has(bl.file)) byFile.set(bl.file, []);
43
+ byFile.get(bl.file).push(bl);
44
+ }
45
+
46
+ for (const [file, links] of byFile) {
47
+ log.message(pc.bold(pc.cyan(file)));
48
+ for (const bl of links) {
49
+ const label = bl.text ? ` (${pc.dim(bl.text)})` : '';
50
+ log.message(` ${pc.red('✗')} ${bl.link}${label}`);
51
+ if (bl.suggestion) {
52
+ log.message(` ${pc.dim('Did you mean:')} ${pc.green(bl.suggestion)}`);
53
+ }
54
+ }
55
+ log.message('');
56
+ }
57
+
58
+ log.message(pc.dim('─'.repeat(50)));
59
+ log.message(`${pc.bold('Summary:')} ${pc.red(result.brokenLinks.length + ' broken')} out of ${result.totalLinks} links in ${result.checkedFiles} files`);
60
+ log.message('');
61
+
62
+ if (options.strict) {
63
+ outro(pc.red('Link check failed (strict mode)'));
64
+ process.exit(1);
65
+ } else {
66
+ outro(pc.yellow('Link check complete with warnings'));
67
+ }
68
+ } catch (error) {
69
+ log.error(pc.red(error.message));
70
+ if (error.stack && !options.quiet) {
71
+ log.error(pc.gray(error.stack));
72
+ }
73
+ process.exit(1);
74
+ }
75
+ }
@@ -3,6 +3,8 @@ import { resolve, join } from 'path';
3
3
  import { intro, outro, log, spinner } from '@clack/prompts';
4
4
  import pc from 'picocolors';
5
5
  import { validateConfig, isPortableConfig, getCoreConfigKeys, getExtensionKeys } from '../core/config-validator.js';
6
+ import { lintContent } from '../core/content-linter.js';
7
+ import { checkLinks } from '../core/link-checker.js';
6
8
 
7
9
  /**
8
10
  * Validate command - Validate docs-config.json
@@ -12,18 +14,41 @@ export async function validateCommand(options) {
12
14
  const inputPath = options.input ? resolve(options.input) : process.cwd();
13
15
  const configPath = join(inputPath, 'docs-config.json');
14
16
 
17
+ const runContent = options.content || options.all;
18
+ const runLinks = options.links || options.all;
19
+ const strict = options.strict || false;
20
+
15
21
  // Quick mode for CI - just exit with code
16
22
  if (options.quiet) {
23
+ let hasErrors = false;
24
+
25
+ // Config validation
17
26
  if (!existsSync(configPath)) {
18
27
  process.exit(1);
19
28
  }
20
29
  try {
21
30
  const config = JSON.parse(readFileSync(configPath, 'utf-8'));
22
31
  const result = validateConfig(config, inputPath, { silent: true });
23
- process.exit(result.valid ? 0 : 1);
32
+ if (!result.valid) hasErrors = true;
33
+
34
+ // Content linting
35
+ if (runContent) {
36
+ const lint = await lintContent(inputPath, { config });
37
+ const hasLintErrors = lint.issues.some(i => i.severity === 'error');
38
+ const hasLintWarnings = lint.issues.some(i => i.severity === 'warning');
39
+ if (hasLintErrors || (strict && hasLintWarnings)) hasErrors = true;
40
+ }
41
+
42
+ // Link checking
43
+ if (runLinks) {
44
+ const linkResult = await checkLinks(inputPath);
45
+ if (linkResult.brokenLinks.length > 0) hasErrors = true;
46
+ }
24
47
  } catch (e) {
25
- process.exit(1);
48
+ hasErrors = true;
26
49
  }
50
+
51
+ process.exit(hasErrors ? 1 : 0);
27
52
  }
28
53
 
29
54
  console.clear();
@@ -31,7 +56,7 @@ export async function validateCommand(options) {
31
56
 
32
57
  const s = spinner();
33
58
 
34
- // Check if config file exists
59
+ // ── Config validation ──
35
60
  s.start('Looking for docs-config.json...');
36
61
 
37
62
  if (!existsSync(configPath)) {
@@ -113,7 +138,89 @@ export async function validateCommand(options) {
113
138
  }
114
139
 
115
140
  log.message('');
116
- outro(pc.green('Validation complete!'));
141
+
142
+ // Track if any checks failed
143
+ let hasFailure = false;
144
+
145
+ // ── Content linting ──
146
+ if (runContent) {
147
+ log.message(pc.dim('─'.repeat(50)));
148
+ log.message('');
149
+ s.start('Linting documentation content...');
150
+
151
+ const lint = await lintContent(inputPath, { config });
152
+
153
+ const errors = lint.issues.filter(i => i.severity === 'error');
154
+ const warnings = lint.issues.filter(i => i.severity === 'warning');
155
+
156
+ if (lint.issues.length === 0) {
157
+ s.stop(pc.green(`Content is clean (${lint.totalFiles} files checked)`));
158
+ } else {
159
+ s.stop(pc.yellow(`Found ${lint.issues.length} issue(s) in ${lint.totalFiles} files`));
160
+ log.message('');
161
+
162
+ if (errors.length > 0) {
163
+ log.error(pc.bold(`Errors (${errors.length}):`));
164
+ for (const issue of errors) {
165
+ log.error(` ${pc.red('✗')} ${pc.cyan(issue.file)}: ${issue.message} ${pc.dim(`[${issue.rule}]`)}`);
166
+ }
167
+ log.message('');
168
+ hasFailure = true;
169
+ }
170
+
171
+ if (warnings.length > 0) {
172
+ log.warn(pc.bold(`Warnings (${warnings.length}):`));
173
+ for (const issue of warnings) {
174
+ log.warn(` ${pc.yellow('!')} ${pc.cyan(issue.file)}: ${issue.message} ${pc.dim(`[${issue.rule}]`)}`);
175
+ }
176
+ log.message('');
177
+ if (strict) hasFailure = true;
178
+ }
179
+ }
180
+ }
181
+
182
+ // ── Link checking ──
183
+ if (runLinks) {
184
+ log.message(pc.dim('─'.repeat(50)));
185
+ log.message('');
186
+ s.start('Checking for broken links...');
187
+
188
+ const linkResult = await checkLinks(inputPath);
189
+
190
+ if (linkResult.brokenLinks.length === 0) {
191
+ s.stop(pc.green(`All ${linkResult.totalLinks} links are valid (${linkResult.checkedFiles} files)`));
192
+ } else {
193
+ s.stop(pc.yellow(`Found ${linkResult.brokenLinks.length} broken link(s)`));
194
+ log.message('');
195
+
196
+ // Group by file
197
+ const byFile = new Map();
198
+ for (const bl of linkResult.brokenLinks) {
199
+ if (!byFile.has(bl.file)) byFile.set(bl.file, []);
200
+ byFile.get(bl.file).push(bl);
201
+ }
202
+
203
+ for (const [file, links] of byFile) {
204
+ log.message(` ${pc.bold(pc.cyan(file))}`);
205
+ for (const bl of links) {
206
+ const label = bl.text ? ` (${pc.dim(bl.text)})` : '';
207
+ log.message(` ${pc.red('✗')} ${bl.link}${label}`);
208
+ if (bl.suggestion) {
209
+ log.message(` ${pc.dim('Did you mean:')} ${pc.green(bl.suggestion)}`);
210
+ }
211
+ }
212
+ }
213
+ log.message('');
214
+ hasFailure = true;
215
+ }
216
+ }
217
+
218
+ if (hasFailure) {
219
+ outro(pc.red('Validation complete with errors'));
220
+ process.exit(1);
221
+ } else {
222
+ outro(pc.green('Validation complete!'));
223
+ }
117
224
  } catch (error) {
118
225
  log.error(pc.red(error.message));
119
226
  if (error.stack && !options.quiet) {
@@ -102,6 +102,94 @@ export async function generateConfig(projectDir, options, frameworkConfig = null
102
102
  }
103
103
  }
104
104
 
105
+ // Inject llms.txt integration into astro.config.mjs
106
+ if (frameworkName === 'astro' && config.integrations?.llmsTxt?.enabled && config.metadata?.url) {
107
+ const astroConfigPath = join(projectDir, "astro.config.mjs");
108
+ if (await pathExists(astroConfigPath)) {
109
+ let content = await readFile(astroConfigPath, "utf-8");
110
+
111
+ // Add import after the last existing import line
112
+ const importLine = `import astroLlmsTxt from '@4hse/astro-llms-txt';`;
113
+ if (!content.includes(importLine)) {
114
+ const lines = content.split('\n');
115
+ let lastImportIdx = 0;
116
+ for (let i = 0; i < lines.length; i++) {
117
+ if (lines[i].startsWith('import ')) lastImportIdx = i;
118
+ }
119
+ lines.splice(lastImportIdx + 1, 0, importLine);
120
+ content = lines.join('\n');
121
+ }
122
+
123
+ // Build the integration call
124
+ const llmsTitle = config.integrations.llmsTxt.title || config.metadata.name || 'Documentation';
125
+ const llmsDesc = config.integrations.llmsTxt.description || config.metadata.description || '';
126
+ const llmsConfig = ` astroLlmsTxt({
127
+ title: ${JSON.stringify(llmsTitle)},
128
+ description: ${JSON.stringify(llmsDesc)},
129
+ docSet: [
130
+ {
131
+ title: ${JSON.stringify(llmsTitle + ' - Full Documentation')},
132
+ description: ${JSON.stringify('Complete documentation content')},
133
+ url: '/llms-full.txt',
134
+ include: ['**'],
135
+ mainSelector: 'article',
136
+ ignoreSelectors: ['nav', '.sidebar', '.toc', 'footer', '.breadcrumbs'],
137
+ },
138
+ {
139
+ title: ${JSON.stringify(llmsTitle + ' - Structure')},
140
+ description: ${JSON.stringify('Documentation structure overview')},
141
+ url: '/llms-small.txt',
142
+ include: ['**'],
143
+ onlyStructure: true,
144
+ mainSelector: 'article',
145
+ ignoreSelectors: ['nav', '.sidebar', '.toc', 'footer', '.breadcrumbs'],
146
+ },
147
+ ],
148
+ }),`;
149
+
150
+ // Insert after sitemap() in integrations array
151
+ if (content.includes('sitemap(),')) {
152
+ content = content.replace(
153
+ 'sitemap(),',
154
+ `sitemap(),\n${llmsConfig}`
155
+ );
156
+ } else {
157
+ // Fallback: insert at start of integrations array
158
+ content = content.replace(
159
+ 'integrations: [',
160
+ `integrations: [\n${llmsConfig}`
161
+ );
162
+ }
163
+
164
+ await writeFile(astroConfigPath, content, "utf-8");
165
+ }
166
+ }
167
+
168
+ // Inject redirects into astro.config.mjs
169
+ if (frameworkName === 'astro' && config.redirects && Object.keys(config.redirects).length > 0) {
170
+ const astroConfigPath = join(projectDir, "astro.config.mjs");
171
+ if (await pathExists(astroConfigPath)) {
172
+ let content = await readFile(astroConfigPath, "utf-8");
173
+
174
+ // Build redirects object for Astro config
175
+ const redirectEntries = Object.entries(config.redirects).map(([source, dest]) => {
176
+ if (typeof dest === 'string') {
177
+ return ` '${source}': '${dest}'`;
178
+ }
179
+ return ` '${source}': { status: ${dest.status || 301}, destination: '${dest.destination}' }`;
180
+ });
181
+
182
+ const redirectsBlock = ` redirects: {\n${redirectEntries.join(',\n')}\n },`;
183
+
184
+ content = content.replace(
185
+ "export default defineConfig({",
186
+ `export default defineConfig({\n${redirectsBlock}`
187
+ );
188
+
189
+ await writeFile(astroConfigPath, content, "utf-8");
190
+ }
191
+ }
192
+
105
193
  // Update vite.config.js for React/Vue frameworks
106
194
  if (['react', 'vue'].includes(frameworkName) && baseUrl && baseUrl !== "/") {
107
195
  const viteConfigPath = join(projectDir, "vite.config.js");
@@ -0,0 +1,172 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { collectMarkdownFiles, parseFrontmatter, deriveSlug } from './doc-utils.js';
4
+
5
+ /**
6
+ * @typedef {'error' | 'warning'} Severity
7
+ * @typedef {{ rule: string, severity: Severity, file: string, message: string }} LintIssue
8
+ * @typedef {{ issues: LintIssue[], totalFiles: number }} LintResult
9
+ */
10
+
11
+ /**
12
+ * Run content quality checks on all markdown files in a docs folder.
13
+ *
14
+ * @param {string} docsPath - Root docs directory
15
+ * @param {object} [options]
16
+ * @param {object} [options.config] - Parsed docs-config.json (for orphan detection)
17
+ * @returns {Promise<LintResult>}
18
+ */
19
+ export async function lintContent(docsPath, options = {}) {
20
+ const files = await collectMarkdownFiles(docsPath);
21
+ /** @type {LintIssue[]} */
22
+ const issues = [];
23
+
24
+ // Pre-compute title map for duplicate detection
25
+ /** @type {Map<string, string[]>} title → list of relative paths */
26
+ const titleMap = new Map();
27
+
28
+ // Pre-compute sidebar slugs for orphan detection
29
+ const sidebarSlugs = new Set();
30
+ if (options.config?.navigation?.sidebar) {
31
+ collectSidebarSlugs(options.config.navigation.sidebar, sidebarSlugs);
32
+ }
33
+
34
+ for (const file of files) {
35
+ const content = readFileSync(file.absolutePath, 'utf-8');
36
+ const { data, body } = parseFrontmatter(content);
37
+ const slug = deriveSlug(file.relativePath);
38
+
39
+ // ── missing-title ──
40
+ if (!data.title) {
41
+ issues.push({
42
+ rule: 'missing-title',
43
+ severity: 'warning',
44
+ file: file.relativePath,
45
+ message: 'No title in frontmatter',
46
+ });
47
+ }
48
+
49
+ // ── missing-description ──
50
+ if (!data.description) {
51
+ issues.push({
52
+ rule: 'missing-description',
53
+ severity: 'warning',
54
+ file: file.relativePath,
55
+ message: 'No description in frontmatter',
56
+ });
57
+ }
58
+
59
+ // ── empty-page ──
60
+ if (!body || body.trim().length === 0) {
61
+ issues.push({
62
+ rule: 'empty-page',
63
+ severity: 'error',
64
+ file: file.relativePath,
65
+ message: 'Page has no content body',
66
+ });
67
+ }
68
+
69
+ // ── long-title ──
70
+ if (data.title && data.title.length > 70) {
71
+ issues.push({
72
+ rule: 'long-title',
73
+ severity: 'warning',
74
+ file: file.relativePath,
75
+ message: `Title is ${data.title.length} characters (recommended max: 70 for SEO)`,
76
+ });
77
+ }
78
+
79
+ // ── missing-image ──
80
+ // Check markdown image references: ![alt](path)
81
+ const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
82
+ let imgMatch;
83
+ while ((imgMatch = imageRegex.exec(content)) !== null) {
84
+ const imgPath = imgMatch[1].split(/[?#]/)[0]; // strip query/fragment
85
+ if (!imgPath || /^(https?:|data:)/.test(imgPath)) continue;
86
+
87
+ // Resolve relative to file's directory or to docs root for absolute paths
88
+ let resolvedPath;
89
+ if (imgPath.startsWith('/')) {
90
+ // Could be in _images, _assets, or public
91
+ const candidates = [
92
+ join(docsPath, '_images', imgPath.slice(1)),
93
+ join(docsPath, '_assets', imgPath.slice(1)),
94
+ join(docsPath, 'public', imgPath.slice(1)),
95
+ ];
96
+ if (!candidates.some(c => existsSync(c))) {
97
+ issues.push({
98
+ rule: 'missing-image',
99
+ severity: 'error',
100
+ file: file.relativePath,
101
+ message: `Referenced image not found: ${imgPath}`,
102
+ });
103
+ }
104
+ } else {
105
+ resolvedPath = join(dirname(file.absolutePath), imgPath);
106
+ if (!existsSync(resolvedPath)) {
107
+ issues.push({
108
+ rule: 'missing-image',
109
+ severity: 'error',
110
+ file: file.relativePath,
111
+ message: `Referenced image not found: ${imgPath}`,
112
+ });
113
+ }
114
+ }
115
+ }
116
+
117
+ // ── Collect titles for duplicate check ──
118
+ if (data.title) {
119
+ const normalizedTitle = data.title.toLowerCase().trim();
120
+ if (!titleMap.has(normalizedTitle)) {
121
+ titleMap.set(normalizedTitle, []);
122
+ }
123
+ titleMap.get(normalizedTitle).push(file.relativePath);
124
+ }
125
+
126
+ // ── orphaned-page ──
127
+ if (sidebarSlugs.size > 0) {
128
+ // Only check content pages, not index pages or API pages
129
+ if (!sidebarSlugs.has(slug)) {
130
+ issues.push({
131
+ rule: 'orphaned-page',
132
+ severity: 'warning',
133
+ file: file.relativePath,
134
+ message: `Page is not linked in sidebar navigation (slug: ${slug})`,
135
+ });
136
+ }
137
+ }
138
+ }
139
+
140
+ // ── duplicate-title (post-pass) ──
141
+ for (const [title, paths] of titleMap) {
142
+ if (paths.length > 1) {
143
+ for (const p of paths) {
144
+ issues.push({
145
+ rule: 'duplicate-title',
146
+ severity: 'warning',
147
+ file: p,
148
+ message: `Duplicate title "${title}" also in: ${paths.filter(x => x !== p).join(', ')}`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+
154
+ return { issues, totalFiles: files.length };
155
+ }
156
+
157
+ /**
158
+ * Recursively extract all href slugs from sidebar config.
159
+ */
160
+ function collectSidebarSlugs(groups, slugSet) {
161
+ for (const group of groups) {
162
+ if (!group.items) continue;
163
+ for (const item of group.items) {
164
+ if (item.href) {
165
+ slugSet.add(item.href);
166
+ }
167
+ if (item.items) {
168
+ collectSidebarSlugs([{ items: item.items }], slugSet);
169
+ }
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,123 @@
1
+ import { readdir, readFile, stat } from 'fs/promises';
2
+ import { join, relative, extname, sep } from 'path';
3
+
4
+ /** Folders that contain non-content files (assets, custom landing, etc.) */
5
+ const EXCLUDED_FOLDERS = ['_assets', '_css', '_images', '_static', '_landing', '_navbar', '_footer', 'public', 'node_modules'];
6
+
7
+ /** Files that are not doc pages */
8
+ const EXCLUDED_FILES = ['docs-config.json', 'vercel.json', 'netlify.toml', 'README.md'];
9
+
10
+ /**
11
+ * Recursively collect all .md and .mdx files under docsPath,
12
+ * respecting the same exclusion rules as sync.js.
13
+ *
14
+ * @param {string} docsPath - Root docs directory
15
+ * @returns {Promise<Array<{ absolutePath: string, relativePath: string }>>}
16
+ */
17
+ export async function collectMarkdownFiles(docsPath) {
18
+ const results = [];
19
+
20
+ async function walk(dir) {
21
+ const entries = await readdir(dir, { withFileTypes: true });
22
+
23
+ for (const entry of entries) {
24
+ const fullPath = join(dir, entry.name);
25
+ const rel = relative(docsPath, fullPath);
26
+ const topSegment = rel.split(sep)[0];
27
+
28
+ if (entry.isDirectory()) {
29
+ if (EXCLUDED_FOLDERS.includes(topSegment) || EXCLUDED_FOLDERS.includes(entry.name)) {
30
+ continue;
31
+ }
32
+ await walk(fullPath);
33
+ } else if (entry.isFile()) {
34
+ if (EXCLUDED_FILES.includes(entry.name)) continue;
35
+ const ext = extname(entry.name).toLowerCase();
36
+ if (ext === '.md' || ext === '.mdx') {
37
+ results.push({ absolutePath: fullPath, relativePath: rel });
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ await walk(docsPath);
44
+ return results;
45
+ }
46
+
47
+ /**
48
+ * Parse YAML-style frontmatter from markdown content.
49
+ * Returns { data: Record<string, string>, body: string }.
50
+ * Simple key: value parsing — handles title, description, etc.
51
+ *
52
+ * @param {string} content - Raw file content
53
+ * @returns {{ data: Record<string, any>, body: string }}
54
+ */
55
+ export function parseFrontmatter(content) {
56
+ if (!content.startsWith('---')) {
57
+ return { data: {}, body: content };
58
+ }
59
+
60
+ const endIdx = content.indexOf('---', 3);
61
+ if (endIdx === -1) {
62
+ return { data: {}, body: content };
63
+ }
64
+
65
+ const fmBlock = content.substring(3, endIdx).trim();
66
+ const body = content.substring(endIdx + 3).trim();
67
+ const data = {};
68
+
69
+ for (const line of fmBlock.split('\n')) {
70
+ const colonIdx = line.indexOf(':');
71
+ if (colonIdx === -1) continue;
72
+ const key = line.substring(0, colonIdx).trim();
73
+ let value = line.substring(colonIdx + 1).trim();
74
+
75
+ // Strip surrounding quotes
76
+ if ((value.startsWith('"') && value.endsWith('"')) ||
77
+ (value.startsWith("'") && value.endsWith("'"))) {
78
+ value = value.slice(1, -1);
79
+ }
80
+
81
+ if (key) data[key] = value;
82
+ }
83
+
84
+ return { data, body };
85
+ }
86
+
87
+ /**
88
+ * Convert a relative file path to a URL slug.
89
+ *
90
+ * Examples:
91
+ * "getting-started/installation.md" → "/getting-started/installation"
92
+ * "introduction/index.mdx" → "/introduction"
93
+ * "index.md" → "/"
94
+ *
95
+ * @param {string} relativePath - Path relative to docsPath
96
+ * @returns {string}
97
+ */
98
+ export function deriveSlug(relativePath) {
99
+ let slug = relativePath
100
+ .replace(/\.(md|mdx)$/, '')
101
+ .split(sep)
102
+ .join('/');
103
+
104
+ // Remove trailing /index
105
+ if (slug.endsWith('/index')) {
106
+ slug = slug.slice(0, -6);
107
+ }
108
+ if (slug === 'index') {
109
+ slug = '';
110
+ }
111
+
112
+ return '/' + slug;
113
+ }
114
+
115
+ /**
116
+ * Check if a URL is external (http/https, mailto, tel, etc.)
117
+ *
118
+ * @param {string} url
119
+ * @returns {boolean}
120
+ */
121
+ export function isExternalUrl(url) {
122
+ return /^(https?:|mailto:|tel:|ftp:)/.test(url);
123
+ }
@@ -13,6 +13,46 @@ const { copy, ensureDir, readFile, writeFile, pathExists, readJson } = pkg;
13
13
  import { join, relative, basename, extname } from 'path';
14
14
  import { readdir } from 'fs/promises';
15
15
 
16
+ /**
17
+ * Add is:inline to all <script> and <style> tags in HTML so Astro ships
18
+ * them as-is. Without this, Astro treats scripts as ES modules (scoping
19
+ * declarations, breaking onclick handlers) and scopes styles (breaking
20
+ * global CSS like :root variables, animations, etc.).
21
+ */
22
+ function inlineForAstro(html) {
23
+ // Add is:inline to <script> tags that don't already have it
24
+ html = html.replace(/<script(?![^>]*is:inline)([^>]*>)/gi, '<script is:inline$1');
25
+ // Add is:inline to <style> tags that don't already have is:inline or is:global
26
+ html = html.replace(/<style(?![^>]*is:(?:inline|global))([^>]*>)/gi, '<style is:inline$1');
27
+ return html;
28
+ }
29
+
30
+ /**
31
+ * Check if HTML is a full document (has <html> or <!doctype>).
32
+ * If so, extract head content, body content, and html/body attributes
33
+ * so we can merge them into the Astro template properly.
34
+ */
35
+ function parseFullHtmlDocument(html) {
36
+ const isFullDoc = /<!doctype\s+html|<html[\s>]/i.test(html);
37
+ if (!isFullDoc) return null;
38
+
39
+ // Extract <html> tag attributes
40
+ const htmlTagMatch = html.match(/<html([^>]*)>/i);
41
+ const htmlAttrs = htmlTagMatch ? htmlTagMatch[1].trim() : '';
42
+
43
+ // Extract <head> inner content
44
+ const headMatch = html.match(/<head[^>]*>([\s\S]*)<\/head>/i);
45
+ const headContent = headMatch ? headMatch[1].trim() : '';
46
+
47
+ // Extract <body> tag attributes and inner content
48
+ const bodyTagMatch = html.match(/<body([^>]*)>/i);
49
+ const bodyAttrs = bodyTagMatch ? bodyTagMatch[1].trim() : '';
50
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
51
+ const bodyContent = bodyMatch ? bodyMatch[1].trim() : '';
52
+
53
+ return { htmlAttrs, headContent, bodyContent, bodyAttrs };
54
+ }
55
+
16
56
  /**
17
57
  * Landing page types
18
58
  */
@@ -309,6 +349,9 @@ async function generateAstroLanding(projectDir, landingData) {
309
349
 
310
350
  let htmlContent = await readFile(join(sourcePath, mainHtml), 'utf-8');
311
351
 
352
+ // Make all <script> tags in the user's HTML pass through Astro untouched
353
+ htmlContent = inlineForAstro(htmlContent);
354
+
312
355
  // Read CSS files and write to a separate file
313
356
  let cssContent = '';
314
357
  for (const cssFile of cssFiles) {
@@ -327,7 +370,36 @@ async function generateAstroLanding(projectDir, landingData) {
327
370
  jsContent += `// ${jsFile}\n${js}\n\n`;
328
371
  }
329
372
 
330
- // Determine header/footer: hidden ('__hidden__'), custom (string HTML), or default (null)
373
+ // Check if the user's HTML is a full document (has <html>, <head>, <body>)
374
+ const parsed = parseFullHtmlDocument(htmlContent);
375
+
376
+ let astroContent;
377
+
378
+ if (parsed) {
379
+ // Full HTML document: merge the user's head/body into the Astro page
380
+ // instead of nesting an entire HTML document inside another one.
381
+ astroContent = generateAstroFromFullDoc(parsed, { cssFiles, jsContent, navbarContent, footerContent });
382
+ } else {
383
+ // HTML fragment: wrap it in a full Astro page
384
+ astroContent = generateAstroFromFragment(htmlContent, { jsContent, navbarContent, footerContent });
385
+ }
386
+
387
+ // Write to index.astro
388
+ const indexPath = join(projectDir, 'src', 'pages', 'index.astro');
389
+ await writeFile(indexPath, astroContent, 'utf-8');
390
+
391
+ // Copy assets if they exist
392
+ await copyLandingAssets(sourcePath, projectDir);
393
+ }
394
+
395
+ /**
396
+ * Generate Astro page from a full HTML document.
397
+ * Extracts <head> and <body> content, preserves the user's structure.
398
+ */
399
+ function generateAstroFromFullDoc(parsed, { cssFiles, jsContent, navbarContent, footerContent }) {
400
+ const { htmlAttrs, headContent, bodyContent, bodyAttrs } = parsed;
401
+
402
+ // Determine header/footer rendering
331
403
  const navbarIsHidden = navbarContent === '__hidden__';
332
404
  const footerIsHidden = footerContent === '__hidden__';
333
405
  const hasCustomNavbar = !navbarIsHidden && !!navbarContent;
@@ -338,16 +410,64 @@ async function generateAstroLanding(projectDir, landingData) {
338
410
  const headerRender = navbarIsHidden
339
411
  ? ''
340
412
  : hasCustomNavbar
341
- ? `<header class="landing-custom-navbar">\n ${navbarContent}\n </header>`
413
+ ? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
342
414
  : '<Header />';
343
415
  const footerRender = footerIsHidden
344
416
  ? ''
345
417
  : hasCustomFooter
346
- ? `<footer class="landing-custom-footer">\n ${footerContent}\n </footer>`
418
+ ? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
347
419
  : '<Footer />';
348
420
 
349
- // Generate standalone Astro component
350
- const astroContent = `---
421
+ return `---
422
+ // Custom landing page - auto-generated by Lito CLI
423
+ // Source: _landing/ folder (full HTML document)
424
+ import '../styles/landing.css';
425
+ ${headerImport}
426
+ ${footerImport}
427
+ ---
428
+
429
+ <!doctype html>
430
+ <html ${htmlAttrs}>
431
+ <head>
432
+ ${headContent}
433
+ </head>
434
+ <body ${bodyAttrs}>
435
+ ${headerRender}
436
+
437
+ ${bodyContent}
438
+
439
+ ${footerRender}
440
+
441
+ ${jsContent ? `<script is:inline>\n${jsContent}\n</script>` : ''}
442
+ </body>
443
+ </html>
444
+ `;
445
+ }
446
+
447
+ /**
448
+ * Generate Astro page from an HTML fragment.
449
+ * Wraps it in a full Astro page with Lito's defaults.
450
+ */
451
+ function generateAstroFromFragment(htmlContent, { jsContent, navbarContent, footerContent }) {
452
+ const navbarIsHidden = navbarContent === '__hidden__';
453
+ const footerIsHidden = footerContent === '__hidden__';
454
+ const hasCustomNavbar = !navbarIsHidden && !!navbarContent;
455
+ const hasCustomFooter = !footerIsHidden && !!footerContent;
456
+
457
+ const headerImport = navbarIsHidden || hasCustomNavbar ? '' : "import Header from '../components/Header.astro';";
458
+ const footerImport = footerIsHidden || hasCustomFooter ? '' : "import Footer from '../components/Footer.astro';";
459
+ const headerRender = navbarIsHidden
460
+ ? ''
461
+ : hasCustomNavbar
462
+ ? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
463
+ : '<Header />';
464
+ const footerRender = footerIsHidden
465
+ ? ''
466
+ : hasCustomFooter
467
+ ? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
468
+ : '<Footer />';
469
+
470
+ return `---
351
471
  // Custom landing page - auto-generated by Lito CLI
352
472
  // Source: _landing/ folder
353
473
  import '../styles/global.css';
@@ -388,17 +508,10 @@ const config = await getConfigFile();
388
508
 
389
509
  ${footerRender}
390
510
 
391
- ${jsContent ? `<script>\n${jsContent}\n</script>` : ''}
511
+ ${jsContent ? `<script is:inline>\n${jsContent}\n</script>` : ''}
392
512
  </body>
393
513
  </html>
394
514
  `;
395
-
396
- // Write to index.astro
397
- const indexPath = join(projectDir, 'src', 'pages', 'index.astro');
398
- await writeFile(indexPath, astroContent, 'utf-8');
399
-
400
- // Copy assets if they exist
401
- await copyLandingAssets(sourcePath, projectDir);
402
515
  }
403
516
 
404
517
  /**
@@ -708,12 +821,12 @@ async function generateAstroSectionsLanding(projectDir, landingData) {
708
821
  const headerRender = navbarIsHidden
709
822
  ? ''
710
823
  : hasCustomNavbar
711
- ? `<header class="landing-custom-navbar">\n ${navbarContent}\n </header>`
824
+ ? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
712
825
  : '<Header />';
713
826
  const footerRender = footerIsHidden
714
827
  ? ''
715
828
  : hasCustomFooter
716
- ? `<footer class="landing-custom-footer">\n ${footerContent}\n </footer>`
829
+ ? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
717
830
  : '<Footer />';
718
831
 
719
832
  const astroContent = `---
@@ -0,0 +1,172 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { collectMarkdownFiles, deriveSlug, isExternalUrl } from './doc-utils.js';
4
+
5
+ /**
6
+ * @typedef {{ file: string, link: string, text: string, suggestion?: string }} BrokenLink
7
+ * @typedef {{ brokenLinks: BrokenLink[], totalLinks: number, checkedFiles: number }} CheckResult
8
+ */
9
+
10
+ /**
11
+ * Scan all markdown files for broken internal links.
12
+ *
13
+ * @param {string} docsPath - Root docs directory
14
+ * @param {object} [options]
15
+ * @returns {Promise<CheckResult>}
16
+ */
17
+ export async function checkLinks(docsPath, options = {}) {
18
+ const files = await collectMarkdownFiles(docsPath);
19
+
20
+ // Build a set of all known slugs for resolution
21
+ const knownSlugs = new Set();
22
+ for (const file of files) {
23
+ knownSlugs.add(deriveSlug(file.relativePath));
24
+ }
25
+
26
+ /** @type {BrokenLink[]} */
27
+ const brokenLinks = [];
28
+ let totalLinks = 0;
29
+
30
+ // Regex patterns for extracting links
31
+ // Markdown links (excluding images which start with !)
32
+ const mdLinkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
33
+ // HTML/JSX href attributes
34
+ const hrefRegex = /href=["']([^"']+)["']/g;
35
+
36
+ for (const file of files) {
37
+ const content = readFileSync(file.absolutePath, 'utf-8');
38
+ const links = [];
39
+
40
+ // Extract markdown links
41
+ let match;
42
+ while ((match = mdLinkRegex.exec(content)) !== null) {
43
+ links.push({ text: match[1], url: match[2] });
44
+ }
45
+ mdLinkRegex.lastIndex = 0;
46
+
47
+ // Extract href links
48
+ while ((match = hrefRegex.exec(content)) !== null) {
49
+ links.push({ text: '', url: match[1] });
50
+ }
51
+ hrefRegex.lastIndex = 0;
52
+
53
+ for (const { text, url } of links) {
54
+ // Skip non-checkable links
55
+ if (!url) continue;
56
+ if (isExternalUrl(url)) continue;
57
+ if (url.startsWith('#')) continue; // anchor-only
58
+ if (url.startsWith('{') || url.includes('${')) continue; // template vars
59
+ if (url.startsWith('data:')) continue;
60
+
61
+ totalLinks++;
62
+
63
+ // Strip anchor fragment and query string
64
+ const cleanUrl = url.split(/[?#]/)[0];
65
+ if (!cleanUrl) continue;
66
+
67
+ // Normalize: ensure leading slash
68
+ const slug = cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl;
69
+
70
+ // Check if slug resolves to a known page
71
+ if (resolveSlug(slug, knownSlugs, docsPath)) continue;
72
+
73
+ // Check if it's a static asset (in _assets, _images, public)
74
+ if (resolveStaticAsset(slug, docsPath)) continue;
75
+
76
+ // Broken link — try to suggest a fix
77
+ const suggestion = findClosestSlug(slug, knownSlugs);
78
+
79
+ brokenLinks.push({
80
+ file: file.relativePath,
81
+ link: url,
82
+ text,
83
+ ...(suggestion ? { suggestion } : {}),
84
+ });
85
+ }
86
+ }
87
+
88
+ return { brokenLinks, totalLinks, checkedFiles: files.length };
89
+ }
90
+
91
+ /**
92
+ * Try to resolve a slug against known slugs.
93
+ * Checks exact match, with/without trailing slash, and index variants.
94
+ */
95
+ function resolveSlug(slug, knownSlugs, docsPath) {
96
+ // Normalize trailing slash
97
+ const normalized = slug.endsWith('/') ? slug.slice(0, -1) : slug;
98
+ if (!normalized) return knownSlugs.has('/'); // root
99
+
100
+ if (knownSlugs.has(normalized)) return true;
101
+
102
+ // Maybe it's linking to a directory with an index file
103
+ if (knownSlugs.has(normalized + '/index')) return true;
104
+
105
+ // Check if the raw file exists (.md/.mdx)
106
+ const withoutSlash = normalized.startsWith('/') ? normalized.slice(1) : normalized;
107
+ const candidates = [
108
+ join(docsPath, withoutSlash + '.md'),
109
+ join(docsPath, withoutSlash + '.mdx'),
110
+ join(docsPath, withoutSlash, 'index.md'),
111
+ join(docsPath, withoutSlash, 'index.mdx'),
112
+ ];
113
+
114
+ return candidates.some(c => existsSync(c));
115
+ }
116
+
117
+ /**
118
+ * Check if a path resolves to a static asset.
119
+ */
120
+ function resolveStaticAsset(slug, docsPath) {
121
+ const withoutSlash = slug.startsWith('/') ? slug.slice(1) : slug;
122
+ const candidates = [
123
+ join(docsPath, 'public', withoutSlash),
124
+ join(docsPath, '_assets', withoutSlash),
125
+ join(docsPath, '_images', withoutSlash),
126
+ ];
127
+ return candidates.some(c => existsSync(c));
128
+ }
129
+
130
+ /**
131
+ * Find the closest matching slug using simple string similarity (Levenshtein-like).
132
+ *
133
+ * @param {string} target
134
+ * @param {Set<string>} knownSlugs
135
+ * @returns {string|null}
136
+ */
137
+ function findClosestSlug(target, knownSlugs) {
138
+ let bestMatch = null;
139
+ let bestScore = Infinity;
140
+
141
+ for (const slug of knownSlugs) {
142
+ const dist = levenshtein(target, slug);
143
+ if (dist < bestScore && dist <= Math.max(target.length, slug.length) * 0.4) {
144
+ bestScore = dist;
145
+ bestMatch = slug;
146
+ }
147
+ }
148
+
149
+ return bestMatch;
150
+ }
151
+
152
+ /**
153
+ * Simple Levenshtein distance.
154
+ */
155
+ function levenshtein(a, b) {
156
+ const m = a.length;
157
+ const n = b.length;
158
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
159
+
160
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
161
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
162
+
163
+ for (let i = 1; i <= m; i++) {
164
+ for (let j = 1; j <= n; j++) {
165
+ dp[i][j] = a[i - 1] === b[j - 1]
166
+ ? dp[i - 1][j - 1]
167
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
168
+ }
169
+ }
170
+
171
+ return dp[m][n];
172
+ }