diff --git a/Dockerfile b/Dockerfile index aa2d507..701b8a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,22 +13,23 @@ WORKDIR /app/packages/chronicle COPY --from=deps /app /app COPY packages/chronicle ./ RUN bun build-cli.ts +RUN chmod +x bin/chronicle.js +RUN ln -s /app/packages/chronicle/bin/chronicle.js /usr/local/bin/chronicle + +# --- init project --- +WORKDIR /docs +RUN bun add /app/packages/chronicle +RUN chronicle init # --- runner --- FROM base AS runner -WORKDIR /app/packages/chronicle - -COPY --from=builder /app /app +WORKDIR /docs -RUN chmod +x bin/chronicle.js +COPY --from=builder /docs /docs +COPY --from=builder /app/packages/chronicle /app/packages/chronicle RUN ln -s /app/packages/chronicle/bin/chronicle.js /usr/local/bin/chronicle -RUN mkdir -p /app/content && ln -s /app/content /app/packages/chronicle/content - -VOLUME /app/content - -ENV CHRONICLE_CONTENT_DIR=./content -WORKDIR /app/packages/chronicle +VOLUME /docs/content EXPOSE 3000 diff --git a/docs/cli.mdx b/docs/cli.mdx index 74b8d9b..84d87d4 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -10,7 +10,7 @@ Chronicle provides a CLI to initialize, develop, build, and serve your documenta ## init -Scaffold a new Chronicle project. +Initialize a new Chronicle project. Must be run before other commands. ```bash chronicle init [options] @@ -18,13 +18,20 @@ chronicle init [options] | Flag | Description | Default | |------|-------------|---------| -| `-d, --dir ` | Target directory | `.` (current directory) | +| `-c, --content ` | Content directory name | `content` | -Creates a `chronicle.yaml` config file and a sample `index.mdx` in the specified directory. +This creates: +- `chronicle.yaml` — site configuration +- `content/` (or custom name) — content directory with a sample `index.mdx` +- `package.json` — with `@raystack/chronicle` dependency (if not exists) +- `.chronicle/` — scaffolded build directory +- `.gitignore` — with `.chronicle` entry + +If the content directory already exists and has files, the sample `index.mdx` is skipped. ## dev -Start the development server with hot reload. +Start the development server with hot reload. Requires `chronicle init` first. ```bash chronicle dev [options] @@ -33,20 +40,15 @@ chronicle dev [options] | Flag | Description | Default | |------|-------------|---------| | `-p, --port ` | Port number | `3000` | -| `-c, --content ` | Content directory | Current directory | ## build -Build the site for production. +Build the site for production. Requires `chronicle init` first. ```bash -chronicle build [options] +chronicle build ``` -| Flag | Description | Default | -|------|-------------|---------| -| `-c, --content ` | Content directory | Current directory | - ## start Start the production server. Requires a prior `chronicle build`. @@ -58,11 +60,10 @@ chronicle start [options] | Flag | Description | Default | |------|-------------|---------| | `-p, --port ` | Port number | `3000` | -| `-c, --content ` | Content directory | Current directory | ## serve -Build and start the production server in one step. +Build and start the production server in one step. Requires `chronicle init` first. ```bash chronicle serve [options] @@ -71,14 +72,10 @@ chronicle serve [options] | Flag | Description | Default | |------|-------------|---------| | `-p, --port ` | Port number | `3000` | -| `-c, --content ` | Content directory | Current directory | - -## Content Directory Resolution -The content directory is resolved in this order: +## Config Resolution -1. `--content` CLI flag (highest priority) -2. `CHRONICLE_CONTENT_DIR` environment variable -3. Current working directory (default) +`chronicle.yaml` is resolved in this order: -The content directory must contain a `chronicle.yaml` file. +1. Current working directory +2. Content directory diff --git a/docs/docker.mdx b/docs/docker.mdx index 03d3def..4e0a252 100644 --- a/docs/docker.mdx +++ b/docs/docker.mdx @@ -19,23 +19,23 @@ docker pull raystack/chronicle The default command builds and starts a production server on port 3000. ```bash -docker run -p 3000:3000 -v ./content:/app/content raystack/chronicle +docker run -p 3000:3000 -v ./content:/docs/content raystack/chronicle ``` -Mount your content directory (containing `chronicle.yaml` and MDX files) to `/app/content`. +Mount your content directory (containing MDX files) to `/docs/content`. Place `chronicle.yaml` in the content directory or it will use defaults. ## Development Server Override the default command with `dev` for hot reload: ```bash -docker run -p 3000:3000 -v ./content:/app/content raystack/chronicle dev --port 3000 +docker run -p 3000:3000 -v ./content:/docs/content raystack/chronicle dev --port 3000 ``` ## Custom Port ```bash -docker run -p 8080:8080 -v ./content:/app/content raystack/chronicle serve --port 8080 +docker run -p 8080:8080 -v ./content:/docs/content raystack/chronicle serve --port 8080 ``` ## Docker Compose @@ -47,7 +47,7 @@ services: ports: - "3000:3000" volumes: - - ./content:/app/content + - ./content:/docs/content ``` ## Available Commands @@ -56,11 +56,11 @@ The entrypoint is `chronicle`, so any CLI command can be passed: ```bash # Build only -docker run -v ./content:/app/content raystack/chronicle build +docker run -v ./content:/docs/content raystack/chronicle build # Start pre-built server -docker run -p 3000:3000 -v ./content:/app/content raystack/chronicle start --port 3000 +docker run -p 3000:3000 -v ./content:/docs/content raystack/chronicle start --port 3000 # Build and start (default) -docker run -p 3000:3000 -v ./content:/app/content raystack/chronicle serve --port 3000 +docker run -p 3000:3000 -v ./content:/docs/content raystack/chronicle serve --port 3000 ``` diff --git a/docs/index.mdx b/docs/index.mdx index c02a328..5baaa03 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -24,7 +24,15 @@ chronicle init This creates: - `chronicle.yaml` — your site configuration -- `index.mdx` — a sample documentation page +- `content/` — content directory with a sample `index.mdx` +- `package.json` — with `@raystack/chronicle` dependency +- `.chronicle/` — scaffolded build directory (gitignored) + +To use an existing directory as content (e.g. `docs/`): + +```bash +chronicle init -c docs +``` ### 2. Start the development server @@ -39,13 +47,15 @@ Your docs site is now running at [http://localhost:3000](http://localhost:3000). Create `.mdx` files in your content directory. Each file becomes a page. Use folders to create nested navigation. ``` -content/ +my-docs/ ├── chronicle.yaml -├── index.mdx -├── getting-started.mdx -└── guides/ - ├── installation.mdx - └── configuration.mdx +├── content/ +│ ├── index.mdx +│ ├── getting-started.mdx +│ └── guides/ +│ ├── installation.mdx +│ └── configuration.mdx +└── .chronicle/ # generated, gitignored ``` ### 4. Build for production @@ -68,9 +78,11 @@ A minimal Chronicle project looks like: ``` my-docs/ ├── chronicle.yaml # Site configuration -├── index.mdx # Home page -└── guides/ - └── setup.mdx # Nested page at /guides/setup +├── content/ +│ ├── index.mdx # Home page +│ └── guides/ +│ └── setup.mdx # Nested page at /guides/setup +└── .chronicle/ # Generated by init, gitignored ``` All configuration is done through `chronicle.yaml`. No additional config files needed. diff --git a/packages/chronicle/bin/chronicle.js b/packages/chronicle/bin/chronicle.js old mode 100644 new mode 100755 diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 970edbd..6f508a7 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -10,7 +10,8 @@ "src", "templates", "next.config.mjs", - "source.config.ts" + "source.config.ts", + "tsconfig.json" ], "bin": { "chronicle": "./bin/chronicle.js" @@ -27,7 +28,6 @@ "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", - "openapi-types": "^12.1.3", "semver": "^7.7.4", "typescript": "5.9.3" }, @@ -56,6 +56,7 @@ "slugify": "^1.6.6", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", + "openapi-types": "^12.1.3", "yaml": "^2.8.2", "zod": "^4.3.6" } diff --git a/packages/chronicle/src/cli/commands/build.ts b/packages/chronicle/src/cli/commands/build.ts index ef11356..3796fa2 100644 --- a/packages/chronicle/src/cli/commands/build.ts +++ b/packages/chronicle/src/cli/commands/build.ts @@ -1,29 +1,30 @@ import { Command } from 'commander' import { spawn } from 'child_process' import path from 'path' -import { fileURLToPath } from 'url' +import fs from 'fs' import chalk from 'chalk' -import { resolveContentDir, loadCLIConfig, attachLifecycleHandlers } from '@/cli/utils' - -const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') -const nextBin = path.join(PACKAGE_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'next.cmd' : 'next') +import { attachLifecycleHandlers, resolveNextCli } from '@/cli/utils' export const buildCommand = new Command('build') .description('Build for production') - .option('-c, --content ', 'Content directory') - .action((options) => { - const contentDir = resolveContentDir(options.content) - loadCLIConfig(contentDir) + .action(() => { + const scaffoldPath = path.join(process.cwd(), '.chronicle') + if (!fs.existsSync(scaffoldPath)) { + console.log(chalk.red('Error: .chronicle/ not found. Run'), chalk.cyan('chronicle init'), chalk.red('first.')) + process.exit(1) + } + + const nextCli = resolveNextCli() console.log(chalk.cyan('Building for production...')) - console.log(chalk.gray(`Content: ${contentDir}`)) - const child = spawn(nextBin, ['build'], { + const child = spawn(process.execPath, [nextCli, 'build'], { stdio: 'inherit', - cwd: PACKAGE_ROOT, + cwd: scaffoldPath, env: { ...process.env, - CHRONICLE_CONTENT_DIR: contentDir, + CHRONICLE_PROJECT_ROOT: process.cwd(), + CHRONICLE_CONTENT_DIR: './content', }, }) diff --git a/packages/chronicle/src/cli/commands/dev.ts b/packages/chronicle/src/cli/commands/dev.ts index 513d2d9..8a482e8 100644 --- a/packages/chronicle/src/cli/commands/dev.ts +++ b/packages/chronicle/src/cli/commands/dev.ts @@ -1,30 +1,31 @@ import { Command } from 'commander' import { spawn } from 'child_process' import path from 'path' -import { fileURLToPath } from 'url' +import fs from 'fs' import chalk from 'chalk' -import { resolveContentDir, loadCLIConfig, attachLifecycleHandlers } from '@/cli/utils' - -const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') -const nextBin = path.join(PACKAGE_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'next.cmd' : 'next') +import { attachLifecycleHandlers, resolveNextCli } from '@/cli/utils' export const devCommand = new Command('dev') .description('Start development server') .option('-p, --port ', 'Port number', '3000') - .option('-c, --content ', 'Content directory') .action((options) => { - const contentDir = resolveContentDir(options.content) - loadCLIConfig(contentDir) + const scaffoldPath = path.join(process.cwd(), '.chronicle') + if (!fs.existsSync(scaffoldPath)) { + console.log(chalk.red('Error: .chronicle/ not found. Run'), chalk.cyan('chronicle init'), chalk.red('first.')) + process.exit(1) + } + + const nextCli = resolveNextCli() console.log(chalk.cyan('Starting dev server...')) - console.log(chalk.gray(`Content: ${contentDir}`)) - const child = spawn(nextBin, ['dev', '-p', options.port], { + const child = spawn(process.execPath, [nextCli, 'dev', '-p', options.port], { stdio: 'inherit', - cwd: PACKAGE_ROOT, + cwd: scaffoldPath, env: { ...process.env, - CHRONICLE_CONTENT_DIR: contentDir, + CHRONICLE_PROJECT_ROOT: process.cwd(), + CHRONICLE_CONTENT_DIR: './content', }, }) diff --git a/packages/chronicle/src/cli/commands/init.ts b/packages/chronicle/src/cli/commands/init.ts index 212a75f..1109055 100644 --- a/packages/chronicle/src/cli/commands/init.ts +++ b/packages/chronicle/src/cli/commands/init.ts @@ -1,9 +1,12 @@ import { Command } from 'commander' +import { execSync } from 'child_process' import fs from 'fs' import path from 'path' import chalk from 'chalk' import { stringify } from 'yaml' import type { ChronicleConfig } from '@/types' +import { loadCLIConfig, scaffoldDir, detectPackageManager } from '@/cli/utils' + function createConfig(): ChronicleConfig { return { @@ -14,6 +17,29 @@ function createConfig(): ChronicleConfig { } } +function createPackageJson(name: string): Record { + return { + name, + private: true, + type: 'module', + scripts: { + dev: 'chronicle dev', + build: 'chronicle build', + start: 'chronicle start', + }, + dependencies: { + '@raystack/chronicle': 'latest', + }, + devDependencies: { + '@raystack/tools-config': '0.56.0', + 'openapi-types': '^12.1.3', + typescript: '5.9.3', + '@types/react': '^19.2.10', + '@types/node': '^25.1.0', + }, + } +} + const sampleMdx = `--- title: Welcome description: Getting started with your documentation @@ -27,18 +53,71 @@ This is your documentation home page. export const initCommand = new Command('init') .description('Initialize a new Chronicle project') - .option('-d, --dir ', 'Content directory', '.') + .option('-c, --content ', 'Content directory name', 'content') .action((options) => { - const contentDir = path.resolve(options.dir) + const projectDir = process.cwd() + const dirName = path.basename(projectDir) || 'docs' + const contentDir = path.join(projectDir, options.content) - // Create content directory + // Create content directory if it doesn't exist if (!fs.existsSync(contentDir)) { fs.mkdirSync(contentDir, { recursive: true }) console.log(chalk.green('✓'), 'Created', contentDir) } - // Create chronicle.yaml - const configPath = path.join(contentDir, 'chronicle.yaml') + // Create or update package.json in project root + const packageJsonPath = path.join(projectDir, 'package.json') + if (!fs.existsSync(packageJsonPath)) { + fs.writeFileSync(packageJsonPath, JSON.stringify(createPackageJson(dirName), null, 2) + '\n') + console.log(chalk.green('✓'), 'Created', packageJsonPath) + } else { + const existing = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) + const template = createPackageJson(dirName) + let updated = false + + // Set type to module + if (existing.type !== 'module') { + existing.type = 'module' + updated = true + } + + // Merge missing scripts + if (!existing.scripts) existing.scripts = {} + for (const [key, value] of Object.entries(template.scripts as Record)) { + if (!existing.scripts[key]) { + existing.scripts[key] = value + updated = true + } + } + + // Merge missing dependencies + if (!existing.dependencies) existing.dependencies = {} + for (const [key, value] of Object.entries(template.dependencies as Record)) { + if (!existing.dependencies[key]) { + existing.dependencies[key] = value + updated = true + } + } + + // Merge missing devDependencies + if (!existing.devDependencies) existing.devDependencies = {} + for (const [key, value] of Object.entries(template.devDependencies as Record)) { + if (!existing.devDependencies[key]) { + existing.devDependencies[key] = value + updated = true + } + } + + if (updated) { + fs.writeFileSync(packageJsonPath, JSON.stringify(existing, null, 2) + '\n') + console.log(chalk.green('✓'), 'Updated', packageJsonPath, 'with missing scripts/deps') + } else { + console.log(chalk.yellow('⚠'), packageJsonPath, 'already has all required entries') + } + } + + // Create chronicle.yaml in project root + const configPath = path.join(projectDir, 'chronicle.yaml') if (!fs.existsSync(configPath)) { fs.writeFileSync(configPath, stringify(createConfig())) console.log(chalk.green('✓'), 'Created', configPath) @@ -46,13 +125,39 @@ export const initCommand = new Command('init') console.log(chalk.yellow('⚠'), configPath, 'already exists') } - // Create sample index.mdx - const indexPath = path.join(contentDir, 'index.mdx') - if (!fs.existsSync(indexPath)) { + // Create sample index.mdx only if content dir is empty + const contentFiles = fs.readdirSync(contentDir) + if (contentFiles.length === 0) { + const indexPath = path.join(contentDir, 'index.mdx') fs.writeFileSync(indexPath, sampleMdx) console.log(chalk.green('✓'), 'Created', indexPath) } + // Add entries to .gitignore + const gitignorePath = path.join(projectDir, '.gitignore') + const gitignoreEntries = ['.chronicle', 'node_modules', '.next'] + if (fs.existsSync(gitignorePath)) { + const existing = fs.readFileSync(gitignorePath, 'utf-8') + const missing = gitignoreEntries.filter(e => !existing.includes(e)) + if (missing.length > 0) { + fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`) + console.log(chalk.green('✓'), 'Added', missing.join(', '), 'to .gitignore') + } + } else { + fs.writeFileSync(gitignorePath, `${gitignoreEntries.join('\n')}\n`) + console.log(chalk.green('✓'), 'Created .gitignore') + } + + // Install dependencies + const pm = detectPackageManager() + console.log(chalk.cyan(`\nInstalling dependencies with ${pm}...`)) + execSync(`${pm} install`, { cwd: projectDir, stdio: 'inherit' }) + + // Scaffold .chronicle/ directory + loadCLIConfig(contentDir) + scaffoldDir(contentDir) + + const runCmd = pm === 'npm' ? 'npx' : pm === 'bun' ? 'bunx' : `${pm} dlx` console.log(chalk.green('\n✓ Chronicle initialized!')) - console.log('\nRun', chalk.cyan('chronicle dev'), 'to start development server') + console.log('\nRun', chalk.cyan(`${runCmd} chronicle dev`), 'to start development server') }) diff --git a/packages/chronicle/src/cli/commands/serve.ts b/packages/chronicle/src/cli/commands/serve.ts index 6d9cb53..e6f2992 100644 --- a/packages/chronicle/src/cli/commands/serve.ts +++ b/packages/chronicle/src/cli/commands/serve.ts @@ -1,32 +1,33 @@ import { Command } from 'commander' import { spawn } from 'child_process' import path from 'path' -import { fileURLToPath } from 'url' +import fs from 'fs' import chalk from 'chalk' -import { resolveContentDir, loadCLIConfig, attachLifecycleHandlers } from '@/cli/utils' - -const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') -const nextBin = path.join(PACKAGE_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'next.cmd' : 'next') +import { attachLifecycleHandlers, resolveNextCli } from '@/cli/utils' export const serveCommand = new Command('serve') .description('Build and start production server') .option('-p, --port ', 'Port number', '3000') - .option('-c, --content ', 'Content directory') .action((options) => { - const contentDir = resolveContentDir(options.content) - loadCLIConfig(contentDir) + const scaffoldPath = path.join(process.cwd(), '.chronicle') + if (!fs.existsSync(scaffoldPath)) { + console.log(chalk.red('Error: .chronicle/ not found. Run'), chalk.cyan('chronicle init'), chalk.red('first.')) + process.exit(1) + } + + const nextCli = resolveNextCli() const env = { ...process.env, - CHRONICLE_CONTENT_DIR: contentDir, + CHRONICLE_PROJECT_ROOT: process.cwd(), + CHRONICLE_CONTENT_DIR: './content', } console.log(chalk.cyan('Building for production...')) - console.log(chalk.gray(`Content: ${contentDir}`)) - const buildChild = spawn(nextBin, ['build'], { + const buildChild = spawn(process.execPath, [nextCli, 'build'], { stdio: 'inherit', - cwd: PACKAGE_ROOT, + cwd: scaffoldPath, env, }) @@ -41,9 +42,9 @@ export const serveCommand = new Command('serve') console.log(chalk.cyan('Starting production server...')) - const startChild = spawn(nextBin, ['start', '-p', options.port], { + const startChild = spawn(process.execPath, [nextCli, 'start', '-p', options.port], { stdio: 'inherit', - cwd: PACKAGE_ROOT, + cwd: scaffoldPath, env, }) diff --git a/packages/chronicle/src/cli/commands/start.ts b/packages/chronicle/src/cli/commands/start.ts index f1081c6..f95fd61 100644 --- a/packages/chronicle/src/cli/commands/start.ts +++ b/packages/chronicle/src/cli/commands/start.ts @@ -1,30 +1,31 @@ import { Command } from 'commander' import { spawn } from 'child_process' import path from 'path' -import { fileURLToPath } from 'url' +import fs from 'fs' import chalk from 'chalk' -import { resolveContentDir, loadCLIConfig, attachLifecycleHandlers } from '@/cli/utils' - -const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') -const nextBin = path.join(PACKAGE_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'next.cmd' : 'next') +import { attachLifecycleHandlers, resolveNextCli } from '@/cli/utils' export const startCommand = new Command('start') .description('Start production server') .option('-p, --port ', 'Port number', '3000') - .option('-c, --content ', 'Content directory') .action((options) => { - const contentDir = resolveContentDir(options.content) - loadCLIConfig(contentDir) + const scaffoldPath = path.join(process.cwd(), '.chronicle') + if (!fs.existsSync(scaffoldPath)) { + console.log(chalk.red('Error: .chronicle/ not found. Run'), chalk.cyan('chronicle init'), chalk.red('first.')) + process.exit(1) + } + + const nextCli = resolveNextCli() console.log(chalk.cyan('Starting production server...')) - console.log(chalk.gray(`Content: ${contentDir}`)) - const child = spawn(nextBin, ['start', '-p', options.port], { + const child = spawn(process.execPath, [nextCli, 'start', '-p', options.port], { stdio: 'inherit', - cwd: PACKAGE_ROOT, + cwd: scaffoldPath, env: { ...process.env, - CHRONICLE_CONTENT_DIR: contentDir, + CHRONICLE_PROJECT_ROOT: process.cwd(), + CHRONICLE_CONTENT_DIR: './content', }, }) diff --git a/packages/chronicle/src/cli/utils/config.ts b/packages/chronicle/src/cli/utils/config.ts index 76c90bf..2b8c0ad 100644 --- a/packages/chronicle/src/cli/utils/config.ts +++ b/packages/chronicle/src/cli/utils/config.ts @@ -13,14 +13,22 @@ export interface CLIConfig { export function resolveContentDir(contentFlag?: string): string { if (contentFlag) return path.resolve(contentFlag) if (process.env.CHRONICLE_CONTENT_DIR) return path.resolve(process.env.CHRONICLE_CONTENT_DIR) - return process.cwd() + return path.resolve('content') +} + +function resolveConfigPath(contentDir: string): string | null { + const cwdPath = path.join(process.cwd(), 'chronicle.yaml') + if (fs.existsSync(cwdPath)) return cwdPath + const contentPath = path.join(contentDir, 'chronicle.yaml') + if (fs.existsSync(contentPath)) return contentPath + return null } export function loadCLIConfig(contentDir: string): CLIConfig { - const configPath = path.join(contentDir, 'chronicle.yaml') + const configPath = resolveConfigPath(contentDir) - if (!fs.existsSync(configPath)) { - console.log(chalk.red('Error: chronicle.yaml not found in'), contentDir) + if (!configPath) { + console.log(chalk.red('Error: chronicle.yaml not found in'), process.cwd(), 'or', contentDir) console.log(chalk.gray(`Run 'chronicle init' to create one`)) process.exit(1) } diff --git a/packages/chronicle/src/cli/utils/index.ts b/packages/chronicle/src/cli/utils/index.ts index 645d5a9..7b2a393 100644 --- a/packages/chronicle/src/cli/utils/index.ts +++ b/packages/chronicle/src/cli/utils/index.ts @@ -1,2 +1,3 @@ export * from './config' export * from './process' +export * from './scaffold' diff --git a/packages/chronicle/src/cli/utils/resolve.ts b/packages/chronicle/src/cli/utils/resolve.ts new file mode 100644 index 0000000..3e4b5e1 --- /dev/null +++ b/packages/chronicle/src/cli/utils/resolve.ts @@ -0,0 +1,4 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') diff --git a/packages/chronicle/src/cli/utils/scaffold.ts b/packages/chronicle/src/cli/utils/scaffold.ts new file mode 100644 index 0000000..5d7263d --- /dev/null +++ b/packages/chronicle/src/cli/utils/scaffold.ts @@ -0,0 +1,131 @@ +import { execSync } from 'child_process' +import { createRequire } from 'module' +import fs from 'fs' +import path from 'path' +import chalk from 'chalk' +import { PACKAGE_ROOT } from './resolve' + +const COPY_FILES = ['src', 'source.config.ts', 'tsconfig.json'] + +function copyRecursive(src: string, dest: string) { + const stat = fs.statSync(src) + if (stat.isDirectory()) { + fs.mkdirSync(dest, { recursive: true }) + for (const entry of fs.readdirSync(src)) { + copyRecursive(path.join(src, entry), path.join(dest, entry)) + } + } else { + fs.copyFileSync(src, dest) + } +} + +function ensureRemoved(targetPath: string) { + try { + fs.lstatSync(targetPath) + fs.rmSync(targetPath, { recursive: true, force: true }) + } catch { + // nothing exists, proceed + } +} + +export function detectPackageManager(): string { + if (process.env.npm_config_user_agent) { + return process.env.npm_config_user_agent.split('/')[0] + } + const cwd = process.cwd() + if (fs.existsSync(path.join(cwd, 'bun.lock')) || fs.existsSync(path.join(cwd, 'bun.lockb'))) return 'bun' + if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm' + if (fs.existsSync(path.join(cwd, 'yarn.lock'))) return 'yarn' + return 'npm' +} + +function generateNextConfig(scaffoldPath: string) { + const config = `import { createMDX } from 'fumadocs-mdx/next' + +const withMDX = createMDX() + +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +export default withMDX(nextConfig) +` + fs.writeFileSync(path.join(scaffoldPath, 'next.config.mjs'), config) +} + +function createPackageJson(): Record { + return { + name: 'chronicle-docs', + private: true, + dependencies: { + '@raystack/chronicle': 'latest', + }, + devDependencies: { + '@raystack/tools-config': '0.56.0', + 'openapi-types': '^12.1.3', + typescript: '5.9.3', + '@types/react': '^19.2.10', + '@types/node': '^25.1.0', + }, + } +} + +function ensureDeps() { + const cwd = process.cwd() + const cwdPkgJson = path.join(cwd, 'package.json') + const cwdNodeModules = path.join(cwd, 'node_modules') + + if (fs.existsSync(cwdPkgJson) && fs.existsSync(cwdNodeModules)) { + // Case 1: existing project with deps installed + return + } + + // Case 2: no package.json — create in cwd and install + if (!fs.existsSync(cwdPkgJson)) { + fs.writeFileSync(cwdPkgJson, JSON.stringify(createPackageJson(), null, 2) + '\n') + } + + if (!fs.existsSync(cwdNodeModules)) { + const pm = detectPackageManager() + console.log(chalk.cyan(`Installing dependencies with ${pm}...`)) + execSync(`${pm} install`, { cwd, stdio: 'inherit' }) + } +} + +export function resolveNextCli(): string { + const cwdRequire = createRequire(path.join(process.cwd(), 'package.json')) + return cwdRequire.resolve('next/dist/bin/next') +} + +export function scaffoldDir(contentDir: string): string { + const scaffoldPath = path.join(process.cwd(), '.chronicle') + + // Create .chronicle/ if not exists + if (!fs.existsSync(scaffoldPath)) { + fs.mkdirSync(scaffoldPath, { recursive: true }) + } + + // Copy package files + for (const name of COPY_FILES) { + const src = path.join(PACKAGE_ROOT, name) + const dest = path.join(scaffoldPath, name) + ensureRemoved(dest) + copyRecursive(src, dest) + } + + // Generate next.config.mjs + generateNextConfig(scaffoldPath) + + // Symlink content dir + const contentLink = path.join(scaffoldPath, 'content') + ensureRemoved(contentLink) + fs.symlinkSync(path.resolve(contentDir), contentLink) + + // Ensure dependencies are available + ensureDeps() + + console.log(chalk.gray(`Scaffold: ${scaffoldPath}`)) + + return scaffoldPath +} diff --git a/packages/chronicle/src/components/ui/search.module.css b/packages/chronicle/src/components/ui/search.module.css index 81ed6ac..086e7f4 100644 --- a/packages/chronicle/src/components/ui/search.module.css +++ b/packages/chronicle/src/components/ui/search.module.css @@ -102,3 +102,10 @@ .item[data-selected="true"] .icon { color: var(--rs-color-foreground-accent-primary-hover); } + +.pageText :global(mark), +.headingText :global(mark) { + background: transparent; + color: var(--rs-color-foreground-accent-primary); + font-weight: 600; +} diff --git a/packages/chronicle/src/components/ui/search.tsx b/packages/chronicle/src/components/ui/search.tsx index f028ddd..fb56aaa 100644 --- a/packages/chronicle/src/components/ui/search.tsx +++ b/packages/chronicle/src/components/ui/search.tsx @@ -111,7 +111,7 @@ export function Search({ className }: SearchProps) {
{getResultIcon(result)} - {stripMethod(result.content)} +
@@ -132,7 +132,7 @@ export function Search({ className }: SearchProps) { {result.type === "heading" ? ( <> - {stripMethod(result.content)} + - @@ -141,7 +141,7 @@ export function Search({ className }: SearchProps) { ) : ( - {stripMethod(result.content)} + )} @@ -178,6 +178,10 @@ function stripMethod(content: string): string { return API_METHODS.has(first) ? content.slice(first.length + 1) : content; } +function HighlightedText({ html, className }: { html: string; className?: string }) { + return ; +} + function getResultIcon(result: SortedResult): React.ReactNode { if (!result.url.startsWith("/apis/")) { return result.type === "page" ? ( diff --git a/packages/chronicle/src/lib/config.ts b/packages/chronicle/src/lib/config.ts index e647d2b..a0aa834 100644 --- a/packages/chronicle/src/lib/config.ts +++ b/packages/chronicle/src/lib/config.ts @@ -11,11 +11,29 @@ const defaultConfig: ChronicleConfig = { search: { enabled: true, placeholder: 'Search...' }, } +function resolveConfigPath(): string | null { + // Check project root via env var + const projectRoot = process.env.CHRONICLE_PROJECT_ROOT + if (projectRoot) { + const rootPath = path.join(projectRoot, CONFIG_FILE) + if (fs.existsSync(rootPath)) return rootPath + } + // Check cwd + const cwdPath = path.join(process.cwd(), CONFIG_FILE) + if (fs.existsSync(cwdPath)) return cwdPath + // Check content dir + const contentDir = process.env.CHRONICLE_CONTENT_DIR + if (contentDir) { + const contentPath = path.join(contentDir, CONFIG_FILE) + if (fs.existsSync(contentPath)) return contentPath + } + return null +} + export function loadConfig(): ChronicleConfig { - const dir = process.env.CHRONICLE_CONTENT_DIR ?? process.cwd() - const configPath = path.join(dir, CONFIG_FILE) + const configPath = resolveConfigPath() - if (!fs.existsSync(configPath)) { + if (!configPath) { return defaultConfig } diff --git a/packages/chronicle/src/lib/source.ts b/packages/chronicle/src/lib/source.ts index 3acd459..71cb663 100644 --- a/packages/chronicle/src/lib/source.ts +++ b/packages/chronicle/src/lib/source.ts @@ -1,4 +1,4 @@ -import { docs } from '../../.source/server' +import { docs } from '@/.source/server' import { loader } from 'fumadocs-core/source' import type { PageTree, PageTreeItem, Frontmatter } from '@/types' diff --git a/packages/chronicle/tsconfig.json b/packages/chronicle/tsconfig.json index 471ded2..b21ec05 100644 --- a/packages/chronicle/tsconfig.json +++ b/packages/chronicle/tsconfig.json @@ -1,6 +1,20 @@ { - "extends": "@raystack/tools-config/tsconfig/react", "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true, + "jsx": "react-jsx", + "module": "ESNext", + "target": "es6", "outDir": "dist", "rootDir": ".", "baseUrl": ".", @@ -8,7 +22,7 @@ "moduleResolution": "bundler", "paths": { "@/*": ["./src/*"], - "@/.source": ["./.source/index"] + "@/.source/*": ["./.source/*"] } }, "include": ["src", ".source", "source.config.ts"],