diff --git a/.gitignore b/.gitignore index 854b00f..2e2d042 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist dist-test +.simpledoc.local.json diff --git a/.husky/pre-commit b/.husky/pre-commit index 8b73430..599bf0a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -npm exec -- lint-staged +npm run -s precommit diff --git a/README.md b/README.md index d267f56..a1c1d1f 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,16 @@ SimpleDoc defines two types of files: 1. **Date-prefixed files**: SHOULD be used for most documents, e.g. `docs/2025-12-22-an-awesome-doc.md`. 2. **Capitalized files**: SHOULD be used for general documents that are not tied to a specific time, e.g. `README.md`. +SimpleDoc also includes optional subspecs for specialized document types, such as the SimpleLog daily log format in `docs/SIMPLELOG_SPEC.md`. +Configuration for tooling can be shared in `simpledoc.json` and overridden per-user in `.simpledoc.local.json` (see `docs/SIMPLEDOC_CONFIG_SPEC.md`). + ### 1. Date-prefixed files - Date-prefixed Markdown files SHOULD be used for most documents that are tied to a specific time. - MUST put date-prefixed files in a top level `docs/` folder, or a subfolder `docs//`. Subfolders MAY be nested indefinitely. - MUST use ISO 8601 date prefixes (`YYYY-MM-DD`) — the date MUST contain dashes. - After the date prefix, lowercase filenames SHOULD use dashes (`-`) as word delimiters (kebab-case). Avoid spaces and underscores. +- The date prefix MAY be the entire filename (for example, daily logs like `docs/logs/2026-02-04.md`). - MUST NOT use capital letters in filename for Latin, Greek, Cyrillic and other writing systems that have lowercase/uppercase distinction. - MAY use non-ASCII characters, e.g. `docs/2025-12-22-postmortem-login-ausfälle.md`, `docs/2025-12-22-功能-设计说明.md`. - Date-prefixed files SHOULD contain YAML frontmatter with at least `title`, `author` and `date` fields, but we are all people and sometimes don't have time to write a proper frontmatter, so it is not required. E.g. diff --git a/docs/SIMPLEDOC_CONFIG_SPEC.md b/docs/SIMPLEDOC_CONFIG_SPEC.md new file mode 100644 index 0000000..8ba5c91 --- /dev/null +++ b/docs/SIMPLEDOC_CONFIG_SPEC.md @@ -0,0 +1,155 @@ +# SimpleDoc Config + +> Repository and local configuration for SimpleDoc tools + +## 1) Config files + +SimpleDoc tooling MAY read two JSON config files at the repo root: + +- `simpledoc.json` (committed, shared defaults) +- `.simpledoc.local.json` (uncommitted, per-user overrides) + +Both files are optional. + +## 2) File format + +- MUST be valid JSON. +- MUST be UTF-8. +- MUST use LF (`\n`) newlines. + +## 3) Precedence + +Configuration values are resolved in this order (highest wins): + +1. CLI flags +2. `.simpledoc.local.json` +3. `simpledoc.json` +4. Tool defaults + +## 4) Schema + +Top-level object. Current keys: + +```json +{ + "docs": { + "root": "docs" + }, + "frontmatter": { + "defaults": { + "author": "Jane Doe ", + "tags": ["docs", "simpledoc"], + "titlePrefix": "Daily Log" + } + }, + "check": { + "ignore": ["docs/generated/**", "docs/_drafts/**"] + }, + "simplelog": { + "root": "docs/logs", + "thresholdMinutes": 5, + "timezone": "Europe/Berlin" + } +} +``` + +### docs.root + +- **Type:** string +- **Meaning:** Root directory for SimpleDoc-managed documentation. +- **Resolution:** If relative, it is resolved from the repo root. +- **Default:** `docs` +- **Notes:** Tools like `npx -y @simpledoc/simpledoc check` and `npx -y @simpledoc/simpledoc migrate` SHOULD treat this as the documentation root. + +### frontmatter.defaults + +- **Type:** object +- **Meaning:** Default frontmatter values to use when a tool needs to create or insert frontmatter. +- **Notes:** These values SHOULD only fill missing fields and MUST NOT overwrite existing frontmatter. + +Supported keys: + +- `author` (string): Default `Name ` to use. +- `tags` (string array): Default tags to add (optional). +- `titlePrefix` (string): Prefix used when generating titles (optional). + +### check.ignore + +- **Type:** array of strings +- **Meaning:** Glob-like patterns to ignore when scanning for violations in `npx -y @simpledoc/simpledoc check` (and optionally other scans). +- **Resolution:** Patterns are matched relative to the repo root. +- **Notes:** Ignored paths SHOULD be skipped entirely during scans. + +### simplelog.root + +- **Type:** string +- **Meaning:** Root directory for SimpleLog daily files. +- **Resolution:** If relative, it is resolved from the repo root. +- **Default:** `${docs.root}/logs` (falls back to `docs/logs` if `docs.root` is unset). +- **Recommendation:** Use a shared path in `simpledoc.json` (e.g. `docs/logs`) and a per-user path in `.simpledoc.local.json` when needed (e.g. `docs/logs/_local/`). + +### simplelog.thresholdMinutes + +- **Type:** number +- **Meaning:** Default threshold (in minutes) for starting a new session section. +- **Default:** 5 +- **Notes:** CLI flags should override this when provided. + +### simplelog.timezone + +- **Type:** string +- **Meaning:** IANA timezone ID to use when creating new SimpleLog files and sections. +- **Default:** System timezone (or `UTC` if unavailable). +- **Notes:** If a SimpleLog file exists with a `tz` frontmatter value, that value SHOULD take precedence for that file. + +## 5) Usage examples + +### Shared default (committed) + +`simpledoc.json` + +```json +{ + "docs": { + "root": "docs" + }, + "frontmatter": { + "defaults": { + "author": "Jane Doe ", + "tags": ["docs", "simpledoc"] + } + }, + "check": { + "ignore": ["docs/generated/**"] + }, + "simplelog": { + "root": "docs/logs" + } +} +``` + +### Local override (uncommitted) + +`.simpledoc.local.json` + +```json +{ + "docs": { + "root": "docs" + }, + "frontmatter": { + "defaults": { + "author": "Alice Example " + } + }, + "simplelog": { + "root": "docs/logs/_local/alice", + "thresholdMinutes": 2, + "timezone": "Europe/Berlin" + } +} +``` + +## 6) Git ignore + +Teams SHOULD add `.simpledoc.local.json` to `.gitignore` to prevent accidental commits. diff --git a/docs/SIMPLEDOC_SPEC.md b/docs/SIMPLEDOC_SPEC.md index 1a720ac..9e8e1cf 100644 --- a/docs/SIMPLEDOC_SPEC.md +++ b/docs/SIMPLEDOC_SPEC.md @@ -17,6 +17,7 @@ SimpleDoc defines two types of files: - MUST put date-prefixed files in a top level `docs/` folder, or a subfolder `docs//`. Subfolders MAY be nested indefinitely. - MUST use ISO 8601 date prefixes (`YYYY-MM-DD`) — the date MUST contain dashes. - After the date prefix, lowercase filenames SHOULD use dashes (`-`) as word delimiters (kebab-case). Avoid spaces and underscores. +- The date prefix MAY be the entire filename (for example, daily logs like `docs/logs/2026-02-04.md`). - MUST NOT use capital letters in filename for Latin, Greek, Cyrillic and other writing systems that have lowercase/uppercase distinction. - MAY use non-ASCII characters, e.g. `docs/2025-12-22-postmortem-login-ausfälle.md`, `docs/2025-12-22-功能-设计说明.md`. - Date-prefixed files SHOULD contain YAML frontmatter with at least `title`, `author` and `date` fields, but we are all people and sometimes don't have time to write a proper frontmatter, so it is not required. E.g. diff --git a/docs/SIMPLELOG_SPEC.md b/docs/SIMPLELOG_SPEC.md new file mode 100644 index 0000000..f200f6b --- /dev/null +++ b/docs/SIMPLELOG_SPEC.md @@ -0,0 +1,182 @@ +# SimpleLog + +> SimpleDoc subspec: Daily Markdown Log (DML) v1 - Specification + +## 1) Storage layout + +- **Root directory:** any path. When used inside a SimpleDoc codebase, logs SHOULD live under `docs/` or `docs//` to remain compliant with SimpleDoc. +- **Daily file name:** `YYYY-MM-DD.md` (local date in the chosen "primary" timezone). + - Example: `2025-12-01.md` +- **Optional subdirectories** (recommended when many files): + - `YYYY/YYYY-MM-DD.md` or `YYYY/MM/YYYY-MM-DD.md` + - Example: `2025/2025-12-01.md` + +## 2) File encoding and newlines + +- MUST be UTF-8. +- MUST use LF (`\n`) newlines. +- SHOULD end with a trailing newline. + +## 3) Frontmatter (required) + +Files MUST start with YAML frontmatter that follows SimpleDoc conventions. + +Required fields: + +- `title`: human-readable title for the day. +- `author`: `Name ` (RFC 5322 name-addr format). +- `date`: `YYYY-MM-DD`. +- `tz`: IANA timezone ID (e.g., `Europe/Berlin`). +- `created`: ISO-8601 timestamp with offset. + +Optional fields: + +- `last_section`: ISO-8601 timestamp with offset (start time of the latest section). +- `updated`: ISO-8601 timestamp with offset. + +Example: + +```md +--- +title: Daily Log 2025-12-01 +author: Jane Doe +date: 2025-12-01 +tz: Europe/Berlin +created: 2025-12-01T00:00:00+01:00 +last_section: 2025-12-01T09:13:00+01:00 +--- +``` + +Notes: + +- The file date is interpreted in the `tz` timezone. +- DST transitions are supported because each entry includes an offset. + +## 4) Session sections (threshold) + +Entries are grouped into session sections. Section titles SHOULD reflect the local time of the first entry in that section. + +- **Section heading format (required):** `## HH:MM` + - `HH` is 24-hour, zero-padded (`00-23`). + +Example: + +```md +## 09:13 + +## 14:03 +``` + +Rules: + +- SHOULD be in chronological order. +- A section may exist with no entries. + +## 5) Entry format (appendable, human-readable, parseable) + +Each entry is a block of text separated by at least one blank line. The entry body is freeform; the only reserved syntax is the session section headings. + +Recommended entry body conventions (all optional): + +- Severity token: `[INFO]`, `[WARN]`, `[ERROR]`, etc. +- Tags: `#tag` tokens +- Key-values: `key=value` tokens (values may be quoted) +- Optional timestamp prefix if you want exact times per entry, e.g. `09:14:10+01:00 ...`. + +Examples: + +```md +Standup notes #team + +[WARN] API latency spike service=orders p95_ms=840 + +09:14:10+01:00 Deployed v1.8.2 #deploy ticket=ABC-123 +``` + +Multiline entries: + +- Continuation lines are allowed and are stored as-is. +- CLI implementations SHOULD NOT alter indentation; they only ensure a blank line separates entries. + +Example: + +```md +Incident review #ops + +- suspected cause: cache stampede +- mitigation: rate-limit + warmup +``` + +## 6) Optional full timestamp derivation + +If an entry begins with a timestamp prefix, you can derive a full timestamp by combining it with the file date: + +- file date `YYYY-MM-DD` +- entry prefix `HH:MM[:SS]+HH:MM` or `HH:MM[:SS]-HH:MM` + +Full timestamp: + +- `YYYY-MM-DDTHH:MM[:SS]+HH:MM` or `YYYY-MM-DDTHH:MM[:SS]-HH:MM` + +Example: + +- File: `2025-12-01.md` +- Entry: `09:14:10+01:00 ...` +- Full timestamp: `2025-12-01T09:14:10+01:00` + +## 7) CLI append behavior (normative) + +When the CLI writes an entry (config is resolved via `simpledoc.json` / `.simpledoc.local.json` if present; see `docs/SIMPLEDOC_CONFIG_SPEC.md`): + +1. Determine "now" in the primary timezone from frontmatter (or CLI config). +2. Select file by the local date in that timezone: `YYYY-MM-DD.md`. +3. If the file does not exist, create it with the required frontmatter. +4. Start a new session section when either: + - no section exists yet, or + - the last section start time is older than the threshold (for example, 5 minutes). + The new section title MUST be the current local time in `HH:MM` format. + CLI implementations SHOULD store the section start time in frontmatter as `last_section` and use it for threshold comparisons. +5. Ensure there is a blank line between the last existing line and the new entry. +6. Append the new entry block using the exact input text (no indentation changes). + +This guarantees the tool only appends (no in-file insertion) while keeping session grouping. + +## 8) Complete example file + +`2025-12-01.md` + +```md +--- +title: Daily Log 2025-12-01 +author: Jane Doe +date: 2025-12-01 +tz: Europe/Berlin +created: 2025-12-01T00:00:00+01:00 +last_section: 2025-12-01T14:27:00+01:00 +--- + +## 09:13 + +Checked alerts #ops + +[WARN] Elevated error rate service=api code=502 +notes="started after deploy" + +## 14:03 + +Deployed v1.8.2 #deploy ticket=ABC-123 + +## 14:27 + +Incident review #ops + +- suspected cause: cache stampede +- mitigation: rate-limit + warmup +``` + +## 9) Multiple timezones in one file (optional) + +If you need multiple timezones in one file (rare, but possible): + +- Keep the frontmatter `tz` as the default. +- Allow an optional zone ID after the offset if you include a timestamp prefix, e.g. `10:00:00+01:00 Europe/Berlin ...`. diff --git a/docs/logs/2026-02-04.md b/docs/logs/2026-02-04.md new file mode 100644 index 0000000..f701d2b --- /dev/null +++ b/docs/logs/2026-02-04.md @@ -0,0 +1,110 @@ +--- +title: Daily Log 2026-02-04 +author: Onur Solmaz +date: 2026-02-04 +tz: Europe/Berlin +created: 2026-02-04T20:35:26+01:00 +last_section: 2026-02-04T22:27:00+01:00 +updated: 2026-02-04T22:28:43+01:00 +--- + +## 20:35 + +Implemented simpledoc log command with auto timezone/date and thresholded sections; writing this entry to document the work. + +Added multiline support to simpledoc log. + +- CLI now accepts --stdin and preserves newline content with proper indentation. +- Spec updated to mention CLI indentation for multiline input. + +Auto-read stdin for simpledoc log when piped, matching Unix utility expectations. + +- Kept --stdin for explicit use. + +Updated SimpleLog to preserve raw input and insert a blank line before new entries, without indentation changes. + +## 21:08 + +Test run after removing auto timestamps and keeping blank-line separation. + +Multiline test entry. + +- line two +- line three + +## 21:14 + +Summarized today’s SimpleLog changes and tests. + +- Updated spec to drop auto timestamps, require frontmatter, and use threshold-based session sections. +- Adjusted CLI to preserve raw input, insert blank lines, and update frontmatter timestamps. +- Logged multiline and stdin behaviors; updated README and skill docs. + +Updated SimpleDoc skill to instruct agents to log noteworthy actions and discoveries. + +Updated SimpleDoc skill to log ongoing progress, not just significant events. + +Expanded SimpleDoc skill logging guidance to cover anything worth noting (significant events and ongoing progress). + +Added config spec for simpledoc.json and .simpledoc.local.json with precedence and schema details. + +- Documented usage in README, SimpleLog spec, and SimpleDoc skill. +- Added .simpledoc.local.json to .gitignore. + +Expanded config spec with docs.root, frontmatter.defaults, check.ignore, and simplelog.timezone. + +## 21:44 + +Implemented config loading (simpledoc.json + .simpledoc.local.json) with precedence and defaults. + +- Wired docs.root, frontmatter.defaults, check.ignore, and simplelog.\* into CLI + migrator logic. +- Added config tests plus docsRoot/ignore/frontmatter default coverage. +- Ran npm test (all passing). + +## 21:59 + +Refactored config validation and shared helpers for docs root, ignore globs, and frontmatter formatting. + +- Normalized docs root + simplelog root handling and stricter config type checks. +- Log frontmatter now uses ordered fields via shared builder. +- Added shared frontmatter/ignore/path helpers; tests: npm test. + +## 22:06 + +Allowed date-only filenames for SimpleDoc (e.g., SimpleLog daily files) and updated tooling. + +- Updated SimpleDoc spec + README to allow date-only docs. +- Naming normalization now keeps date-only filenames; added plan test. +- Added simpledoc check to pre-commit hook. +- Tests: npm test. + +Refactored pre-commit to run a single npm script, centralized frontmatter field order, and moved frontmatter parsing into a shared helper. + +- Added npm script precommit and updated .husky/pre-commit. +- Exported frontmatter order constants + parse helper in src/frontmatter.ts; log/migrator now reuse. +- Tests: npm test. + +Updated CLI instruction strings to use npx with the package name for migrate guidance. + +- check/install outputs now say `npx -y @simpledoc/simpledoc migrate`. +- migrate/install intro labels updated to show the npx command. +- Config spec references updated. +- Tests: npm test. + +Added tests for config validation errors, frontmatter helpers, and SimpleLog threshold section behavior. + +- New config error tests for invalid types. +- New frontmatter tests for ordered output + parsing. +- New log test with mocked time to verify threshold-based sections. +- Tests: npm test. + +## 22:27 + +Updated SimpleLog section thresholding to use a frontmatter `last_section` timestamp and documented it in the spec. + +- CLI now compares threshold against last_section (or last section heading if missing). +- Frontmatter order includes last_section; updated log test to assert it. +- Spec now documents last_section as optional frontmatter. +- Tests: npm test. + +Rebuilt CLI output so logs now write `last_section` to frontmatter. diff --git a/package.json b/package.json index fbf7081..47e1fae 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "lint": "eslint . --max-warnings 0", "prepack": "npm run build", "prepare": "husky", + "precommit": "npm exec -- lint-staged && npm run -s build && node ./dist/bin/simpledoc.js check", "release": "release-it", "release:ci": "release-it --ci", "migrate": "npm run -s build && node ./dist/bin/simpledoc.js migrate", diff --git a/skills/simpledoc/SKILL.md b/skills/simpledoc/SKILL.md index 49fe001..5cc10e7 100644 --- a/skills/simpledoc/SKILL.md +++ b/skills/simpledoc/SKILL.md @@ -83,6 +83,66 @@ SimpleDoc Guidelines: - Choose a concise, action-oriented `title`. - Populate `tags` when it improves discoverability; omit the line if not needed. +## Daily Logs (SimpleLog) + +Use SimpleLog for daily logs. The spec lives at `docs/SIMPLELOG_SPEC.md`. + +### Where logs live + +- Default location: `docs/logs/YYYY-MM-DD.md`. +- The CLI writes to `/docs/logs/` by default when inside a git repo. +- You can set a shared default in `simpledoc.json` and override locally in `.simpledoc.local.json` (see `docs/SIMPLEDOC_CONFIG_SPEC.md`). + +### Create a daily log entry (recommended) + +Use the CLI to create the file and append entries: + +```bash +simpledoc log "Entry text here" +``` + +Notes: + +- The CLI creates the daily log file if missing, including required frontmatter. +- It adds a new session section only when the threshold is exceeded (default 5 minutes). +- It preserves the text you type and inserts a blank line before each new entry. + +### Multiline entries + +Pipe or heredoc input (stdin) for multiline entries: + +```bash +cat <<'EOF' | simpledoc log +Multiline entry. +- line two +- line three +EOF +``` + +You can also use `--stdin` explicitly: + +```bash +simpledoc log --stdin <<'EOF' +Another multiline entry. +EOF +``` + +### Manual edits (if needed) + +- Keep the YAML frontmatter intact (`title`, `author`, `date`, `tz`, `created`, optional `updated`). +- Ensure a blank line separates entries. +- Session sections must be `## HH:MM` (local time of the first entry in that section). + +### Ongoing logging (agent behavior) + +Once this skill is active in a repo, the agent SHOULD log anything worth noting as it goes. This includes: + +- Significant changes, decisions, discoveries, tradeoffs, and assumptions. +- Ongoing progress and small but real steps (changes, commands, tests, doc updates). +- Errors, failures, workarounds, and clarifications. + +Log each entry after completing the step or realizing the insight. + ## Final Checks Before Submitting - [ ] Filename follows the `YYYY-MM-DD-…` pattern (date uses dashes) and lives in the correct directory. diff --git a/src/cli/check.ts b/src/cli/check.ts index 6903a8d..7b12ef9 100644 --- a/src/cli/check.ts +++ b/src/cli/check.ts @@ -7,6 +7,7 @@ import { type ReferenceUpdateAction, type RenameAction, } from "../migrator.js"; +import { loadConfig } from "../config.js"; import { MAX_STEP_FILE_PREVIEW_LINES, createScanProgressBarReporter, @@ -20,10 +21,16 @@ function getErrorMessage(err: unknown): string { export async function runCheck(): Promise { try { + const config = await loadConfig(process.cwd()); const scanProgress = createScanProgressBarReporter( Boolean(process.stdin.isTTY && process.stdout.isTTY), ); - const plan = await planMigration({ onProgress: scanProgress }); + const plan = await planMigration({ + onProgress: scanProgress, + docsRoot: config.docsRoot, + ignoreGlobs: config.checkIgnore, + frontmatterDefaults: config.frontmatterDefaults, + }); const renames = plan.actions.filter( (a): a is RenameAction => a.type === "rename", @@ -74,7 +81,7 @@ export async function runCheck(): Promise { process.stdout.write(`${limited}\n\n`); } - process.stdout.write("Run `simpledoc migrate` to fix.\n"); + process.stdout.write("Run `npx -y @simpledoc/simpledoc migrate` to fix.\n"); process.exitCode = 1; } catch (err) { process.stderr.write(`${getErrorMessage(err)}\n`); diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d9cf67..9d3c07d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,6 +3,7 @@ import { Command, CommanderError } from "commander"; import { runCheck } from "./check.js"; import { runInstall } from "./install.js"; +import { runLog } from "./log.js"; import { runMigrate } from "./migrate.js"; type MigrateOptions = { @@ -15,6 +16,11 @@ type InstallOptions = { dryRun: boolean; yes: boolean; }; +type LogOptions = { + root?: string; + thresholdMinutes?: string; + stdin?: boolean; +}; function getErrorMessage(err: unknown): string { if (err instanceof Error) return err.message; @@ -32,6 +38,22 @@ function shouldDefaultToMigrate(argvRest: string[]): boolean { return restWithoutHelp.length > 0; } +async function readStdinMessage(fallback: string): Promise { + const chunks: string[] = []; + return await new Promise((resolve, reject) => { + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + chunks.push(String(chunk)); + }); + process.stdin.on("error", reject); + process.stdin.on("end", () => { + const combined = chunks.join(""); + const trimmed = combined.trim(); + resolve(trimmed.length > 0 ? combined : fallback); + }); + }); +} + export async function runCli(argv: string[]): Promise { const program = new Command(); program @@ -75,6 +97,32 @@ export async function runCli(argv: string[]): Promise { await runCheck(); }); + program + .command("log") + .description( + "Append a SimpleLog entry (Daily Markdown Log) to the configured log root.", + ) + .argument("[message...]", "Entry text to append") + .option( + "--root ", + "Root directory for log files (default: config or docs/logs)", + ) + .option( + "--stdin", + "Read entry text from stdin (supports multiline). If stdin is piped, it is read automatically.", + ) + .option( + "--threshold-minutes ", + "Start a new time section if the last entry is older than this. Use 0 to disable (default: config or 5).", + ) + .action(async (messageParts: string[], options: LogOptions) => { + let message = messageParts.join(" "); + if (options.stdin || !process.stdin.isTTY) { + message = await readStdinMessage(message); + } + await runLog(message, options); + }); + const rest = argv.slice(2); const argvToParse = shouldDefaultToMigrate(rest) ? [...argv.slice(0, 2), "migrate", ...rest] diff --git a/src/cli/install.ts b/src/cli/install.ts index d78d322..5582e19 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -27,6 +27,7 @@ import { } from "./ui.js"; import { runMigrate } from "./migrate.js"; import { runInstallSteps } from "./steps/install.js"; +import { loadConfig } from "../config.js"; type InstallOptions = { dryRun: boolean; @@ -105,6 +106,7 @@ function printMigrationSummary(info: MigrationInfo, includePreview: boolean) { export async function runInstall(options: InstallOptions): Promise { try { + const config = await loadConfig(process.cwd()); const git = createGitClient(); const repoRootAbs = await git.getRepoRoot(process.cwd()); const installStatus = await getInstallationStatus(repoRootAbs); @@ -114,6 +116,9 @@ export async function runInstall(options: InstallOptions): Promise { const migrationPlan = await planMigration({ cwd: repoRootAbs, onProgress: scanProgress, + docsRoot: config.docsRoot, + ignoreGlobs: config.checkIgnore, + frontmatterDefaults: config.frontmatterDefaults, }); const migrationInfo = buildMigrationInfo(migrationPlan); @@ -148,7 +153,9 @@ export async function runInstall(options: InstallOptions): Promise { } if (migrationInfo.hasIssues) { printMigrationSummary(migrationInfo, false); - process.stdout.write("Run `simpledoc migrate` to fix.\n"); + process.stdout.write( + "Run `npx -y @simpledoc/simpledoc migrate` to fix.\n", + ); } else { process.stdout.write( "Done. Review with `git status` / `git diff` and commit when ready.\n", @@ -157,7 +164,7 @@ export async function runInstall(options: InstallOptions): Promise { return; } - intro("simpledoc install"); + intro("npx -y @simpledoc/simpledoc install"); if (installActionsAll.length > 0) { const installSel = await runInstallSteps(installStatus); @@ -194,7 +201,7 @@ export async function runInstall(options: InstallOptions): Promise { process.stdout.write(`${limited}\n\n`); } const migrateNow = await promptConfirm( - "Run `simpledoc migrate` now?", + "Run `npx -y @simpledoc/simpledoc migrate` now?", true, ); if (migrateNow === null) return abort("Operation cancelled."); diff --git a/src/cli/log.ts b/src/cli/log.ts new file mode 100644 index 0000000..2f8160a --- /dev/null +++ b/src/cli/log.ts @@ -0,0 +1,371 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +import { loadConfig } from "../config.js"; +import { + buildFrontmatter, + type FrontmatterValue, + parseFrontmatterBlock, + SIMPLELOG_FRONTMATTER_ORDER, +} from "../frontmatter.js"; + +type LogOptions = { + root?: string; + thresholdMinutes?: number | string; +}; + +type LogClock = { + timeZone: string; + date: string; + time: string; + offset: string; +}; + +const SECTION_RE = /^##\s+(\d{2}):(\d{2})(?::\d{2})?\s*$/; + +function getErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function formatOffset(offsetMinutes: number): string { + if (offsetMinutes === 0) return "Z"; + const sign = offsetMinutes >= 0 ? "+" : "-"; + const abs = Math.abs(offsetMinutes); + const hours = Math.floor(abs / 60); + const minutes = abs % 60; + return `${sign}${pad2(hours)}:${pad2(minutes)}`; +} + +function parseThresholdMinutes(value: number | string): number { + if (typeof value === "number") { + if (Number.isFinite(value) && value >= 0) return value; + } else if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + const parsed = Number(trimmed); + if (Number.isFinite(parsed) && parsed >= 0) return parsed; + } + } + throw new Error("--threshold-minutes must be a number >= 0"); +} + +function getDefaultTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +} + +function getClockForTimeZone(now: Date, timeZone: string): LogClock { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const parts = formatter.formatToParts(now); + const lookup = (type: string): string => + parts.find((part) => part.type === type)?.value ?? ""; + + const year = Number(lookup("year")); + const month = Number(lookup("month")); + const day = Number(lookup("day")); + const hour = Number(lookup("hour")); + const minute = Number(lookup("minute")); + const second = Number(lookup("second")); + + if (!year || !month || !day) { + throw new Error(`Failed to derive date parts for timezone ${timeZone}`); + } + + const date = `${year}-${pad2(month)}-${pad2(day)}`; + const time = `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`; + const asUtc = Date.UTC(year, month - 1, day, hour, minute, second); + const offsetMinutes = Math.round((asUtc - now.getTime()) / 60_000); + const offset = formatOffset(offsetMinutes); + + return { timeZone, date, time, offset }; +} + +function safeClockForTimeZone(now: Date, timeZone: string): LogClock | null { + try { + return getClockForTimeZone(now, timeZone); + } catch { + return null; + } +} + +function stripLegacyHeader(content: string): string { + const lines = content.split(/\r?\n/); + if (!/^#\s+\d{4}-\d{2}-\d{2}\s*$/.test(lines[0] ?? "")) return content; + + let idx = 1; + while (idx < lines.length) { + const line = lines[idx] ?? ""; + if (line.trim() === "") { + idx += 1; + continue; + } + if (line.trim().startsWith(">")) { + idx += 1; + continue; + } + break; + } + + return lines.slice(idx).join("\n"); +} + +function resolveAuthor(): string { + const name = + process.env.GIT_AUTHOR_NAME || + process.env.GIT_COMMITTER_NAME || + process.env.USER || + "Unknown"; + const email = + process.env.GIT_AUTHOR_EMAIL || + process.env.GIT_COMMITTER_EMAIL || + process.env.EMAIL || + "unknown@example.com"; + + if (name.includes("<") && name.includes(">")) return name; + return `${name} <${email}>`; +} + +function normalizeFrontmatter( + data: Record, + clock: LogClock, + author: string, + titlePrefix?: string, +): { data: Record; changed: boolean } { + const next = { ...data }; + let changed = false; + + if (!next.title) { + const prefix = titlePrefix?.trim(); + next.title = prefix ? `${prefix} ${clock.date}` : `Daily Log ${clock.date}`; + changed = true; + } + if (!next.author) { + next.author = author; + changed = true; + } + if (!next.date) { + next.date = clock.date; + changed = true; + } + if (!next.tz) { + next.tz = clock.timeZone; + changed = true; + } + if (!next.created) { + next.created = `${clock.date}T${clock.time}${clock.offset}`; + changed = true; + } + + return { data: next, changed }; +} + +type SectionTimestamp = { date: Date; value: string }; + +function findLastSectionTimestamp( + lines: string[], + clock: LogClock, +): SectionTimestamp | null { + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i] ?? ""; + const match = line.match(SECTION_RE); + if (!match) continue; + const time = `${match[1]}:${match[2]}:00`; + const value = `${clock.date}T${time}${clock.offset}`; + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return { date: parsed, value }; + } + return null; +} + +function ensureTrailingNewline(text: string): string { + if (text === "") return ""; + return text.endsWith("\n") ? text : `${text}\n`; +} + +function ensureBlankLine(text: string): string { + if (text === "") return ""; + if (text.endsWith("\n\n")) return text; + if (text.endsWith("\n")) return `${text}\n`; + return `${text}\n\n`; +} + +function joinFrontmatterAndBody(frontmatter: string, body: string): string { + const trimmedBody = body.replace(/^\n+/, ""); + if (!trimmedBody) return frontmatter; + return `${frontmatter}${trimmedBody}`; +} + +function normalizeEntryBody(message: string): string { + const normalized = message.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + const withoutLeadingBlank = normalized.replace(/^\n+/, ""); + return withoutLeadingBlank.replace(/\n+$/, ""); +} + +function buildEntryText(message: string): string { + return normalizeEntryBody(message); +} + +function parseIsoTimestamp(value: string | undefined): Date | null { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} + +export async function runLog( + message: string, + options: LogOptions, +): Promise { + try { + const normalizedMessage = message + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + if (!normalizedMessage.trim()) + throw new Error("Log entry message is required."); + + const config = await loadConfig(process.cwd()); + const thresholdMinutes = + options.thresholdMinutes !== undefined + ? parseThresholdMinutes(options.thresholdMinutes) + : config.simplelog.thresholdMinutes; + const now = new Date(); + + const baseDir = config.repoRootAbs; + const rootDir = options.root + ? path.resolve(baseDir, options.root) + : path.resolve(baseDir, config.simplelog.root); + + await fs.mkdir(rootDir, { recursive: true }); + + let timeZone = config.simplelog.timezone ?? getDefaultTimeZone(); + let clock = getClockForTimeZone(now, timeZone); + let filePath = path.join(rootDir, `${clock.date}.md`); + + let content = ""; + let fileExists = false; + + try { + content = await fs.readFile(filePath, "utf8"); + fileExists = true; + } catch (err) { + if (!(err instanceof Error) || !("code" in err) || err.code !== "ENOENT") + throw err; + } + + let parsed = parseFrontmatterBlock(content); + + if (fileExists && parsed.hasFrontmatter && parsed.data.tz) { + const desiredTz = parsed.data.tz; + if (desiredTz !== timeZone) { + const altClock = safeClockForTimeZone(now, desiredTz); + if (altClock) { + timeZone = altClock.timeZone; + clock = altClock; + const altPath = path.join(rootDir, `${clock.date}.md`); + if (altPath !== filePath) { + filePath = altPath; + try { + content = await fs.readFile(filePath, "utf8"); + fileExists = true; + } catch (err) { + if ( + !(err instanceof Error) || + !("code" in err) || + err.code !== "ENOENT" + ) + throw err; + fileExists = false; + content = ""; + } + parsed = parseFrontmatterBlock(content); + } + } + } + } + + if (!fileExists) { + content = ""; + parsed = { data: {}, body: "", hasFrontmatter: false }; + } + + const body = parsed.hasFrontmatter + ? parsed.body + : stripLegacyHeader(content); + + const author = config.frontmatterDefaults.author ?? resolveAuthor(); + const normalized = normalizeFrontmatter( + parsed.data, + clock, + author, + config.frontmatterDefaults.titlePrefix, + ); + const lines = body.split(/\r?\n/); + const lastSectionFromHeading = findLastSectionTimestamp(lines, clock); + const lastSectionFromFrontmatter = parseIsoTimestamp( + parsed.data.last_section, + ); + const lastSection = + lastSectionFromFrontmatter ?? lastSectionFromHeading?.date ?? null; + + let needsSection = !lastSection; + if (!needsSection && thresholdMinutes > 0 && lastSection) { + const diffMs = now.getTime() - lastSection.getTime(); + if (diffMs >= thresholdMinutes * 60_000) needsSection = true; + } + + let nextBody = ensureTrailingNewline(body); + if (nextBody.trim() !== "") nextBody = ensureBlankLine(nextBody); + if (needsSection) { + const sectionTitle = `## ${clock.time.slice(0, 5)}`; + nextBody += `${sectionTitle}\n`; + nextBody = ensureBlankLine(nextBody); + } + + const entryText = buildEntryText(normalizedMessage); + nextBody += `${entryText}\n`; + + const updated = `${clock.date}T${clock.time}${clock.offset}`; + const updatedFrontmatterData: Record = { + ...normalized.data, + updated, + }; + if (needsSection) { + updatedFrontmatterData.last_section = updated; + } else if ( + !("last_section" in updatedFrontmatterData) && + lastSectionFromHeading + ) { + updatedFrontmatterData.last_section = lastSectionFromHeading.value; + } + if ( + !("tags" in updatedFrontmatterData) && + config.frontmatterDefaults.tags + ) { + updatedFrontmatterData.tags = config.frontmatterDefaults.tags; + } + const updatedFrontmatter = buildFrontmatter(updatedFrontmatterData, { + order: SIMPLELOG_FRONTMATTER_ORDER, + }); + const nextContent = joinFrontmatterAndBody(updatedFrontmatter, nextBody); + await fs.writeFile(filePath, nextContent, "utf8"); + process.stdout.write(`Logged to ${filePath}\n`); + } catch (err) { + process.stderr.write(`${getErrorMessage(err)}\n`); + process.exitCode = 1; + } +} diff --git a/src/cli/migrate.ts b/src/cli/migrate.ts index 1f6ce2c..f63d325 100644 --- a/src/cli/migrate.ts +++ b/src/cli/migrate.ts @@ -42,6 +42,7 @@ import { runFrontmatterStep } from "./steps/frontmatter.js"; import { runInstallSteps } from "./steps/install.js"; import { detectRootMoves, runRootMoveStep } from "./steps/root-move.js"; import { runReferenceUpdatesStep } from "./steps/references.js"; +import { loadConfig } from "../config.js"; type StepPreview = { title: string; @@ -59,12 +60,16 @@ type MigrateOptions = { function buildDefaultPreviews(opts: { plan: MigrationPlan; installActions: InstallAction[]; + docsRoot: string; }): StepPreview[] { const renameActionsAll = opts.plan.actions.filter( (a): a is RenameAction => a.type === "rename", ); - const rootMovesAll = detectRootMoves(renameActionsAll); - const lowercaseRenamesAll = detectLowercaseDocRenames(renameActionsAll); + const rootMovesAll = detectRootMoves(renameActionsAll, opts.docsRoot); + const lowercaseRenamesAll = detectLowercaseDocRenames( + renameActionsAll, + opts.docsRoot, + ); const categorizedRenames = new Set([ ...rootMovesAll.map((a) => a.from), ...lowercaseRenamesAll.map((a) => a.from), @@ -85,7 +90,7 @@ function buildDefaultPreviews(opts: { if (rootMovesAll.length > 0) defaultPreviews.push({ - title: "Relocate root Markdown docs into `docs/` (SimpleDoc convention)", + title: `Relocate root Markdown docs into \`${opts.docsRoot}/\` (SimpleDoc convention)`, actionsText: formatActions(rootMovesAll), actionCount: rootMovesAll.length, }); @@ -172,12 +177,18 @@ function printPreviews(previews: StepPreview[]): void { export async function runMigrate(options: MigrateOptions): Promise { try { + const config = await loadConfig(process.cwd()); process.stderr.write( "Planning changes (this may take a while on large repos)...\n", ); const hasTty = hasInteractiveTty(); const scanProgress = createScanProgressBarReporter(hasTty); - const planAll = await planMigration({ onProgress: scanProgress }); + const planAll = await planMigration({ + onProgress: scanProgress, + docsRoot: config.docsRoot, + ignoreGlobs: config.checkIgnore, + frontmatterDefaults: config.frontmatterDefaults, + }); const installStatus = await getInstallationStatus(planAll.repoRootAbs); const installActionsAll = await buildDefaultInstallActions(installStatus); @@ -200,6 +211,7 @@ export async function runMigrate(options: MigrateOptions): Promise { const defaultPreviews = buildDefaultPreviews({ plan: planAll, installActions: installActionsAll, + docsRoot: config.docsRoot, }); if (defaultPreviews.length === 0) { @@ -244,7 +256,7 @@ export async function runMigrate(options: MigrateOptions): Promise { return; } - intro("simpledoc migrate"); + intro("npx -y @simpledoc/simpledoc migrate"); if (planAll.dirty && !options.force) { const contDirty = await promptConfirm( @@ -259,8 +271,11 @@ export async function runMigrate(options: MigrateOptions): Promise { (a): a is RenameAction => a.type === "rename", ); - const rootMovesAll = detectRootMoves(renameActionsAll); - const lowercaseRenamesAll = detectLowercaseDocRenames(renameActionsAll); + const rootMovesAll = detectRootMoves(renameActionsAll, config.docsRoot); + const lowercaseRenamesAll = detectLowercaseDocRenames( + renameActionsAll, + config.docsRoot, + ); const categorizedRenames = new Set([ ...rootMovesAll.map((a) => a.from), ...lowercaseRenamesAll.map((a) => a.from), @@ -272,7 +287,7 @@ export async function runMigrate(options: MigrateOptions): Promise { const renameCaseOverrides: Record = {}; - const rootMoveSel = await runRootMoveStep(rootMovesAll); + const rootMoveSel = await runRootMoveStep(rootMovesAll, config.docsRoot); if (rootMoveSel === null) return abort("Operation cancelled."); const includeRootMoves = rootMoveSel.include; Object.assign(renameCaseOverrides, rootMoveSel.renameCaseOverrides); @@ -323,6 +338,9 @@ export async function runMigrate(options: MigrateOptions): Promise { forceDatePrefixPaths, forceUndatedPaths, includeCanonicalRenames: Boolean(includeCapitalizedRenames), + docsRoot: config.docsRoot, + ignoreGlobs: config.checkIgnore, + frontmatterDefaults: config.frontmatterDefaults, }); } diff --git a/src/cli/steps/naming-lowercase.ts b/src/cli/steps/naming-lowercase.ts index 570bd63..3d6b922 100644 --- a/src/cli/steps/naming-lowercase.ts +++ b/src/cli/steps/naming-lowercase.ts @@ -17,11 +17,13 @@ function hasDatePrefix(baseName: string): boolean { export function detectLowercaseDocRenames( renameActions: RenameAction[], + docsRoot: string, ): RenameAction[] { + const docsPrefix = `${docsRoot.replace(/\/+$/, "")}/`; return renameActions.filter( (a) => - a.from.startsWith("docs/") && - a.to.startsWith("docs/") && + a.from.startsWith(docsPrefix) && + a.to.startsWith(docsPrefix) && hasDatePrefix(path.posix.basename(a.to)), ); } diff --git a/src/cli/steps/root-move.ts b/src/cli/steps/root-move.ts index 76d2765..26bb73c 100644 --- a/src/cli/steps/root-move.ts +++ b/src/cli/steps/root-move.ts @@ -11,25 +11,34 @@ function formatRenameSources(actions: RenameAction[]): string { return actions.map((action) => `- ${action.from}`).join("\n"); } -export function detectRootMoves(renameActions: RenameAction[]): RenameAction[] { +export function detectRootMoves( + renameActions: RenameAction[], + docsRoot: string, +): RenameAction[] { + const docsPrefix = `${docsRoot.replace(/\/+$/, "")}/`; return renameActions.filter( - (a) => !a.from.includes("/") && a.to.startsWith("docs/"), + (a) => !a.from.includes("/") && a.to.startsWith(docsPrefix), ); } -export async function runRootMoveStep(actions: RenameAction[]): Promise<{ +export async function runRootMoveStep( + actions: RenameAction[], + docsRoot: string, +): Promise<{ include: boolean; renameCaseOverrides: Record; } | null> { if (actions.length === 0) return { include: false, renameCaseOverrides: {} }; + const docsRootLabel = `${docsRoot.replace(/\/+$/, "")}/`; + noteWrapped( - `Markdown files detected in the repo root (will be moved into \`docs/\`):\n\n${limitLines(formatRenameSources(actions), MAX_STEP_FILE_PREVIEW_LINES)}`, - `Proposed: Relocate root Markdown docs into \`docs/\` (${actions.length})`, + `Markdown files detected in the repo root (will be moved into \`${docsRootLabel}\`):\n\n${limitLines(formatRenameSources(actions), MAX_STEP_FILE_PREVIEW_LINES)}`, + `Proposed: Relocate root Markdown docs into \`${docsRootLabel}\` (${actions.length})`, ); const choice = await promptSelect<"yes" | "customize" | "no">( - `Move ${actions.length} root Markdown file${actions.length === 1 ? "" : "s"} into \`docs/\`?`, + `Move ${actions.length} root Markdown file${actions.length === 1 ? "" : "s"} into \`${docsRootLabel}\`?`, [ { label: "Yes", value: "yes" }, { label: "Customize", value: "customize" }, diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fbf7077 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,270 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { createGitClient, type GitClient } from "./git.js"; +import { normalizeDocsRoot } from "./paths.js"; + +type FrontmatterDefaults = { + author?: string; + tags?: string[]; + titlePrefix?: string; +}; + +type SimpleLogConfig = { + root?: string; + thresholdMinutes?: number; + timezone?: string; +}; + +type DocsConfig = { + root?: string; +}; + +type CheckConfig = { + ignore?: string[]; +}; + +type FrontmatterConfig = { + defaults?: FrontmatterDefaults; +}; + +export type SimpleDocConfig = { + docs?: DocsConfig; + frontmatter?: FrontmatterConfig; + check?: CheckConfig; + simplelog?: SimpleLogConfig; +}; + +export type ResolvedSimpleDocConfig = { + repoRootAbs: string; + docsRoot: string; + frontmatterDefaults: FrontmatterDefaults; + checkIgnore: string[]; + simplelog: { + root: string; + thresholdMinutes: number; + timezone?: string; + }; +}; + +const DEFAULT_DOCS_ROOT = "docs"; +const DEFAULT_SIMPLELOG_THRESHOLD_MINUTES = 5; + +function isPlainObject(value: unknown): value is Record { + if (!value || typeof value !== "object") return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + +function mergeConfig( + base: SimpleDocConfig, + override: SimpleDocConfig, +): SimpleDocConfig { + const out: SimpleDocConfig = { ...base }; + for (const [key, value] of Object.entries(override)) { + const existing = (out as Record)[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + (out as Record)[key] = mergeConfig( + existing as SimpleDocConfig, + value as SimpleDocConfig, + ); + } else { + (out as Record)[key] = value as unknown; + } + } + return out; +} + +async function readConfigFile( + absPath: string, +): Promise { + try { + const raw = await fs.readFile(absPath, "utf8"); + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + const data = JSON.parse(trimmed) as SimpleDocConfig; + if (!isPlainObject(data)) + throw new Error("Config must be a JSON object at the top level."); + return data; + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") + return null; + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to read config ${absPath}: ${message}`); + } +} + +function normalizeRelPath( + input: unknown, + repoRootAbs: string, + fallback: string, + label: string, +): string { + if (input === undefined) return fallback; + if (typeof input !== "string") throw new Error(`${label} must be a string`); + const trimmed = input.trim(); + if (!trimmed) return fallback; + + let relPath = trimmed; + if (path.isAbsolute(relPath)) { + const relative = path.relative(repoRootAbs, relPath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error(`Config path must be inside repo: ${relPath}`); + relPath = relative; + } + + relPath = relPath.replace(/\\/g, "/"); + relPath = relPath.replace(/^\.\//, ""); + relPath = relPath.replace(/\/+$/, ""); + relPath = path.posix.normalize(relPath); + if (relPath === "." || relPath === "") return fallback; + if (relPath.startsWith("../")) + throw new Error(`Config path must be inside repo: ${relPath}`); + return relPath; +} + +function normalizeTags(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) + throw new Error("frontmatter.defaults.tags must be an array of strings"); + const tags = value + .map((item) => { + if (typeof item !== "string") + throw new Error( + "frontmatter.defaults.tags must be an array of strings", + ); + return item.trim(); + }) + .filter((item) => item.length > 0); + return tags.length > 0 ? tags : undefined; +} + +function normalizeFrontmatterDefaults(value: unknown): FrontmatterDefaults { + if (value === undefined) return {}; + if (!isPlainObject(value)) + throw new Error("frontmatter.defaults must be an object"); + const author = + typeof value.author === "string" + ? value.author.trim() || undefined + : value.author === undefined + ? undefined + : (() => { + throw new Error("frontmatter.defaults.author must be a string"); + })(); + const titlePrefix = + typeof value.titlePrefix === "string" + ? value.titlePrefix.trim() || undefined + : value.titlePrefix === undefined + ? undefined + : (() => { + throw new Error( + "frontmatter.defaults.titlePrefix must be a string", + ); + })(); + const tags = normalizeTags(value.tags); + return { author, titlePrefix, tags }; +} + +function normalizeCheckIgnore(value: unknown): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) + throw new Error("check.ignore must be an array of strings"); + const patterns = value + .map((item) => { + if (typeof item !== "string") + throw new Error("check.ignore must be an array of strings"); + return item.trim(); + }) + .filter((item) => item.length > 0); + return patterns; +} + +function normalizeThreshold(value: unknown): number { + if (value === undefined) return DEFAULT_SIMPLELOG_THRESHOLD_MINUTES; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) + throw new Error("simplelog.thresholdMinutes must be a number >= 0"); + return value; +} + +function normalizeOptionalString( + value: unknown, + label: string, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new Error(`${label} must be a string`); + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function assertPlainObjectOptional(value: unknown, label: string): void { + if (value === undefined) return; + if (!isPlainObject(value)) throw new Error(`${label} must be an object`); +} + +export async function loadConfig( + cwd: string, + opts?: { git?: GitClient }, +): Promise { + const git = opts?.git ?? createGitClient(); + let repoRootAbs = cwd; + try { + repoRootAbs = await git.getRepoRoot(cwd); + } catch { + repoRootAbs = cwd; + } + + const repoConfigPath = path.join(repoRootAbs, "simpledoc.json"); + const localConfigPath = path.join(repoRootAbs, ".simpledoc.local.json"); + + const repoConfig = (await readConfigFile(repoConfigPath)) ?? {}; + const localConfig = (await readConfigFile(localConfigPath)) ?? {}; + const merged = mergeConfig(repoConfig, localConfig); + + assertPlainObjectOptional(merged.docs, "docs"); + assertPlainObjectOptional(merged.frontmatter, "frontmatter"); + assertPlainObjectOptional( + merged.frontmatter?.defaults, + "frontmatter.defaults", + ); + assertPlainObjectOptional(merged.check, "check"); + assertPlainObjectOptional(merged.simplelog, "simplelog"); + + const docsRoot = normalizeDocsRoot( + normalizeRelPath( + merged.docs?.root, + repoRootAbs, + DEFAULT_DOCS_ROOT, + "docs.root", + ), + ); + const defaultSimplelogRoot = path.posix.join(docsRoot, "logs"); + const simplelogRoot = normalizeRelPath( + merged.simplelog?.root, + repoRootAbs, + defaultSimplelogRoot, + "simplelog.root", + ); + const thresholdMinutes = normalizeThreshold( + merged.simplelog?.thresholdMinutes, + ); + const timezone = normalizeOptionalString( + merged.simplelog?.timezone, + "simplelog.timezone", + ); + + const frontmatterDefaults = normalizeFrontmatterDefaults( + merged.frontmatter?.defaults, + ); + const checkIgnore = normalizeCheckIgnore(merged.check?.ignore); + + return { + repoRootAbs, + docsRoot, + frontmatterDefaults, + checkIgnore, + simplelog: { + root: simplelogRoot, + thresholdMinutes, + timezone, + }, + }; +} diff --git a/src/doc-classifier.ts b/src/doc-classifier.ts index eb06ea5..0ae4dd2 100644 --- a/src/doc-classifier.ts +++ b/src/doc-classifier.ts @@ -6,6 +6,7 @@ import { isAllCapsDocBaseName, isMarkdownFile, } from "./naming.js"; +import { normalizeDocsRoot } from "./paths.js"; export type DocLocation = "root" | "docs" | "other"; @@ -18,13 +19,18 @@ export type DocClassification = { shouldDatePrefix: boolean; }; -export function classifyDoc(relPath: string): DocClassification { +export function classifyDoc( + relPath: string, + opts?: { docsRoot?: string }, +): DocClassification { const baseName = path.posix.basename(relPath); if (!isMarkdownFile(baseName)) throw new Error(`classifyDoc expected a Markdown file, got: ${relPath}`); + const docsRoot = normalizeDocsRoot(opts?.docsRoot ?? "docs"); + const docsPrefix = `${docsRoot}/`; const location: DocLocation = relPath.includes("/") - ? relPath.startsWith("docs/") + ? relPath.startsWith(docsPrefix) ? "docs" : "other" : "root"; diff --git a/src/frontmatter.ts b/src/frontmatter.ts new file mode 100644 index 0000000..d6034f9 --- /dev/null +++ b/src/frontmatter.ts @@ -0,0 +1,89 @@ +export type FrontmatterValue = string | string[]; + +export type ParsedFrontmatter = { + data: Record; + body: string; + hasFrontmatter: boolean; +}; + +export const DOC_FRONTMATTER_ORDER = ["title", "author", "date", "tags"]; + +export const SIMPLELOG_FRONTMATTER_ORDER = [ + "title", + "author", + "date", + "tz", + "created", + "last_section", + "updated", + "tags", +]; + +function yamlQuote(value: string): string { + const s = String(value).replace(/\r?\n/g, " ").trim(); + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function formatArray(values: string[]): string { + const rendered = values.map((value) => yamlQuote(value)); + return `[${rendered.join(", ")}]`; +} + +function formatValue(value: FrontmatterValue, quoteStrings: boolean): string { + if (Array.isArray(value)) return formatArray(value); + if (quoteStrings) return yamlQuote(value); + return value; +} + +function isEmptyValue(value: FrontmatterValue | undefined): boolean { + if (value === undefined) return true; + if (Array.isArray(value)) return value.length === 0; + return value.trim() === ""; +} + +export function buildFrontmatter( + data: Record, + opts?: { order?: string[]; quoteStrings?: boolean }, +): string { + const order = opts?.order ?? []; + const quoteStrings = opts?.quoteStrings ?? false; + const keys = Object.keys(data).filter((key) => !isEmptyValue(data[key])); + const preferred = order.filter((key) => keys.includes(key)); + const extras = keys.filter((key) => !order.includes(key)).sort(); + const finalKeys = [...preferred, ...extras]; + + const lines = finalKeys.map((key) => { + const value = data[key] as FrontmatterValue; + return `${key}: ${formatValue(value, quoteStrings)}`; + }); + + return `---\n${lines.join("\n")}\n---\n\n`; +} + +export function parseFrontmatterBlock(content: string): ParsedFrontmatter { + const lines = content.split(/\r?\n/); + if (lines[0] !== "---") { + return { data: {}, body: content, hasFrontmatter: false }; + } + + let end = -1; + for (let i = 1; i < lines.length; i += 1) { + if (lines[i] === "---") { + end = i; + break; + } + } + + if (end === -1) { + return { data: {}, body: content, hasFrontmatter: false }; + } + + const data: Record = {}; + for (const line of lines.slice(1, end)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (match) data[match[1]] = match[2]; + } + + const body = lines.slice(end + 1).join("\n"); + return { data, body, hasFrontmatter: true }; +} diff --git a/src/ignore.ts b/src/ignore.ts new file mode 100644 index 0000000..a3a38b9 --- /dev/null +++ b/src/ignore.ts @@ -0,0 +1,52 @@ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function globToRegexSource(pattern: string): string { + let out = ""; + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i]!; + if (ch === "*") { + if (pattern[i + 1] === "*") { + const next = pattern[i + 2]; + if (next === "/") { + out += "(?:.*/)?"; + i += 2; + } else { + out += ".*"; + i += 1; + } + continue; + } + out += "[^/]*"; + continue; + } + if (ch === "?") { + out += "[^/]"; + continue; + } + out += escapeRegExp(ch); + } + return out; +} + +function globToRegExp(pattern: string): RegExp { + let normalized = pattern.replace(/\\/g, "/").replace(/^\.\/+/, ""); + normalized = normalized.replace(/\/+$/, ""); + if (normalized.endsWith("/**")) { + const base = normalized.slice(0, -3); + return new RegExp(`^${globToRegexSource(base)}(?:/.*)?$`); + } + return new RegExp(`^${globToRegexSource(normalized)}$`); +} + +export function buildIgnoreMatcher( + patterns: string[], +): (relPath: string) => boolean { + if (patterns.length === 0) return () => false; + const regexes = patterns.map((pattern) => globToRegExp(pattern)); + return (relPath: string): boolean => { + const normalized = relPath.replace(/\\/g, "/"); + return regexes.some((regex) => regex.test(normalized)); + }; +} diff --git a/src/migrator.ts b/src/migrator.ts index 98038d5..c26e400 100644 --- a/src/migrator.ts +++ b/src/migrator.ts @@ -11,6 +11,9 @@ import { isLowercaseDocBaseName, isMarkdownFile, } from "./naming.js"; +import { normalizeDocsRoot } from "./paths.js"; +import { buildIgnoreMatcher } from "./ignore.js"; +import { buildFrontmatter, DOC_FRONTMATTER_ORDER } from "./frontmatter.js"; export type { RenameCaseMode } from "./naming.js"; @@ -26,6 +29,7 @@ export type FrontmatterAction = { title: string; author: string; date: string; + tags?: string[]; }; export type MigrationAction = | RenameAction @@ -36,6 +40,7 @@ export type MigrationPlan = { repoRootAbs: string; trackedSet: Set; dirty: boolean; + docsRoot: string; actions: MigrationAction[]; }; @@ -70,15 +75,6 @@ function hasFrontmatter(content: string): boolean { return after.startsWith("---\n") || after.startsWith("---\r\n"); } -function yamlQuote(value: string): string { - const s = String(value).replace(/\r?\n/g, " ").trim(); - return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - type ReferenceReplacement = { from: string; to: string }; function buildReferenceReplacements( @@ -185,22 +181,21 @@ async function readSmallTextFileForReferences( return content; } -function buildFrontmatter({ +function buildDocFrontmatter({ title, author, date, + tags, }: { title: string; author: string; date: string; + tags?: string[]; }): string { - return [ - "---", - `title: ${yamlQuote(title)}`, - `author: ${yamlQuote(author)}`, - `date: ${yamlQuote(date)}`, - "---", - ].join("\n"); + return buildFrontmatter( + { title, author, date, tags }, + { order: DOC_FRONTMATTER_ORDER, quoteStrings: true }, + ); } async function getFileSystemInfo( @@ -296,6 +291,10 @@ async function listRootFiles(repoRootAbs: string): Promise { .filter((name) => !name.startsWith(".")); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export function formatActions(actions: MigrationAction[]): string { const lines: string[] = []; for (const action of actions) { @@ -315,6 +314,13 @@ export function formatActions(actions: MigrationAction[]): string { export type MigrationPlanOptions = { cwd?: string; + docsRoot?: string; + ignoreGlobs?: string[]; + frontmatterDefaults?: { + author?: string; + tags?: string[]; + titlePrefix?: string; + }; moveRootMarkdownToDocs?: boolean; renameDocsToDatePrefix?: boolean; addFrontmatter?: boolean; @@ -335,6 +341,9 @@ export async function planMigration( options: MigrationPlanOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); + const docsRoot = normalizeDocsRoot(options.docsRoot ?? "docs"); + const docsRootPrefix = `${docsRoot}/`; + const ignoreMatcher = buildIgnoreMatcher(options.ignoreGlobs ?? []); const moveRootMarkdownToDocs = options.moveRootMarkdownToDocs ?? true; const renameDocsToDatePrefix = options.renameDocsToDatePrefix ?? true; const addFrontmatter = options.addFrontmatter ?? true; @@ -353,6 +362,7 @@ export async function planMigration( const rootFiles = await listRootFiles(repoRootAbs); const rootMarkdown = rootFiles.filter((f) => { + if (ignoreMatcher(f)) return false; if (!isMarkdownFile(f)) return false; return ( Boolean(getCanonicalBaseName(f)) || @@ -363,17 +373,20 @@ export async function planMigration( const existingAll = await git.listRepoFiles(repoRootAbs); const existingOnDisk: string[] = []; + const existingOnDiskAll: string[] = []; const totalFiles = existingAll.length; for (let idx = 0; idx < existingAll.length; idx++) { const filePath = existingAll[idx]!; - if (await pathExists(repoRootAbs, filePath)) existingOnDisk.push(filePath); + const exists = await pathExists(repoRootAbs, filePath); + if (exists) existingOnDiskAll.push(filePath); + if (!ignoreMatcher(filePath) && exists) existingOnDisk.push(filePath); if (onProgress) onProgress({ phase: "scan", current: idx + 1, total: totalFiles }); } - const existingAllSet = new Set(existingOnDisk); + const existingAllSet = new Set(existingOnDiskAll); const docsMarkdown = existingOnDisk.filter( - (p) => p.startsWith("docs/") && isMarkdownFile(p), + (p) => p.startsWith(docsRootPrefix) && isMarkdownFile(p), ); const candidates = [...new Set([...rootMarkdown, ...docsMarkdown])]; @@ -392,7 +405,7 @@ export async function planMigration( const desiredTargets = new Map(); for (const filePath of candidates) { - const classification = classifyDoc(filePath); + const classification = classifyDoc(filePath, { docsRoot }); const base = classification.baseName; const desiredMode: RenameCaseMode = renameCaseOverrides[filePath] ?? classification.mode; @@ -427,7 +440,7 @@ export async function planMigration( const targetBase = normalizeDatePrefixedDocs ? applyRenameCase(base, desiredMode) : base; - desiredTargets.set(filePath, path.posix.join("docs", targetBase)); + desiredTargets.set(filePath, path.posix.join(docsRoot, targetBase)); continue; } @@ -439,7 +452,7 @@ export async function planMigration( const slug = applyRenameCase(base, desiredMode); desiredTargets.set( filePath, - path.posix.join("docs", `${meta.date}-${slug}`), + path.posix.join(docsRoot, `${meta.date}-${slug}`), ); continue; } @@ -565,15 +578,23 @@ export async function planMigration( if (hasFrontmatter(content)) continue; const meta = await getMeta(filePath); - const author = meta.author ?? "Unknown "; + const defaultAuthor = options.frontmatterDefaults?.author; + const author = + defaultAuthor ?? meta.author ?? "Unknown "; const date = datePrefix; - const title = titleFromMarkdown(content) ?? titleFromSlug(targetBase); + const titleFromDoc = titleFromMarkdown(content); + let title = titleFromDoc ?? titleFromSlug(targetBase); + if (!titleFromDoc && options.frontmatterDefaults?.titlePrefix) { + const prefix = options.frontmatterDefaults.titlePrefix.trim(); + title = `${prefix} ${title}`.trim(); + } frontmatterAdds.push({ type: "frontmatter", path: targetPath, title, author, date, + tags: options.frontmatterDefaults?.tags, }); } } @@ -587,9 +608,10 @@ export async function planMigration( const searchStrings = [...new Set(finalRenames.map((r) => r.from))]; const candidatesForScan = new Set(); for (const filePath of await git.grepFilesFixed(repoRootAbs, searchStrings)) - candidatesForScan.add(filePath); + if (!ignoreMatcher(filePath)) candidatesForScan.add(filePath); for (const filePath of existingOnDisk) { if (trackedSet.has(filePath)) continue; + if (ignoreMatcher(filePath)) continue; candidatesForScan.add(filePath); } @@ -616,6 +638,7 @@ export async function planMigration( repoRootAbs, trackedSet, dirty, + docsRoot, actions, }; } @@ -728,10 +751,11 @@ export async function runMigrationPlan( options?.authorOverride ?? options?.authorRewrites?.[action.author] ?? action.author; - const frontmatter = buildFrontmatter({ + const frontmatter = buildDocFrontmatter({ title: action.title, author, date: action.date, + tags: action.tags, }); await writeFrontmatter(plan.repoRootAbs, action.path, frontmatter); } diff --git a/src/naming.ts b/src/naming.ts index 32ab242..e792820 100644 --- a/src/naming.ts +++ b/src/naming.ts @@ -119,13 +119,13 @@ function normalizeBaseName(baseName: string, mode: RenameCaseMode): string { const dateParts = parseDatePrefixedStem(stem); if (dateParts) { - const fallback = mode === "capitalized" ? "UNTITLED" : "untitled"; - const rest = dateParts.rest || fallback; + if (!dateParts.rest) return `${dateParts.date}${ext}`; const restNormalized = mode === "capitalized" - ? normalizeCapitalizedStem(rest) - : normalizeLowercaseStem(rest); - return `${dateParts.date}-${restNormalized || fallback}${ext}`; + ? normalizeCapitalizedStem(dateParts.rest) + : normalizeLowercaseStem(dateParts.rest); + if (!restNormalized) return `${dateParts.date}${ext}`; + return `${dateParts.date}-${restNormalized}${ext}`; } const normalizedStem = diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..4e15a76 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,8 @@ +export function normalizeDocsRoot(root?: string): string { + const normalized = (root ?? "docs") + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + if (!normalized || normalized === ".") return "docs"; + return normalized; +} diff --git a/test/integration/config.test.ts b/test/integration/config.test.ts new file mode 100644 index 0000000..e9989e4 --- /dev/null +++ b/test/integration/config.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { loadConfig } from "../../src/config.js"; +import { makeTempRepo, writeFile } from "../helpers/repo.js"; + +test("config: merges repo and local overrides", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile( + repo.dir, + "simpledoc.json", + JSON.stringify( + { + docs: { root: "documentation" }, + frontmatter: { defaults: { author: "Repo " } }, + check: { ignore: ["docs/generated/**"] }, + simplelog: { thresholdMinutes: 7 }, + }, + null, + 2, + ) + "\n", + ); + + await writeFile( + repo.dir, + ".simpledoc.local.json", + JSON.stringify( + { + frontmatter: { + defaults: { author: "Local " }, + }, + simplelog: { root: "docs/logs/_local/alice", thresholdMinutes: 2 }, + }, + null, + 2, + ) + "\n", + ); + + const config = await loadConfig(repo.dir); + assert.equal(config.docsRoot, "documentation"); + assert.equal(config.simplelog.root, "docs/logs/_local/alice"); + assert.equal(config.simplelog.thresholdMinutes, 2); + assert.equal(config.frontmatterDefaults.author, "Local "); + assert.deepEqual(config.checkIgnore, ["docs/generated/**"]); +}); + +test("config: defaults simplelog root to docs root", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile( + repo.dir, + "simpledoc.json", + JSON.stringify({ docs: { root: "documentation" } }, null, 2) + "\n", + ); + + const config = await loadConfig(repo.dir); + assert.equal(config.docsRoot, "documentation"); + assert.equal(config.simplelog.root, "documentation/logs"); +}); + +test("config: rejects invalid value types", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile( + repo.dir, + "simpledoc.json", + JSON.stringify( + { + docs: { root: 123 }, + check: { ignore: "docs/generated/**" }, + simplelog: { thresholdMinutes: "5" }, + }, + null, + 2, + ) + "\n", + ); + + await assert.rejects( + () => loadConfig(repo.dir), + /docs\.root must be a string/, + ); +}); + +test("config: rejects invalid nested values", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile( + repo.dir, + "simpledoc.json", + JSON.stringify( + { + frontmatter: { defaults: { author: 42, tags: ["ok", 1] } }, + }, + null, + 2, + ) + "\n", + ); + + await assert.rejects( + () => loadConfig(repo.dir), + /frontmatter\.defaults\.author must be a string/, + ); +}); diff --git a/test/integration/frontmatter.test.ts b/test/integration/frontmatter.test.ts new file mode 100644 index 0000000..53f4e43 --- /dev/null +++ b/test/integration/frontmatter.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildFrontmatter, + parseFrontmatterBlock, + DOC_FRONTMATTER_ORDER, +} from "../../src/frontmatter.js"; + +test("frontmatter: builds ordered doc frontmatter", async () => { + const fm = buildFrontmatter( + { + title: "Hello", + author: "Alice ", + date: "2026-02-04", + tags: ["alpha", "beta"], + extra: "note", + }, + { order: DOC_FRONTMATTER_ORDER, quoteStrings: true }, + ); + + const lines = fm.trim().split("\n"); + assert.equal(lines[0], "---"); + const keys = lines.slice(1, 6).map((line) => line.split(":")[0]); + assert.deepEqual(keys, ["title", "author", "date", "tags", "extra"]); + assert.match(fm, /tags: \["alpha", "beta"\]/); +}); + +test("frontmatter: parses a frontmatter block", async () => { + const content = [ + "---", + "title: Test Doc", + "author: Someone ", + "date: 2026-02-04", + "---", + "", + "Body line", + "", + ].join("\n"); + + const parsed = parseFrontmatterBlock(content); + assert.equal(parsed.hasFrontmatter, true); + assert.equal(parsed.data.title, "Test Doc"); + assert.equal(parsed.data.author, "Someone "); + assert.match(parsed.body, /Body line/); +}); diff --git a/test/integration/log.test.ts b/test/integration/log.test.ts new file mode 100644 index 0000000..6f00e1c --- /dev/null +++ b/test/integration/log.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { runLog } from "../../src/cli/log.js"; +import { parseFrontmatterBlock } from "../../src/frontmatter.js"; +import { makeTempRepo, readFile, writeFile } from "../helpers/repo.js"; + +async function withMockedDate( + iso: string, + fn: () => Promise, +): Promise { + const RealDate = Date; + const fixed = new RealDate(iso); + const fixedMs = fixed.getTime(); + class MockDate extends RealDate { + constructor(...args: unknown[]) { + if (args.length === 0) { + super(fixedMs); + return; + } + if (args.length === 1) { + super(args[0] as string | number | Date); + return; + } + const [year, month, date, hours, minutes, seconds, ms] = args as number[]; + super(year, month, date, hours, minutes, seconds, ms); + } + + static now(): number { + return fixedMs; + } + } + + globalThis.Date = MockDate as DateConstructor; + try { + await fn(); + } finally { + globalThis.Date = RealDate; + } +} + +test("log: starts a new section after the threshold", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile( + repo.dir, + "simpledoc.json", + JSON.stringify( + { + simplelog: { + root: "docs/logs", + timezone: "UTC", + thresholdMinutes: 5, + }, + frontmatter: { defaults: { author: "Test " } }, + }, + null, + 2, + ) + "\n", + ); + + const originalCwd = process.cwd(); + t.after(() => process.chdir(originalCwd)); + process.chdir(repo.dir); + + await withMockedDate("2026-02-04T12:00:00Z", async () => { + await runLog("First entry", {}); + }); + + await withMockedDate("2026-02-04T12:03:00Z", async () => { + await runLog("Second entry", {}); + }); + + await withMockedDate("2026-02-04T12:10:00Z", async () => { + await runLog("Third entry", {}); + }); + + const content = await readFile(repo.dir, "docs/logs/2026-02-04.md"); + const sectionCount = (content.match(/^## /gm) ?? []).length; + assert.equal(sectionCount, 2); + assert.match(content, /^## 12:00/m); + assert.match(content, /^## 12:10/m); + assert.match(content, /First entry/); + assert.match(content, /Second entry/); + assert.match(content, /Third entry/); + + const parsed = parseFrontmatterBlock(content); + assert.equal(parsed.data.last_section, "2026-02-04T12:10:00Z"); +}); diff --git a/test/integration/migrator.apply.test.ts b/test/integration/migrator.apply.test.ts index 164f068..e9f6835 100644 --- a/test/integration/migrator.apply.test.ts +++ b/test/integration/migrator.apply.test.ts @@ -58,3 +58,30 @@ test("apply: inserts frontmatter using git author and date prefix", async (t) => assert.match(doc, /author: "Alice "/); assert.match(doc, /date: "2024-03-02"/); }); + +test("apply: uses frontmatter defaults for author, tags, and title prefix", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile(repo.dir, "docs/2024-03-02-some-doc.md", "Body\n"); + commitAll(repo.dir, { + message: "Add date-prefixed doc", + author: "Alice ", + dateIso: "2024-04-01T12:00:00Z", + }); + + const plan = await planMigration({ + cwd: repo.dir, + frontmatterDefaults: { + author: "Default ", + tags: ["alpha", "beta"], + titlePrefix: "Note", + }, + }); + await runMigrationPlan(plan, { authorOverride: null, authorRewrites: null }); + + const doc = await readFile(repo.dir, "docs/2024-03-02-some-doc.md"); + assert.match(doc, /title: "Note Some Doc"/); + assert.match(doc, /author: "Default "/); + assert.match(doc, /tags: \["alpha", "beta"\]/); +}); diff --git a/test/integration/migrator.plan.test.ts b/test/integration/migrator.plan.test.ts index 9d95329..bb36ece 100644 --- a/test/integration/migrator.plan.test.ts +++ b/test/integration/migrator.plan.test.ts @@ -114,6 +114,24 @@ test("plan: treats YYYY-MM-DD_ as date-prefixed and normalizes separators", asyn ]); }); +test("plan: allows date-only docs without slug", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile(repo.dir, "docs/logs/2024-06-01.md", "# Log\n"); + commitAll(repo.dir, { + message: "Add date-only log", + author: "Alice ", + dateIso: "2024-06-10T12:00:00Z", + }); + + const plan = await planMigration({ cwd: repo.dir }); + const renames = plan.actions.filter( + (a) => a.type === "rename" && a.from === "docs/logs/2024-06-01.md", + ); + assert.deepEqual(renames, []); +}); + test("plan: keeps YYYY-MM-DD prefix dashes even in capitalized mode", async (t) => { const repo = await makeTempRepo(); t.after(repo.cleanup); @@ -316,3 +334,47 @@ test("plan: resolves rename collisions by uniquifying targets", async (t) => { `Expected one target to end with -2.md, got: ${targets.join(", ")}`, ); }); + +test("plan: respects docsRoot option", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile(repo.dir, "documentation/Develop.md", "# Hello\n"); + commitAll(repo.dir, { + message: "Add Develop", + author: "Alice ", + dateIso: "2024-01-15T12:00:00Z", + }); + + const plan = await planMigration({ + cwd: repo.dir, + docsRoot: "documentation", + }); + const renames = plan.actions.filter((a) => a.type === "rename"); + assert.deepEqual(renames, [ + { + type: "rename", + from: "documentation/Develop.md", + to: "documentation/2024-01-15-develop.md", + }, + ]); +}); + +test("plan: ignores paths matched by ignore globs", async (t) => { + const repo = await makeTempRepo(); + t.after(repo.cleanup); + + await writeFile(repo.dir, "docs/generated/Develop.md", "# Hello\n"); + commitAll(repo.dir, { + message: "Add generated doc", + author: "Alice ", + dateIso: "2024-01-15T12:00:00Z", + }); + + const plan = await planMigration({ + cwd: repo.dir, + ignoreGlobs: ["docs/generated/**"], + }); + + assert.equal(plan.actions.length, 0); +});