feat(page): site-wide include root via ParseFileInSite#272
Conversation
The v0.2.0 page-root confinement made it impossible for a multi-page
docs site to share a single source-of-truth `_app/` across pages —
any `include="../some-other-page/_app/foo.go"` was rejected with
"escapes the page root." The comment at page.go:103-109 explicitly
deferred widening this to "a clear use case emerges."
The clear use case is now: livetemplate/docs's
/getting-started/your-first-app references the same counter source
as /recipes/counter, both via include=. They cannot duplicate the
code without drift; they cannot share without crossing page-root
boundaries.
Public API
- NEW: tinkerdown.ParseFileInSite(path, siteRoot) — uses siteRoot
as the include-confinement boundary.
- UNCHANGED: tinkerdown.ParseFile(path) — single-page callers
(CLI tools, library users rendering one .md file) keep the
page-root-confined default. Backward compatible.
- ParseFileInSite with siteRoot="" falls back to ParseFile
semantics, so callers can pass through a configured-or-empty
value without branching.
Internal updates
- internal/server/server.go and internal/site/manager.go switch
their 5 ParseFile call sites to ParseFileInSite, passing the
server's rootDir / manager's rootDir respectively. Result:
`tinkerdown serve` automatically uses site-wide include
resolution; `tinkerdown validate` likewise via the site manager.
Tests (page_test.go, +127 LOC)
- PageRootConfinementByDefault — ParseFile keeps v0.2.0 behavior;
../ includes are still rejected.
- AllowsSiblingPageIncludes — ParseFileInSite resolves
../pageB/_app/snippet.go from pageA when both share siteRoot.
- RejectsPathsEscapingSiteRoot — confinement still applies; a
page cannot reach outside siteRoot just because we widened it.
- EmptySiteRootBehavesLikeParseFile — siteRoot="" semantics
documented as identical to ParseFile.
CHANGELOG: v0.2.1 entry under Keep-a-Changelog.
Verified end-to-end against livetemplate/docs's recipes-counter-deeper
worktree:
- /getting-started/your-first-app and /recipes/counter/ both HTTP 200
- your-first-app's `include="../recipes/counter/_app/counter.go"`
blocks render the actual file content (verified by grep for
AnonymousAuthenticator in the rendered HTML)
- Zero "escapes the page root" warnings in the serve log
- Site discovery picks up /recipes/counter/index.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis PR adds Strengths
Issues / Suggestions1. Implicit precondition:
|
There was a problem hiding this comment.
Pull request overview
Adds a new public parsing entry point that widens include="..." resolution from the per-page directory subtree to a caller-provided site root, enabling cross-page shared snippets while still confining includes to a boundary directory.
Changes:
- Introduces
tinkerdown.ParseFileInSite(path, siteRoot)and refactorsParseFileto delegate to a sharedparseFile. - Updates site/server discovery paths to use
ParseFileInSite(..., rootDir)soservecan resolve cross-page includes. - Adds unit tests covering default confinement, sibling-page includes under a site root, escape rejection, and empty-siteRoot behavior; documents the change in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| page.go | Adds ParseFileInSite and switches include confinement root based on siteRoot. |
| page_test.go | Adds tests for page-root vs site-root include confinement behavior. |
| internal/site/manager.go | Parses pages with site-wide include root during discovery/reload. |
| internal/server/server.go | Parses pages with site-wide include root during legacy tutorial discovery. |
| CHANGELOG.md | Documents v0.2.1 behavior and new API entry point. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // confinement check. | ||
| includeRoot := includeBaseDir | ||
| if siteRoot != "" { | ||
| includeRoot = siteRoot |
| includeRoot := includeBaseDir | ||
| if siteRoot != "" { | ||
| includeRoot = siteRoot | ||
| } | ||
| processedContent, includedFiles, includeWarnings = include.PreprocessWithLinks(processedContent, includeBaseDir, includeRoot, linkOpts) | ||
| for _, w := range includeWarnings { |
| The `serve` and `validate` commands automatically use `ParseFileInSite` | ||
| with the configured content directory; no per-page frontmatter is | ||
| needed. |
Four real issues from the round-1 bot reviews: 1. ParseFileInSite precondition was implicit (footgun) ParseFileInSite did not verify that path was actually under siteRoot. A caller passing path="/tmp/foo.md" with siteRoot="/var/www/site" would see EVERY include — even local `include="snippet.go"` in the page's own dir — silently fail confinement against a root that wasn't an ancestor of the page. Fix: validate at the public-API entry. Return an explicit "path X is not under siteRoot Y" error before any include resolution. Symlink-canonicalise both sides first (same trick include.Resolve uses) so /tmp → /private/tmp on macOS doesn't false-positive. Internal callers (server.go, manager.go) discover pages from under rootDir, so they're never affected; this catches the library-caller footgun before it confuses someone. 2. Stale "page root" error wording include.Resolve has been printing `include "..." escapes the page root` for v0.2.0. With site-wide resolution, the boundary is no longer the page — it's whatever root the caller passed. The message is now misleading for ParseFileInSite users. Fix: rename to "escapes the include root". One-line change in internal/include/include.go. 3. validate.go was still using ParseFile (CHANGELOG inaccuracy) The CHANGELOG entry claimed "serve and validate automatically use ParseFileInSite" but cmd/tinkerdown/commands/validate.go was unchanged. Validation now mirrors serve: pass absDir as the site root so cross-page includes that work at serve time ALSO validate at lint time. 4. Test assertion could pass on empty StaticHTML (false positive) PageRootConfinementByDefault checked `if got != "" && strings.Contains(got, "package x")` — if StaticHTML were empty (e.g., goldmark dropping the block silently), the test would pass without actually verifying anything. Firmed up: assert the page DID render its heading first, then assert the snippet content is absent. 5. Missing test: path-not-under-siteRoot edge case New TestParseFileInSite_RejectsPathOutsideSiteRoot exercises the validation added in fix #1, serving as a regression guard for the precondition. All 5 ParseFile/ParseFileInSite tests pass. Internal packages (include, site, server, cmd/tinkerdown/commands) still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewOverviewThis PR cleanly solves the multi-page shared-include problem by adding Issues🔴
|
Round-2 Claude review caught a real bug: ParseFileInSite computed absRoot for the precondition check but then called parseFile(path, siteRoot) with the original raw siteRoot. A relative siteRoot like "./site" would pass the precondition (which used absRoot internally) but then misbehave inside include.Resolve where the path comparison happens. Fix: compute absRoot once, use it for both the precondition AND the parseFile call. parseFile now always sees an absolute path, matching the invariant include.Resolve expects. Also adds TestParseFileInSite_AcceptsRelativeSiteRoot — exercises a relative siteRoot end-to-end (chdir to siteDir's parent, pass "./site" to ParseFileInSite, verify cross-page include resolves). This would have caught the raw-vs-resolved bug; serves as a regression guard. Validate.go's absDir scoping (separate Claude flag): confirmed safe. absDir is computed once at line 31 of validate.go (filepath.Abs(dir)) before the WalkDir loop, then used as both the walk root and the ParseFileInSite siteRoot. Per-file paths walked under it always remain under the site root, so the new precondition check never fires false-positively. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review —
|
Claude's round-2 review flagged the manual os.Chdir + Cleanup pattern
as goroutine-unsafe and noted that t.Chdir() (Go 1.21+) is the
purpose-built helper:
- Restores cwd automatically on test exit (no manual Cleanup needed)
- Implicitly marks the test as non-parallel-safe (Go's test runner
will warn if someone adds t.Parallel() later)
- Doesn't silently swallow restore errors
The replacement is mechanical and TestParseFileInSite_AcceptsRelative-
SiteRoot still passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #272:
|
| Test | Behavior verified |
|---|---|
PageRootConfinementByDefault |
ParseFile rejects ../ across pages |
RejectsPathOutsideSiteRoot |
Precondition fires with clear error |
AllowsSiblingPageIncludes |
Cross-page includes succeed within site |
RejectsPathsEscapingSiteRoot |
Confinement still enforced at site boundary |
EmptySiteRootBehavesLikeParseFile |
"" fallback documented and tested |
AcceptsRelativeSiteRoot |
Relative siteRoot canonicalized correctly |
t.Chdir is fine — go.mod requires Go 1.26.
One edge case worth considering: what happens when path == siteRoot (the markdown file is directly at the site root, not in a subdirectory)? filepath.Rel returns ".", which doesn't start with "..", so it passes the precondition check — correct. includeBaseDir would then equal siteRoot, which is fine. This scenario seems unlikely in practice but is implicitly handled correctly.
Minor nit
parseFile's comment (the one that used to explain v1 confinement, now replaced) is gone, which is correct. The new comment is accurate. No issue.
Verdict: Ready to merge with the absPath-threading suggestion as an optional follow-up. The security model is sound, tests are comprehensive, and the API is backward-compatible.
Round-3 Claude review caught two stylistic items:
1. parseFile re-computes filepath.Abs(path) even though
ParseFileInSite already has the resolved absPath. Now
ParseFileInSite passes absPath directly so the data flow
is explicit (parseFile still defends against relative path
in the ParseFile-via-empty-siteRoot case).
2. Two comment blocks leaned toward "what" instead of "why":
- parseFile's includeRoot comment shrunk to one sentence
focused on the v1 boundary preserved.
- validate.go inline comment trimmed to a single line.
The refactor is mechanical; all 6 ParseFile/ParseFileInSite tests
still pass plus full internal package suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #272:
|
Summary
Widens the include-resolution root from per-page directory to a configurable site root, so multi-page sites can share a single source-of-truth across pages via
include="...".The v0.2.0 comment at `page.go:103-109` explicitly deferred this until "a clear use case emerges." The clear use case is now: livetemplate/docs has both `/getting-started/your-first-app` (the from-scratch tutorial) and `/recipes/counter` (the deeper recipe), both wanting to slice from the same canonical `_app/counter/`. Without site-wide root, they have to duplicate code or one has to give up literate authoring.
Public API (additive only)
Internal updates
Tests (page_test.go, +127 LOC, all pass)
Verified end-to-end
Against livetemplate/docs's PR-B worktree:
Test plan
Heads-up
Once merged + tagged v0.2.1, livetemplate/docs's Dockerfile bumps `TINKERDOWN_REF` from v0.2.0 → v0.2.1 to unblock PR-B.
🤖 Generated with Claude Code