Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-20
56 changes: 56 additions & 0 deletions openspec/changes/support-index-md-as-section-file/design.md
Original file line number Diff line number Diff line change
@@ -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 |
123 changes: 123 additions & 0 deletions openspec/changes/support-index-md-as-section-file/proposal.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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)
21 changes: 21 additions & 0 deletions openspec/changes/support-index-md-as-section-file/tasks.md
Original file line number Diff line number Diff line change
@@ -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
Loading