diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4afb4b..7c5bed56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,9 @@ jobs: - name: Check documentation links run: sh scripts/check-docs-links.sh + - name: Check documentation style + run: sh scripts/check-docs-style.sh + - name: Check for removed source syntax in docs run: sh scripts/check-removed-syntax.sh @@ -148,20 +151,15 @@ jobs: go-version: '1.26.4' cache: true - - name: Compile docs site against in-tree GOWDK + - name: Build and smoke docs site against in-tree GOWDK working-directory: docs-site run: | set -euxo pipefail - # The site embeds dist/site; a placeholder lets the main package - # compile without the full asset build (Render compiles the real site - # at deploy). This job only catches Go/generator drift against HEAD. - mkdir -p dist/site - printf '\n' > dist/site/index.html - go build ./... + scripts/install-tailwind-linux.sh + scripts/build-production.sh + scripts/smoke-production.sh + go test ./... go vet ./... - # The docs generator must run against the repo's own docs/ tree. - go run ./cmd/syncdocs - test -f src/components/docs-sidebar.cmp.gwdk example-reports: name: Example reports diff --git a/README.md b/README.md index 29181488..065a8941 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ Replace `github.com/acme/hello-gowdk/ui` with your app module path. ## CLI at a Glance "Inspectable" is not just a slogan — the CLI exposes every stage of the -pipeline. Run `gowdk` with no arguments for full flags. +pipeline. Run `gowdk` with no arguments for the registered command list, and use +[the CLI reference](docs/reference/cli.md) for the complete flag contract. The +command registry is covered by `cmd/gowdk/main_commands_test.go`; this section +is a short overview, not a second source of truth. ### Build and run @@ -157,6 +160,7 @@ pipeline. Run `gowdk` with no arguments for full flags. | `gowdk dev` | Build, serve, rebuild on change, live-reload browsers, show an error overlay | | `gowdk preview` | Build and serve a local deploy preview | | `gowdk serve` | Serve already-generated build output | +| `gowdk clean` | Remove configured generated outputs, with `--dry-run` and `--json` | ### Inspect and debug @@ -168,7 +172,7 @@ pipeline. Run `gowdk` with no arguments for full flags. | `gowdk doctor` | Check local environment and project health | | `gowdk audit` | Derive security posture, evaluate baseline/declared policies, and optionally emit/run audit tests (`--json` for CI) | | `gowdk inspect ir` / `tree` / `endpoint-graph` / `asset-graph` / `go-bindings` | Print validated compiler IR, source-linked node tree, endpoint dispatch graph, asset graph, or Go binding report JSON | -| `gowdk manifest` / `routes` / `sitemap` | Print validated manifest, route/endpoint metadata, or editor site-map JSON | +| `gowdk manifest` / `routes` / `endpoints` / `sitemap` | Print validated manifest, route metadata, backend endpoint metadata, or editor site-map JSON | | `gowdk tokens` | Print raw language tokens for a file | | `gowdk fmt` | Format `.gwdk` sources (`--write`) | @@ -179,6 +183,7 @@ pipeline. Run `gowdk` with no arguments for full flags. | `gowdk generate stubs` | Write conservative missing action/API Go handler stubs next to their owning source package | | `gowdk contracts` / `graph` / `trace` / `list` | Print contract registration metadata, the command/event graph, a single contract trace, or filtered lists of commands/queries/events/jobs | | `gowdk add ` | Wire an optional addon into `gowdk.config.go` (`add --list` for addable built-ins, `add --list --registry` for metadata; `add seo` requires `--base-url`) | +| `gowdk playground policy` / `export` / `run` | Print sandbox policy, archive a source project, or run a project through the explicit hosted-execution sandbox | | `gowdk lsp` | Start the language server over stdio | ## Design @@ -219,7 +224,12 @@ How responsibility is split, and the opinions behind it: ## What Works Today -This table describes the current demoable 0.x slice. Status levels: +This table describes the current demoable 0.x slice. "Current Limit" separates +GOWDK backlog from app-owned work by design; app-owned limits mean the compiler +intentionally leaves domain policy, persistence, credentials, deployment, or +business validation in ordinary Go/application infrastructure. + +Status levels: - **Works** — the listed path works end-to-end today. - **Works, contract unstable** — the listed path works end-to-end, but the @@ -241,6 +251,7 @@ This table describes the current demoable 0.x slice. Status levels: | Components | Works, contract unstable | Components support imported contracts, slots, scoped CSS/assets, first local client behavior, and generated island assets. Page stores can opt into localStorage/sessionStorage persistence with `persist "local"`/`persist "session"`, including WASM island read/write/sync through the host loader. | Non-string props, richer slots/events, real `g:if`/`g:for`, lifecycle cleanup, and dependency diagnostics are planned. | [Components](docs/language/components.md) | [Components](examples/components/base/base-components.page.gwdk) | | WASM islands | Early | Component-level `wasm` and page-level `go client {}` emit Go `js/wasm` browser assets for supported fixtures; build-time validation checks browser-safe imports and ABI exports, browser tests cover mount/event/patch/emit/destroy/store participation, and `runtime/wasm` exposes the Go payload/result helper. | User-code runtime validation beyond the current patch/store contract remains planned. | [Components](docs/language/components.md) | [WASM example](examples/components/wasm/README.md) | | CSS/assets | Works, contract unstable | CSS processors, page CSS, scoped component CSS, component assets, asset manifests, content-hashed filenames, optional production obfuscation for compiler-owned JS, and optional Tailwind wrapper exist. | CSS processor contracts and optional dependency boundaries need hardening. | [CSS](docs/reference/css.md) | [CSS](examples/css/styled.page.gwdk) | +| SEO | Works, contract unstable | The SEO addon emits `sitemap.xml` and `robots.txt`, validates supported `jsonld` page metadata, injects deterministic JSON-LD into generated HTML, and generated apps can serve `/sitemap.xml` by merging public build-time URLs with an optional dynamic sitemap provider. | Search console ownership, private/auth content policy, source-of-truth inventory queries, and request-time-only URL discovery stay app-owned; unsupported schema kinds remain GOWDK backlog. | [SEO](docs/reference/seo.md) | [SEO](examples/seo) | | One-binary output | Works, contract unstable | `gowdk build --app --bin` can generate and compile an embedded Go server for supported SPA/backend/SSR slices, `--docker` emits a minimal non-root Docker context beside the binary, and CI starts the embed example binary to verify health plus embedded page serving. | Runtime operations, richer Docker target config, and split/backend-only deploys are still expanding. | [Deployment](docs/reference/deployment.md) | [Embed](examples/embed/site.page.gwdk) | | Generated app WASM | Early | `gowdk build --app --wasm` compiles the generated app into a Go `js/wasm` deploy artifact, and CI verifies the emitted module header. | Host runtime/loader integration is deploy-platform owned; this is separate from component-level WASM islands. | [Deployment](docs/reference/deployment.md) | [Embed](examples/embed/site.page.gwdk) | | Contracts | Works, contract unstable | Runtime contracts support typed queries, commands, events, jobs, role filtering, local dispatch, file outbox, broker/fanout adapters, worker replay with dedup/backoff hooks, contract graph/trace/list commands, generated `g:command`/`g:query` web adapters, and explicit domain-event to query invalidation metadata. | Separate worker/cron binary generators and editor-first contract visualization remain planned platform tooling. | [Contracts](docs/reference/contracts.md) | [Runtime contracts](runtime/contracts) | diff --git a/addons/seo/seo.go b/addons/seo/seo.go index 7f7d9ffa..15b42946 100644 --- a/addons/seo/seo.go +++ b/addons/seo/seo.go @@ -11,6 +11,9 @@ type Options = gowdk.SEOOptions // URL describes one additional sitemap URL. type URL = gowdk.SEOURL +// DynamicSitemap describes an optional request-time sitemap provider. +type DynamicSitemap = gowdk.SEODynamicSitemap + // Addon enables build-time SEO output. BaseURL is required when building. func Addon(options ...Options) gowdk.Addon { var selected Options diff --git a/cmd/gowdk/main_test.go b/cmd/gowdk/main_test.go index 79d89365..7df368bc 100644 --- a/cmd/gowdk/main_test.go +++ b/cmd/gowdk/main_test.go @@ -1189,6 +1189,31 @@ func TestTestCommandRunsInitializedProjectAppStageWithJSON(t *testing.T) { }) } +func TestResolveExplicitTestPathsUsesLaunchDirectory(t *testing.T) { + launchRoot := t.TempDir() + relative := filepath.Join("site", "pages", "home.page.gwdk") + absolute := filepath.Join(launchRoot, "site", "pages", "about.page.gwdk") + + var paths []string + var launchCwd string + withWorkingDir(t, launchRoot, func() { + var err error + launchCwd, err = os.Getwd() + if err != nil { + t.Fatal(err) + } + paths, err = resolveExplicitTestPaths([]string{relative, absolute}) + if err != nil { + t.Fatal(err) + } + }) + + wantRelative := filepath.Join(launchCwd, relative) + if len(paths) != 2 || paths[0] != wantRelative || paths[1] != absolute { + t.Fatalf("unexpected resolved test paths: %#v", paths) + } +} + func TestTestCommandRejectsUpdateFlag(t *testing.T) { _, err := parseTestOptions([]string{"--update"}) if err == nil || !strings.Contains(err.Error(), `unknown test flag "--update"`) { diff --git a/cmd/gowdk/test.go b/cmd/gowdk/test.go index 171956b1..f5c68d66 100644 --- a/cmd/gowdk/test.go +++ b/cmd/gowdk/test.go @@ -328,13 +328,18 @@ func buildTestWorkdir(cli cliOptions, options testOptions, modules []string) (*t } } + paths, err := resolveExplicitTestPaths(options.Paths) + if err != nil { + cleanup() + return nil, nil, err + } fmt.Fprintf(os.Stderr, "gowdk test [build]: %s\n", work.Root) request := buildRequest{ OutputDir: work.OutputDir, AppDir: work.AppDir, BinaryPath: work.BinaryPath, Modules: modules, - Paths: append([]string(nil), options.Paths...), + Paths: paths, } if err := runInWorkingDir(cli.ProjectRoot, func() error { return runTestBuildOnce(cli, request, options.JSON) @@ -345,6 +350,25 @@ func buildTestWorkdir(cli cliOptions, options testOptions, modules []string) (*t return work, cleanup, nil } +func resolveExplicitTestPaths(paths []string) ([]string, error) { + if len(paths) == 0 { + return nil, nil + } + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + resolved := make([]string, 0, len(paths)) + for _, path := range paths { + if filepath.IsAbs(path) { + resolved = append(resolved, filepath.Clean(path)) + continue + } + resolved = append(resolved, filepath.Clean(filepath.Join(cwd, path))) + } + return resolved, nil +} + func runTestBuildOnce(cli cliOptions, request buildRequest, quietStdout bool) error { if !quietStdout { return buildOnce(cli, request, newBuildTimingRecorder(false)) diff --git a/docs-site/.gitignore b/docs-site/.gitignore index e2db9ea2..b274c569 100644 --- a/docs-site/.gitignore +++ b/docs-site/.gitignore @@ -1,8 +1,5 @@ -# Generated site output. `dist/site` is committed so Render's default Go build -# can run the docs-site server without a custom build command. +# Generated site output. Production and CI rebuild `dist/site` from source. dist/* -!dist/site/ -!dist/site/** .gowdk/ gowdk_cache/ @@ -18,3 +15,4 @@ app /gowdk-page /syncdocs tools/tailwindcss +tools/gowdk diff --git a/docs-site/README.md b/docs-site/README.md index 162a8700..871ff3c1 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -18,6 +18,10 @@ sidebar, "on this page" TOC, breadcrumbs, prev/next, ⌘K search, copy buttons, callouts) lives in the reusable `DocsPage`/`DocsSidebar`/`Callout` components, so every generated page is modular and consistent. +`dist/site/` is also generated output. It is rebuilt by CI and Render from the +markdown, `.gwdk`, CSS, assets, and in-tree compiler; do not commit generated +HTML or hashed assets from that directory. + ## Prerequisites - Go 1.26.4+. @@ -72,18 +76,13 @@ Watches the `.gwdk` sources and `app.css` and rebuilds on change. Open ## Build Site Output ```sh -go run ./cmd/syncdocs -rm -rf dist/site (cd .. && go build -o docs-site/tools/gowdk ./cmd/gowdk) -./tools/gowdk build -mkdir -p dist/site/assets -cp -R assets/. dist/site/assets/ -cp assets/favicon.ico dist/site/favicon.ico +scripts/build-production.sh ``` -Always run `cmd/syncdocs` before the GOWDK build so the published docs match the -selected GOWDK source. `rm -rf dist/site` is required because the generated tree -mirrors the repo structure and stale routes must not linger. +Always run the production script before starting the site binary. It runs +`cmd/syncdocs`, clears `dist/site`, builds with the in-tree CLI, copies static +assets, and compiles the Go server. CI and Render use the same script. `./tools/gowdk build` compiles the `.gwdk` sources to static HTML, emits each page's `` from its `title`, `description`, and @@ -126,8 +125,7 @@ To preview website changes locally before opening a PR: ./tools/gowdk dev --addr 127.0.0.1:8091 # or a production-faithful preview that serves the exact built output through # the site's own Go binary (the same one that ships to production): -go run ./cmd/syncdocs -rm -rf dist/site && ./tools/gowdk build +scripts/build-production.sh GOWDK_ADDR=127.0.0.1:8091 go run . ``` @@ -156,6 +154,12 @@ project. - `app.css`: Tailwind v4 input and the site's visual system. - `cmd/syncdocs/`: generator that builds the docs pages and sidebar from the main repo's `docs/` markdown (uses `goldmark`). See "Sync docs". +- `scripts/build-production.sh`: production-faithful build used by CI and + Render. +- `scripts/smoke-production.sh`: local smoke check for the generated site + served through the production binary. +- `scripts/install-tailwind-linux.sh`: pinned Tailwind standalone CLI install + for Linux CI/Render environments. - `src/pages/index.page.gwdk`: the documentation home served at `/`. - `src/pages/docs/**.page.gwdk`: the documentation pages — **generated**; do not hand-edit. diff --git a/docs-site/app.css b/docs-site/app.css index 68c9d916..1b91cdaf 100644 --- a/docs-site/app.css +++ b/docs-site/app.css @@ -439,12 +439,13 @@ a { .prose :not(pre) > code { padding: 1px 5px; + border: 1px solid #c6dbe4; border-radius: 5px; - background: var(--color-surface-soft); - color: #0a5f74; + background: #f7fbfd; + color: #174956; font-family: var(--font-mono); font-size: 0.85em; - font-weight: 500; + font-weight: 600; word-break: break-word; } @@ -460,7 +461,7 @@ a { figure.code { margin: 22px 0 28px; border: 1px solid rgba(8, 26, 33, 0.5); - border-radius: 12px; + border-radius: 8px; background: var(--color-code); overflow: hidden; box-shadow: var(--shadow-card); @@ -469,6 +470,7 @@ figure.code { figure.code figcaption { display: flex; align-items: center; + justify-content: space-between; padding: 9px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.07); color: #8fb2bf; @@ -487,11 +489,36 @@ figure.code pre { line-height: 1.7; } +.code-lang { + display: inline-flex; + min-height: 20px; + align-items: center; +} + .prose pre code { font-family: var(--font-mono); font-size: 13px; } +.tok-keyword, +.tok-directive { + color: #7dd3fc; + font-weight: 650; +} + +.tok-string { + color: #f7c873; +} + +.tok-number { + color: #c4b5fd; +} + +.tok-comment { + color: #8aa6b2; + font-style: italic; +} + /* tables emitted by the docs renderer */ .table-scroll { margin: 22px 0 28px; @@ -897,12 +924,12 @@ figure.code { font-size: 11.5px; font-weight: 600; cursor: pointer; - opacity: 0; + opacity: 0.84; transition: opacity 130ms ease, background 130ms ease; } figure.code:hover .code-copy, -.prose pre:hover .code-copy, +.prose > pre:hover .code-copy, .code-copy:focus-visible { opacity: 1; } @@ -912,13 +939,13 @@ figure.code:hover .code-copy, color: #fff; } -/* generated fenced code blocks (goldmark
) */
-.prose pre {
+/* fallback for any authored raw 
; generated fences use figure.code */
+.prose > pre {
   position: relative;
   margin: 22px 0 28px;
   padding: 16px 18px;
   border: 1px solid rgba(8, 26, 33, 0.5);
-  border-radius: 12px;
+  border-radius: 8px;
   background: var(--color-code);
   color: var(--color-code-text);
   overflow-x: auto;
@@ -1165,12 +1192,15 @@ body.docs-modal-open {
 
   .prose pre,
   figure.code pre {
+    font-size: 12.5px;
+  }
+
+  .prose > pre {
     margin-right: -14px;
     margin-left: -14px;
     border-right: 0;
     border-left: 0;
     border-radius: 0;
-    font-size: 12.5px;
   }
 
   .doc-pager {
diff --git a/docs-site/cmd/syncdocs/main.go b/docs-site/cmd/syncdocs/main.go
index 75123dd0..ca6cc39a 100644
--- a/docs-site/cmd/syncdocs/main.go
+++ b/docs-site/cmd/syncdocs/main.go
@@ -13,12 +13,14 @@ package main
 import (
 	"bytes"
 	"fmt"
+	"html"
 	"os"
 	"path"
 	"path/filepath"
 	"regexp"
 	"sort"
 	"strings"
+	"unicode/utf8"
 
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/extension"
@@ -44,7 +46,7 @@ var sections = []section{
 	}},
 	{Title: "Reference", Dir: "reference", Order: []string{
 		"README", "routing", "cli", "config", "css", "hooks", "addons",
-		"contracts", "errors", "diagnostics", "diagnostic-codes", "dev",
+		"contracts", "seo", "errors", "diagnostics", "diagnostic-codes", "dev",
 		"deployment", "testing", "framework-integrations", "manifest",
 	}},
 	{Title: "Compiler", Dir: "compiler", Order: []string{
@@ -54,7 +56,7 @@ var sections = []section{
 	{Title: "Engineering", Dir: "engineering", Order: []string{
 		"architecture", "security", "conventions", "naming-conventions",
 		"code-quality", "generated-code-policy", "dependency-policy",
-		"operations", "testing", "ci", "release",
+		"documentation-style", "operations", "testing", "ci", "release",
 	}},
 	{Title: "Decisions", Dir: "engineering/decisions", Order: []string{"README"}},
 	{Title: "Product", Dir: "product", Order: []string{
@@ -99,12 +101,8 @@ func main() {
 		os.Exit(1)
 	}
 
-	routes := map[string]bool{}
 	for _, p := range pages {
-		routes[p.Route] = true
-	}
-	for _, p := range pages {
-		if err := writePage(docsRoot, p, routes); err != nil {
+		if err := writePage(docsRoot, p); err != nil {
 			fmt.Fprintln(os.Stderr, "syncdocs:", p.Rel, err)
 			os.Exit(1)
 		}
@@ -262,8 +260,9 @@ func frontMatter(markdown string) (title, lead string) {
 }
 
 var (
-	hrefRe   = regexp.MustCompile(`href="([^"]+)"`)
-	inlineRe = regexp.MustCompile("[`*_]")
+	hrefRe    = regexp.MustCompile(`href="([^"]+)"`)
+	inlineRe  = regexp.MustCompile("[`*_]")
+	commentRe = regexp.MustCompile(`(?s)`)
 	// GOWDK's view parser requires void elements to be self-closed; goldmark
 	// emits some (task-list , 
,
, ) without the slash. voidRe = regexp.MustCompile(`<(input|br|hr|img|col|area|base|embed|source|track|wbr)([^>]*?)\s*/?>`) @@ -282,12 +281,16 @@ func labelTaskListCheckboxes(s string) string { ``) } -func writePage(docsRoot string, p page, routes map[string]bool) error { +func stripHTMLComments(s string) string { + return commentRe.ReplaceAllString(s, "") +} + +func writePage(docsRoot string, p page) error { payload, err := os.ReadFile(filepath.Join(docsRoot, filepath.FromSlash(p.Rel))) if err != nil { return err } - body := stripFirstH1(string(payload)) + body := stripFirstH1AndLead(string(payload)) var buf bytes.Buffer if err := md.Convert([]byte(body), &buf); err != nil { @@ -295,7 +298,9 @@ func writePage(docsRoot string, p page, routes map[string]bool) error { } article := selfCloseVoids(buf.String()) article = labelTaskListCheckboxes(article) + article = stripHTMLComments(article) article = rewriteLinks(article, p.Rel) + article = highlightCodeBlocks(article) article = escapeBraces(article) var out strings.Builder @@ -327,16 +332,251 @@ func writePage(docsRoot string, p page, routes map[string]bool) error { return os.WriteFile(p.Output, []byte(out.String()), 0o644) } -func stripFirstH1(markdown string) string { +func stripFirstH1AndLead(markdown string) string { lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") for i, line := range lines { if strings.HasPrefix(line, "# ") { - return strings.Join(lines[i+1:], "\n") + return strings.Join(stripLeadingLead(lines[i+1:]), "\n") } } return markdown } +func stripLeadingLead(lines []string) []string { + i := 0 + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + if i >= len(lines) || !startsPlainLeadParagraph(lines[i]) { + return lines + } + for i < len(lines) && strings.TrimSpace(lines[i]) != "" { + i++ + } + for i < len(lines) && strings.TrimSpace(lines[i]) == "" { + i++ + } + return lines[i:] +} + +func startsPlainLeadParagraph(line string) bool { + t := strings.TrimSpace(line) + if t == "" { + return false + } + if strings.HasPrefix(t, "#") || strings.HasPrefix(t, "```") || + strings.HasPrefix(t, "-") || strings.HasPrefix(t, "* ") || + strings.HasPrefix(t, "1.") || strings.HasPrefix(t, "|") || + strings.HasPrefix(t, ">") { + return false + } + return true +} + +var codeBlockRe = regexp.MustCompile(`(?s)
(.*?)
`) + +func highlightCodeBlocks(article string) string { + return codeBlockRe.ReplaceAllStringFunc(article, func(match string) string { + parts := codeBlockRe.FindStringSubmatch(match) + lang := normalizeLanguage(parts[1]) + label := languageLabel(lang) + highlighted := highlightCode(parts[2], lang) + return `
` + + `
` + html.EscapeString(label) + `
` + + `
` + highlighted + `
` + + `
` + }) +} + +func normalizeLanguage(lang string) string { + lang = strings.ToLower(strings.TrimSpace(lang)) + switch lang { + case "": + return "text" + case "bash", "shell", "console": + return "sh" + case "javascript": + return "js" + case "typescript": + return "ts" + default: + return lang + } +} + +func languageLabel(lang string) string { + switch lang { + case "gwdk": + return "GOWDK" + case "go": + return "Go" + case "sh": + return "Shell" + case "js": + return "JavaScript" + case "ts": + return "TypeScript" + case "json": + return "JSON" + case "yaml", "yml": + return "YAML" + case "toml": + return "TOML" + case "text": + return "Text" + default: + return strings.ToUpper(lang[:1]) + lang[1:] + } +} + +var keywordSets = map[string]map[string]bool{ + "gwdk": words("act api build canonical client component css description emits fragment go guard import jsonld layout noindex page partial paths props route server state title use view wasm"), + "go": words("any bool break case chan const continue default defer else error fallthrough for func go goto if import interface map nil package range return select struct switch type var"), + "sh": words("case cd cp curl do done echo else esac export fi for if in mkdir rm set sh test then"), + "js": words("const else false for function if let new null return true var while"), + "ts": words("const else false for function if interface let new null return string true type var while"), +} + +func words(s string) map[string]bool { + set := map[string]bool{} + for _, word := range strings.Fields(s) { + set[word] = true + } + return set +} + +func highlightCode(codeHTML, lang string) string { + raw := html.UnescapeString(codeHTML) + keywords := keywordSets[lang] + var out strings.Builder + for i := 0; i < len(raw); { + if isBlockCommentStart(raw, i, lang) { + j := strings.Index(raw[i+2:], "*/") + if j < 0 { + out.WriteString(tokenSpan("comment", raw[i:])) + break + } + end := i + 2 + j + 2 + out.WriteString(tokenSpan("comment", raw[i:end])) + i = end + continue + } + if isLineCommentStart(raw, i, lang) { + j := strings.IndexByte(raw[i:], '\n') + if j < 0 { + out.WriteString(tokenSpan("comment", raw[i:])) + break + } + out.WriteString(tokenSpan("comment", raw[i:i+j])) + out.WriteByte('\n') + i += j + 1 + continue + } + if lang == "gwdk" && strings.HasPrefix(raw[i:], "g:") { + j := i + 2 + for j < len(raw) && isIdentPart(raw[j]) { + j++ + } + out.WriteString(tokenSpan("directive", raw[i:j])) + i = j + continue + } + if raw[i] == '"' || raw[i] == '\'' || raw[i] == '`' { + j := scanString(raw, i) + out.WriteString(tokenSpan("string", raw[i:j])) + i = j + continue + } + if isDigit(raw[i]) { + j := i + 1 + for j < len(raw) && (isDigit(raw[j]) || raw[j] == '.' || raw[j] == '_') { + j++ + } + out.WriteString(tokenSpan("number", raw[i:j])) + i = j + continue + } + if isIdentStart(raw[i]) { + j := i + 1 + for j < len(raw) && isIdentPart(raw[j]) { + j++ + } + word := raw[i:j] + if keywords[word] { + out.WriteString(tokenSpan("keyword", word)) + } else { + out.WriteString(html.EscapeString(word)) + } + i = j + continue + } + r, size := utf8.DecodeRuneInString(raw[i:]) + if r == utf8.RuneError && size == 0 { + break + } + out.WriteString(html.EscapeString(raw[i : i+size])) + i += size + } + return out.String() +} + +func isBlockCommentStart(raw string, i int, lang string) bool { + return (lang == "go" || lang == "gwdk" || lang == "js" || lang == "ts") && strings.HasPrefix(raw[i:], "/*") +} + +func isLineCommentStart(raw string, i int, lang string) bool { + if (lang == "go" || lang == "gwdk" || lang == "js" || lang == "ts") && strings.HasPrefix(raw[i:], "//") { + return true + } + if (lang == "sh" || lang == "yaml" || lang == "yml" || lang == "toml") && raw[i] == '#' { + start := strings.LastIndexByte(raw[:i], '\n') + 1 + return strings.TrimSpace(raw[start:i]) == "" + } + return false +} + +func scanString(raw string, start int) int { + quote := raw[start] + i := start + 1 + escaped := false + for i < len(raw) { + if quote != '`' && raw[i] == '\n' { + return i + } + if escaped { + escaped = false + i++ + continue + } + if quote != '`' && raw[i] == '\\' { + escaped = true + i++ + continue + } + if raw[i] == quote { + return i + 1 + } + i++ + } + return i +} + +func tokenSpan(class, text string) string { + return `` + html.EscapeString(text) + `` +} + +func isIdentStart(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '_' +} + +func isIdentPart(b byte) bool { + return isIdentStart(b) || isDigit(b) || b == '-' +} + +func isDigit(b byte) bool { + return b >= '0' && b <= '9' +} + // rewriteLinks turns relative ".md" links into site routes, resolved against // the current page's directory. func rewriteLinks(htmlBody, currentRel string) string { diff --git a/docs-site/cmd/syncdocs/main_test.go b/docs-site/cmd/syncdocs/main_test.go new file mode 100644 index 00000000..c5bc2e4d --- /dev/null +++ b/docs-site/cmd/syncdocs/main_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "strings" + "testing" +) + +func TestStripFirstH1AndLeadRemovesPromotedLead(t *testing.T) { + body := stripFirstH1AndLead(`# Language + +The promoted lead should appear only in the page header. + +## Status + +Body content. +`) + + if strings.Contains(body, "promoted lead") { + t.Fatalf("body still contains promoted lead:\n%s", body) + } + if !strings.Contains(body, "## Status") { + t.Fatalf("body lost first real section:\n%s", body) + } +} + +func TestStripFirstH1AndLeadPreservesImmediateStructuredContent(t *testing.T) { + listBody := stripFirstH1AndLead(`# Language + +- Keep this list. +`) + if !strings.Contains(listBody, "- Keep this list.") { + t.Fatalf("body lost immediate list:\n%s", listBody) + } + + codeBody := stripFirstH1AndLead("# Language\n\n```gwdk\nroute \"/\"\n```\n") + if !strings.Contains(codeBody, "```gwdk") { + t.Fatalf("body lost immediate code fence:\n%s", codeBody) + } +} + +func TestHighlightCodeBlocksWrapsLanguageAndTokens(t *testing.T) { + article := highlightCodeBlocks(`
route "/"
+view {
+  
+}
+
`) + + for _, want := range []string{ + `
`, + `GOWDK`, + `route`, + `"/"`, + `g:post`, + } { + if !strings.Contains(article, want) { + t.Fatalf("highlighted article missing %q:\n%s", want, article) + } + } +} + +func TestStripHTMLCommentsRemovesGoldmarkRawHTMLMarker(t *testing.T) { + body := stripHTMLComments(`

Text continues.

`) + if strings.Contains(body, "raw HTML omitted") || strings.Contains(body, "