diff --git a/openspec/changes/support-index-md-as-section-file/.openspec.yaml b/openspec/changes/support-index-md-as-section-file/.openspec.yaml new file mode 100644 index 00000000..8b769149 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-20 diff --git a/openspec/changes/support-index-md-as-section-file/design.md b/openspec/changes/support-index-md-as-section-file/design.md new file mode 100644 index 00000000..77f86f1f --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/design.md @@ -0,0 +1,56 @@ +## Context + +Docforge hardcodes `const sectionFile = "_index.md"` to identify section index files — nodes that define section metadata without fetching upstream content. Hugo uses `_index.md` as its section index convention, but VitePress and other modern SSGs use `index.md`. When repos migrate from Hugo to VitePress and rename their section files, docforge silently breaks: it treats `index.md` as a normal content file, attempts source resolution, and fails. + +The docsy plugin already checks for both filenames (`pkg/manifestplugins/docsy/plugin.go:22`), proving this gap was recognized but never propagated to the core resolution logic. + +## Goals / Non-Goals + +**Goals:** + +- Recognize `index.md` (without underscore) as a valid section index file alongside `_index.md` +- Maintain identical behavior for existing `_index.md` manifests +- Keep the change minimal and contained — no new configuration surfaces + +**Non-Goals:** + +- Making the set of section file names user-configurable (the set is well-known and stable) +- Changing `--hugo-section-files` CLI behavior or FSWriter output normalization +- Refactoring the entire index file handling system +- Supporting arbitrary filenames as section indices + +## Decisions + +### Decision 1: Package-level helper function over configuration + +**Choice:** Add `isSectionFile(name string) bool` helper that checks both names. + +**Alternatives considered:** + +| Option | Description | Why rejected | +|--------|-------------|--------------| +| Wire `IndexFileNames` config into resolution | Thread existing config through manifest resolver | Unnecessary plumbing for a two-element set that won't grow | +| Unify under `--hugo-section-files` flag | Single config point for all section file handling | High-risk refactor, mixes output normalization with input detection | + +**Rationale:** The set `{"_index.md", "index.md"}` is a well-known convention pair unlikely to expand. A simple predicate function is easier to audit, test, and maintain than configuration threading. + +### Decision 2: Inline checks for cross-package call sites + +**Choice:** Use inline `name == "_index.md" || name == "index.md"` in packages outside `pkg/manifest/` (fswriter, frontmatter, alias plugin) rather than exporting the helper. + +**Rationale:** Exporting a helper from `pkg/manifest` would create import dependencies from packages that currently don't depend on it. The two-name check is trivial enough that duplication is preferable to coupling. + +### Decision 3: Preserve TrimSuffix ordering in HugoPrettyPath + +**Choice:** Trim `_index` before `index` in `HugoPrettyPath`. + +**Rationale:** If `index` were trimmed first from `_index.md`, it would leave a trailing `_` in the path. The longer prefix must be stripped first. + +## Risks / Trade-offs + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| `index.md` with a `source:` field treated as section file | None | N/A | Check requires BOTH `isSectionFile(name)` AND `source == ""` | +| Hugo output produces `index.md` instead of `_index.md` | None | N/A | FSWriter line 27 normalization is unchanged — output always uses `_index.md` in Hugo mode | +| Alias plugin generates wrong suffix for `index.md` | Low | Low | Covered by test — same empty-suffix behavior as `_index.md` | +| Future SSG introduces third convention name | Very low | Low | Add one more case to `isSectionFile` — trivial change | diff --git a/openspec/changes/support-index-md-as-section-file/proposal.md b/openspec/changes/support-index-md-as-section-file/proposal.md new file mode 100644 index 00000000..22b0dce7 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/proposal.md @@ -0,0 +1,123 @@ +## Why + +The constant `sectionFile = "_index.md"` is hardcoded throughout docforge. When a node has `file: _index.md` and no `source:`, docforge skips source resolution and auto-generates a frontmatter-only section index. This is the mechanism that allows downstream manifests to define section metadata without fetching content from upstream sources. + +The problem: `index.md` (without the underscore) does NOT trigger this special case. VitePress and other modern static site generators use `index.md` as their section index convention instead of Hugo's `_index.md`. When downstream repos rename `_index.md` to `index.md`, docforge treats the file as a normal content file, resolves its source, and fetches real content — breaking the overlay mechanism silently. + +The docsy plugin (`pkg/manifestplugins/docsy/plugin.go:22`) already checks for both filenames, proving this was a known gap that was never propagated to the other checks. + +## What Changes + +- Replace `const sectionFile = "_index.md"` with a package-level helper function `isSectionFile(name string) bool` that matches both `_index.md` and `index.md` +- Update all hardcoded `_index.md` checks in production code to use the new helper or check both names +- Update `HugoPrettyPath` in `node.go` to also strip the `index` prefix (resolving an existing TODO) + +## Design + +### Approach chosen: Minimal helper function + +Three approaches were considered: + +| Approach | Description | Trade-off | +|----------|-------------|-----------| +| **A (chosen)** | Add `isSectionFile` helper, check both names | Minimal change, low risk, solves the problem | +| B | Wire `IndexFileNames` config into manifest resolution | More plumbing, configurable but unnecessary complexity | +| C | Unify all index file handling under `--hugo-section-files` | Full refactor, high risk for minimal gain | + +**Rationale:** The set of valid section file names (`_index.md`, `index.md`) is well-known and unlikely to grow. A simple helper function is easier to understand, test, and maintain than threading configuration through the manifest resolution layer. + +### Implementation + +```go +// pkg/manifest/manifest.go + +// isSectionFile returns true if the given filename is a section index file. +// Both Hugo (_index.md) and VitePress (index.md) conventions are supported. +func isSectionFile(name string) bool { + return name == "_index.md" || name == "index.md" +} +``` + +**Call sites to update:** + +1. `pkg/manifest/manifest.go:256` — `resolveManifestLinks` early return + ```go + // Before: + if node.File == sectionFile && node.Source == "" { + // After: + if isSectionFile(node.File) && node.Source == "" { + ``` + +2. `pkg/writers/fswriter.go:30` — frontmatter generation guard + ```go + // Before: + if f.Hugo && name == "_index.md" && node != nil && node.Frontmatter != nil && docBlob == nil { + // After: + if f.Hugo && (name == "_index.md" || name == "index.md") && node != nil && node.Frontmatter != nil && docBlob == nil { + ``` + Note: Line 27 (`name = "_index.md"`) stays unchanged — that's the output normalization for Hugo mode via `IndexFileNames`, which is a separate concern. + +3. `pkg/nodeplugins/markdown/document/frontmatter/frontmatter.go:108` — fallback index detection + ```go + // Before: + return name == "_index.md" + // After: + return name == "_index.md" || name == "index.md" + ``` + +4. `pkg/manifestplugins/alias/plugin.go:40` — alias suffix for section files + ```go + // Before: + if child.Name() == "_index.md" { + // After: + if child.Name() == "_index.md" || child.Name() == "index.md" { + ``` + +5. `pkg/manifest/node.go:67` — `HugoPrettyPath` prefix stripping + ```go + // Before: + name = strings.TrimSuffix(name, "_index") + // After: + name = strings.TrimSuffix(name, "_index") + name = strings.TrimSuffix(name, "index") + ``` + Note: order matters — `_index` must be trimmed first, otherwise `index` would leave a trailing `_`. + +6. `pkg/manifestplugins/docsy/plugin.go:22` — already handles both, no change needed. + +### What does NOT change + +- The `const sectionFile` is removed, but its semantics are preserved via `isSectionFile` +- `--hugo-section-files` CLI flag behavior is unchanged (still controls output rename in FSWriter) +- The FSWriter's line 27 (`name = "_index.md"`) stays — this is Hugo output normalization, separate from section file detection +- All existing `_index.md` manifests continue working identically + +## Capabilities + +### New Capabilities + +- Manifests can use `file: index.md` (without source) as a section index, equivalent to `file: _index.md` + +### Modified Capabilities + +- `section-file-detection`: Both `_index.md` and `index.md` without a `source:` field are treated as auto-generated section indices +- `alias-generation`: Alias suffix calculation treats `index.md` the same as `_index.md` (empty suffix) +- `frontmatter-generation`: The FSWriter generates frontmatter-only content for both `_index.md` and `index.md` when `docBlob` is nil +- `index-file-detection`: The frontmatter processor's `nodeIsIndexFile` fallback recognizes both names +- `hugo-pretty-path`: URL path generation correctly strips `index` prefix (not just `_index`) + +## Impact + +- **CLI**: No flag changes +- **Manifest schema**: `file: index.md` without `source:` now behaves identically to `file: _index.md` without `source:` — previously it would attempt (and likely fail) source resolution +- **Code**: ~5 files modified, no packages added or removed +- **Downstream consumers**: Repos that renamed `_index.md` to `index.md` (e.g., VitePress migrations) will work correctly without needing to revert the rename +- **Backwards compatibility**: Existing `_index.md` usage is entirely unaffected +- **Risk**: Low — the change is additive and each call site is a simple boolean expansion + +## Testing + +- Update existing `manifest_test.go` `_index.md` test entry to also cover `index.md` variant +- Add a test case in `resolveManifestLinks` for `file: index.md` with no source (should return nil) +- Update alias plugin tests to verify `index.md` gets empty suffix +- Update frontmatter tests to verify `index.md` is detected as index file diff --git a/openspec/changes/support-index-md-as-section-file/specs/alias-generation/spec.md b/openspec/changes/support-index-md-as-section-file/specs/alias-generation/spec.md new file mode 100644 index 00000000..d877a660 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/specs/alias-generation/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Alias suffix for section index files +The alias plugin SHALL assign an empty suffix to nodes whose name equals `_index.md` OR `index.md`. All other nodes SHALL receive a suffix derived from their name. + +#### Scenario: Node named _index.md gets empty alias suffix +- **WHEN** a child node has name `_index.md` +- **THEN** the alias plugin SHALL assign an empty string as the alias suffix + +#### Scenario: Node named index.md gets empty alias suffix +- **WHEN** a child node has name `index.md` +- **THEN** the alias plugin SHALL assign an empty string as the alias suffix + +#### Scenario: Node with regular name gets normal alias suffix +- **WHEN** a child node has name `getting-started.md` +- **THEN** the alias plugin SHALL assign a suffix derived from the node name diff --git a/openspec/changes/support-index-md-as-section-file/specs/frontmatter-generation/spec.md b/openspec/changes/support-index-md-as-section-file/specs/frontmatter-generation/spec.md new file mode 100644 index 00000000..7e53fb32 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/specs/frontmatter-generation/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Frontmatter-only generation for section index files +The FSWriter in Hugo mode SHALL generate frontmatter-only content for nodes named `_index.md` OR `index.md` when the document blob is nil and frontmatter is present. + +#### Scenario: Hugo mode generates frontmatter for _index.md with nil blob +- **WHEN** Hugo mode is enabled AND name is `_index.md` AND node has frontmatter AND docBlob is nil +- **THEN** the FSWriter SHALL write a file containing only the frontmatter + +#### Scenario: Hugo mode generates frontmatter for index.md with nil blob +- **WHEN** Hugo mode is enabled AND name is `index.md` AND node has frontmatter AND docBlob is nil +- **THEN** the FSWriter SHALL write a file containing only the frontmatter + +#### Scenario: Non-index file with nil blob does not get frontmatter generation +- **WHEN** Hugo mode is enabled AND name is `readme.md` AND docBlob is nil +- **THEN** the FSWriter SHALL NOT generate frontmatter-only content diff --git a/openspec/changes/support-index-md-as-section-file/specs/hugo-pretty-path/spec.md b/openspec/changes/support-index-md-as-section-file/specs/hugo-pretty-path/spec.md new file mode 100644 index 00000000..5e513258 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/specs/hugo-pretty-path/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: HugoPrettyPath strips both index prefixes +The `HugoPrettyPath` function SHALL strip both `_index` and `index` prefixes from filenames when generating URL paths. The `_index` prefix MUST be stripped before `index` to avoid leaving a trailing underscore. + +#### Scenario: _index.md is stripped to produce clean path +- **WHEN** `HugoPrettyPath` processes a node with file `_index.md` +- **THEN** the resulting path SHALL have the `_index` portion removed + +#### Scenario: index.md is stripped to produce clean path +- **WHEN** `HugoPrettyPath` processes a node with file `index.md` +- **THEN** the resulting path SHALL have the `index` portion removed + +#### Scenario: Regular filename is not affected by index stripping +- **WHEN** `HugoPrettyPath` processes a node with file `getting-started.md` +- **THEN** the resulting path SHALL retain the filename (minus `.md` extension) diff --git a/openspec/changes/support-index-md-as-section-file/specs/index-file-detection/spec.md b/openspec/changes/support-index-md-as-section-file/specs/index-file-detection/spec.md new file mode 100644 index 00000000..ce38e916 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/specs/index-file-detection/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Fallback index file detection recognizes both conventions +The frontmatter processor's `nodeIsIndexFile` fallback SHALL return true for files named `_index.md` OR `index.md`. + +#### Scenario: _index.md is detected as index file +- **WHEN** the frontmatter processor checks if `_index.md` is an index file +- **THEN** it SHALL return true + +#### Scenario: index.md is detected as index file +- **WHEN** the frontmatter processor checks if `index.md` is an index file +- **THEN** it SHALL return true + +#### Scenario: Regular file is not detected as index file +- **WHEN** the frontmatter processor checks if `overview.md` is an index file +- **THEN** it SHALL return false diff --git a/openspec/changes/support-index-md-as-section-file/specs/section-file-detection/spec.md b/openspec/changes/support-index-md-as-section-file/specs/section-file-detection/spec.md new file mode 100644 index 00000000..1e9fee99 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/specs/section-file-detection/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Section file detection skips source resolution +The manifest resolver SHALL treat a node as a section index file when its `file` field equals `_index.md` OR `index.md` AND its `source` field is empty. Section index files MUST NOT undergo source resolution — they are auto-generated with frontmatter only. + +#### Scenario: Node with file _index.md and no source is a section file +- **WHEN** a manifest node has `file: _index.md` and `source: ""` +- **THEN** the resolver SHALL skip source resolution and return nil + +#### Scenario: Node with file index.md and no source is a section file +- **WHEN** a manifest node has `file: index.md` and `source: ""` +- **THEN** the resolver SHALL skip source resolution and return nil + +#### Scenario: Node with file index.md and a source is NOT a section file +- **WHEN** a manifest node has `file: index.md` and `source: "https://example.com/content.md"` +- **THEN** the resolver SHALL proceed with normal source resolution + +#### Scenario: Node with a non-index filename is NOT a section file +- **WHEN** a manifest node has `file: readme.md` and `source: ""` +- **THEN** the resolver SHALL proceed with normal resolution (not treated as section file) diff --git a/openspec/changes/support-index-md-as-section-file/tasks.md b/openspec/changes/support-index-md-as-section-file/tasks.md new file mode 100644 index 00000000..db53e5e8 --- /dev/null +++ b/openspec/changes/support-index-md-as-section-file/tasks.md @@ -0,0 +1,21 @@ +## 1. Core Helper + +- [ ] 1.1 Add `isSectionFile(name string) bool` helper in `pkg/manifest/manifest.go` and remove `const sectionFile` +- [ ] 1.2 Update `resolveManifestLinks` to use `isSectionFile(node.File)` instead of `node.File == sectionFile` + +## 2. Cross-Package Call Sites + +- [ ] 2.1 Update `pkg/writers/fswriter.go` frontmatter guard to check both `_index.md` and `index.md` +- [ ] 2.2 Update `pkg/nodeplugins/markdown/document/frontmatter/frontmatter.go` `nodeIsIndexFile` to return true for `index.md` +- [ ] 2.3 Update `pkg/manifestplugins/alias/plugin.go` to treat `index.md` as section file for alias suffix + +## 3. HugoPrettyPath + +- [ ] 3.1 Update `pkg/manifest/node.go` `HugoPrettyPath` to strip `index` prefix after `_index` (order matters) + +## 4. Tests + +- [ ] 4.1 Add test case in `manifest_test.go` for `file: index.md` with no source (should skip resolution) +- [ ] 4.2 Update alias plugin tests to verify `index.md` gets empty suffix +- [ ] 4.3 Update frontmatter tests to verify `index.md` is detected as index file +- [ ] 4.4 Add/update `HugoPrettyPath` test for `index.md` stripping