Skip to content
Merged
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
1 change: 0 additions & 1 deletion .github/workflows/sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ on:
- https://github.com/livetemplate/livetemplate
- https://github.com/livetemplate/client
- https://github.com/livetemplate/lvt
- https://github.com/livetemplate/examples
ref:
description: "Git ref (tag or branch) to sync from"
required: true
Expand Down
45 changes: 35 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,57 @@ This repo is the source for the LiveTemplate documentation website. Most content

## Where content lives

- **`content/_meta/source-of-truth.md`** (created in Phase 2) — for every page, names the canonical source file and repo. **Edit content in the canonical source repo, not here**, unless this repo is the canonical source.
- **`content/recipes/`** — recipes are authored directly in this repo; this is the canonical home for them.
- **`content/recipes/ui-patterns/index.md`** — UI pattern recipe catalog; detail pages are proxied to the docs-site recipes binary via tinkerdown's external-app router.
- **Everything else under `content/`** — mirrored from a source repo. Don't edit here; edit the source. The Phase 3 sync action will overwrite local edits.
- **`content/_meta/source-of-truth.md`** — for every page, names the canonical source file and repo. **Edit content in the canonical source repo, not here**, unless this repo is the canonical source.
- **`content/recipes/`** — recipes (the docs *about* runnable apps) are authored directly in this repo. Markdown only — no Go code lives under `content/`.
- **`examples/<slug>/`** — runnable apps. The single canonical home for every recipe's code, template, e2e test, and standalone runner. `cmd/site` imports these packages and mounts them at `/apps/<slug>/`; tinkerdown proxies inline `embed-lvt` blocks here.
- **Everything else under `content/`** — mirrored from a source repo. Don't edit here; edit the source. The sync action will overwrite local edits.

## Workflow

1. Determine where the content is canonical (check `content/_meta/source-of-truth.md`).
2. If canonical here → edit, open PR.
3. If canonical elsewhere → open a PR in the source repo. The next release will sync the change here automatically (Phase 3 onwards).
3. If canonical elsewhere → open a PR in the source repo. The next release will sync the change here automatically.

## Adding an example

Every runnable app — whether it has a recipe write-up or not — lives at `examples/<slug>/`. Pattern:

```
examples/foo/
├── handler.go # exports Handler(opts ...livetemplate.Option) http.Handler
├── foo.tmpl # //go:embed-ed by handler.go
├── foo_test.go # chromedp e2e (package foo_test); spawns `go run ./cmd`
└── cmd/
└── main.go # standalone runner — supports PORT env + --dev flag
```

Steps:

1. Create the folder + four files (use `examples/counter/` as the template — it's the smallest runnable shape).
2. Optional: add `content/recipes/foo/index.md` if you want a recipe write-up. Cite source via site-rooted includes: `` ```go include="/examples/foo/foo.go" lines="5-15" `` ``.
3. Wire `cmd/site/main.go`: import `"github.com/livetemplate/docs/examples/foo"` and add a `mux.Handle("/apps/foo/", ...)` line.
4. `go build ./...` + `go test ./examples/foo` to confirm.

No `livetemplate/livetemplate` CI changes required — the cross-repo workflow runs `go test ./examples/...` and picks up the new folder automatically.

## Local dev

```bash
tinkerdown serve content/ --watch
tinkerdown serve content/ --watch # docs site
go run ./examples/counter/cmd --dev # any example, standalone
make serve # both together (cmd/site + tinkerdown)
```

## Validation & tests

```bash
tinkerdown validate content/ # parses every page
cd cmd/sync && go test ./... # sync tool unit tests
cd e2e && go test ./... # chromedp browser e2e against staging
tinkerdown validate content/ # parses every page (includes resolve, etc.)
go test ./cmd/sync/... # sync tool unit tests
go test ./examples/... # all example e2e tests (chromedp)
go test ./e2e/... # site-level browser tests
```

Browser e2e tests live in `e2e/` (added in Phase 1) and run via chromedp against the live staging site at `https://livetemplate-docs-staging.fly.dev/`.
The `examples/` tests need Docker Chrome (chromedp pulls it on first use); they pass under `-short` by compiling but skip the browser-driven parts.

## Sync workflow (Phase 3)

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# until upstream fixes the vendored asset embed (Phase 0 finding T0-1).
# `TINKERDOWN_REF` selects which branch/tag to clone and is overridable.

ARG TINKERDOWN_REF=v0.2.2
ARG TINKERDOWN_REF=v0.3.0

# ---- Stage 1: Build TypeScript client assets for tinkerdown ----
FROM node:20-alpine AS client-builder
Expand Down
28 changes: 20 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
.PHONY: help serve run-firstapp validate
.PHONY: help serve site validate examples-test examples-list

help:
@echo "LiveTemplate docs — local development targets:"
@echo " serve Run the docs site (tinkerdown serve content/)"
@echo " run-firstapp Run the tutorial counter locally on :9090 (use when"
@echo " iterating on _app/counter/; the docs site's embeds"
@echo " point at lt-firstapp.fly.dev by default)"
@echo " validate Validate every page (tinkerdown validate content/)"
@echo " serve Run the docs site (tinkerdown serve content/)"
@echo " site Run cmd/site (recipes binary on :9091, what tinkerdown proxies to)"
@echo " validate Validate every page (tinkerdown validate content/)"
@echo " examples-test Run all example tests (go test ./examples/...)"
@echo " examples-list Enumerate examples (one per line)"
@echo ""
@echo " To run a single example standalone:"
@echo " go run ./examples/<slug>/cmd --dev"
@echo ""
@echo " To run a single example's tests:"
@echo " go test ./examples/<slug>"

serve:
tinkerdown serve content/

run-firstapp:
cd content/getting-started/_app/counter && PORT=9090 go run .
site:
go run ./cmd/site

validate:
tinkerdown validate content/

examples-test:
go test ./examples/...

examples-list:
@ls -1 examples/ | grep -v '^cmd$$'
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,24 @@ Production site: https://livetemplate.fly.dev (Phase 6 onwards)

## How content gets here

Most pages are mirrored from canonical files in the source repos:
Most reference content is mirrored from canonical files in the source repos:

| Section | Source repo | Sync mechanism |
|---|---|---|
| Reference, Guides | [livetemplate/livetemplate](https://github.com/livetemplate/livetemplate) | GitHub Action on release tag (Phase 3) |
| Reference, Guides | [livetemplate/livetemplate](https://github.com/livetemplate/livetemplate) | GitHub Action on release tag |
| TypeScript client docs | [livetemplate/client](https://github.com/livetemplate/client) | GitHub Action on release tag |
| CLI docs | [livetemplate/lvt](https://github.com/livetemplate/lvt) | GitHub Action on release tag |
| App recipe source | [livetemplate/examples](https://github.com/livetemplate/examples) | GitHub Action on release tag |
| Recipes (interactive) | this repo | Authored directly here |
| Recipes (markdown) | this repo, under `content/recipes/` | Authored directly here |
| Runnable apps + their tests | this repo, under `examples/<slug>/` | Authored directly here |

The source-of-truth matrix (Phase 2 deliverable) lives at `content/_meta/source-of-truth.md`.
The source-of-truth matrix lives at `content/_meta/source-of-truth.md`.

Every runnable demo cited by a recipe lives at `examples/<slug>/` —
one folder per app, containing the Go package, template, chromedp
e2e test, and a `cmd/main.go` standalone runner. `cmd/site` imports
these packages and mounts them at `/apps/<slug>/` so tinkerdown's
inline `embed-lvt` blocks can render live widgets on docs pages. See
`CONTRIBUTING.md` → "Adding an example" for the per-app shape.

## Local development

Expand Down
18 changes: 10 additions & 8 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//
// Recipes are imported as Go packages — each exposes `Handler() http.Handler`.
// Adding a recipe is two lines here plus a Go package under
// content/recipes/<slug>/_app/.
// examples/<slug>/.
package main

import (
Expand All @@ -20,12 +20,12 @@ import (

"github.com/livetemplate/livetemplate"

counter "github.com/livetemplate/docs/content/recipes/counter/_app"
loginrecipe "github.com/livetemplate/docs/content/recipes/login/_app"
patterns "github.com/livetemplate/docs/content/recipes/patterns/_app"
pe "github.com/livetemplate/docs/content/recipes/progressive-enhancement/_app"
notepad "github.com/livetemplate/docs/content/recipes/shared-notepad/_app"
todos "github.com/livetemplate/docs/content/recipes/todos/_app"
"github.com/livetemplate/docs/examples/counter"
loginrecipe "github.com/livetemplate/docs/examples/login"
"github.com/livetemplate/docs/examples/patterns"
pe "github.com/livetemplate/docs/examples/progressive-enhancement"
notepad "github.com/livetemplate/docs/examples/shared-notepad"
"github.com/livetemplate/docs/examples/todos"
)

func main() {
Expand All @@ -49,7 +49,9 @@ func main() {
// page embed-lvt path="/apps/counter/" upstream="http://localhost:9091"
// → tinkerdown fetches http://localhost:9091/apps/counter/
// → mux routes to counter.Handler()
mux.Handle("/apps/counter/", http.StripPrefix("/apps/counter", counter.Handler()))
mux.Handle("/apps/counter/", http.StripPrefix("/apps/counter", counter.Handler(
livetemplate.WithAllowedOrigins(allowedOrigins),
)))

// UI patterns are mounted at their recipe URL space because the
// catalog and detail pages are first-class recipes. Tinkerdown's proxy
Expand Down
78 changes: 0 additions & 78 deletions cmd/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,6 @@ func Run(opts Options) (Result, error) {
if err := os.WriteFile(dest, []byte(out), 0o644); err != nil {
return res, codedErr{fmt.Errorf("write %s: %w", dest, err), 3}
}
if err := mirrorAdjacentApp(srcAbs, dest); err != nil {
return res, codedErr{fmt.Errorf("mirror _app/ for %s: %w", p.SiteURL, err), 3}
}
}
res.Updated++
res.UpdatedPaths = append(res.UpdatedPaths, p.SiteURL)
Expand Down Expand Up @@ -356,81 +353,6 @@ func writeFrontmatterValue(b *strings.Builder, key string, v any) {
}
}

// mirrorAdjacentApp copies an `_app/` directory next to the upstream
// markdown into the same relative position next to the destination
// file. Used for literate authoring (`include="./_app/foo.go"`) where
// the included files live alongside the README.
//
// The destination `_app/` is cleared before repopulation so the docs
// site mirrors upstream state authoritatively (orphaned files removed).
//
// Symlinks inside the upstream `_app/` are rejected — they're a
// path-confinement risk and tinkerdown's include resolver canonicalises
// paths anyway.
func mirrorAdjacentApp(srcReadmeAbs, destReadmeAbs string) error {
srcAppDir := filepath.Join(filepath.Dir(srcReadmeAbs), "_app")
st, err := os.Stat(srcAppDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat %s: %w", srcAppDir, err)
}
if !st.IsDir() {
return nil
}
destAppDir := filepath.Join(filepath.Dir(destReadmeAbs), "_app")
if err := os.RemoveAll(destAppDir); err != nil {
return fmt.Errorf("clear %s: %w", destAppDir, err)
}
srcRoot, err := filepath.Abs(srcAppDir)
if err != nil {
return fmt.Errorf("resolve %s: %w", srcAppDir, err)
}
return filepath.WalkDir(srcAppDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
// Reject symlinks anywhere in _app/ — they break path
// confinement and tinkerdown's include resolver canonicalises
// paths so symlinked content wouldn't survive the docs render anyway.
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink not allowed in _app/: %s", path)
}
rel, err := filepath.Rel(srcRoot, path)
if err != nil {
return err
}
dest := filepath.Join(destAppDir, rel)
if d.IsDir() {
return os.MkdirAll(dest, 0o755)
}
if !d.Type().IsRegular() {
return fmt.Errorf("non-regular file in _app/: %s", path)
}
return copyFile(path, dest)
})
}

// copyFile copies a regular file's bytes from src to dst. The destination's
// parent directory must exist (mirrorAdjacentApp creates it via WalkDir).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}

// linkRewriter rewrites cross-repo GitHub links to docs-site-relative
// URLs based on the source-of-truth matrix. Links that don't map to a
// known page are left untouched (so external GitHub references survive).
Expand Down
Loading
Loading