diff --git a/README.md b/README.md
index ffb404b..dd5ec2b 100644
--- a/README.md
+++ b/README.md
@@ -44,13 +44,13 @@ Run the complete source, build, export, size, and route smoke gate:
./scripts/verify-production.sh
```
-The gate derives the exact GoSX and gotreesitter versions from `go.mod`, rejects local engine
-overrides, builds the browser parser and all 206 lazy grammar assets from Go, validates every `.gsx` source, runs the Go tests,
-creates a clean `gosx build --prod` artifact, runs `gosx export`, reports runtime size, boots
-`dist/run.sh`, and requests every documentation route. Its Chrome check types a unique private
-marker into the editor and proves that gotreesitter parses it without any source-bearing request;
-it then proves an internal route change does not issue a document request. The `gosx` binary on
-`PATH` must exactly match the version in `go.mod`.
+The gate derives the exact GoSX and gotreesitter versions from `go.mod`. It rejects local engine
+overrides. It builds the playground, all 206 lazy grammar assets, and the authoring engine from Go.
+It validates every `.gsx` source and runs the Go tests. It creates a clean `gosx build --prod`
+artifact, runs `gosx export`, reports runtime size, boots `dist/run.sh`, and requests every route.
+It checks every internal link and validates the authoring asset surface. Its Chrome check proves
+that playground source never enters a request. It also proves that internal navigation does not
+request a new document. The `gosx` binary on `PATH` must exactly match the version in `go.mod`.
Browser performance budgets are optional because they require a local Chrome installation:
diff --git a/app/authoring/page.server.go b/app/authoring/page.server.go
index b6124b1..f78baee 100644
--- a/app/authoring/page.server.go
+++ b/app/authoring/page.server.go
@@ -78,11 +78,11 @@ func init() {
docsapp.RegisterStaticDocsPage(
"Grammar Authoring",
"Inherit a base grammar and author a delta of added/overridden rules in the browser — grammargen merges and compiles the extended grammar live.",
+ "/authoring",
route.FileModuleOptions{
Load: loadAuthoring,
Metadata: func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
return server.Metadata{
- Title: server.Title{Absolute: "Grammar Authoring — gotreesitter"},
Links: []server.LinkTag{{
Rel: "stylesheet",
Href: docsapp.PublicAssetURL("authoring/authoring.css"),
diff --git a/app/changelog/page.server.go b/app/changelog/page.server.go
index 2941956..026fa1b 100644
--- a/app/changelog/page.server.go
+++ b/app/changelog/page.server.go
@@ -32,6 +32,7 @@ func init() {
docsapp.RegisterStaticDocsPage(
"Changelog",
"Explore every GoTreeSitter release, current unreleased work, upgrade impact, and source evidence.",
+ "/changelog",
route.FileModuleOptions{
Load: loadChangelog,
Metadata: func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
@@ -45,10 +46,9 @@ func changelogMetadata() server.Metadata {
const (
title = "Changelog — GoTreeSitter"
description = "Explore GoTreeSitter releases, current work, upgrade impact, and source evidence."
- siteURL = "https://gotreesitter.m31labs.dev"
)
image := server.MediaAsset{
- URL: siteURL + docsapp.PublicAssetURL("social/changelog.png"),
+ URL: docsapp.SiteURL + docsapp.PublicAssetURL("social/changelog.png"),
Width: 1200,
Height: 630,
Alt: "GoTreeSitter Changelog with an abstract syntax-tree release timeline.",
@@ -57,11 +57,11 @@ func changelogMetadata() server.Metadata {
return server.Metadata{
Title: server.Title{Absolute: title},
Description: description,
- MetadataBase: siteURL,
- Alternates: &server.Alternates{Canonical: siteURL + "/changelog"},
+ MetadataBase: docsapp.SiteURL,
+ Alternates: &server.Alternates{Canonical: docsapp.SiteURL + "/changelog"},
OpenGraph: &server.OpenGraph{
Type: "website",
- URL: siteURL + "/changelog",
+ URL: docsapp.SiteURL + "/changelog",
SiteName: "GoTreeSitter",
Title: title,
Description: description,
diff --git a/app/docs/__slug/page.server.go b/app/docs/__slug/page.server.go
index 578210f..b382e68 100644
--- a/app/docs/__slug/page.server.go
+++ b/app/docs/__slug/page.server.go
@@ -62,16 +62,18 @@ func init() {
"title": title,
"description": doc.Frontmatter["description"],
"content": node,
+ "slug": slug,
}, nil
},
Metadata: func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
values, _ := data.(map[string]any)
title, _ := values["title"].(string)
description, _ := values["description"].(string)
- return server.Metadata{
- Title: server.Title{Default: title},
- Description: description,
- }, nil
+ slug, _ := values["slug"].(string)
+ meta := docsapp.CanonicalMetadata("/docs/" + slug)
+ meta.Title = server.Title{Default: title + " | GoTreeSitter Docs"}
+ meta.Description = description
+ return meta, nil
},
},
)
diff --git a/app/layout.gsx b/app/layout.gsx
index 405c778..41851b2 100644
--- a/app/layout.gsx
+++ b/app/layout.gsx
@@ -12,7 +12,7 @@ func Layout() Node {
Skip to content
-
+
@@ -20,16 +20,16 @@ func Layout() Node {
gotreesitter
- {gtsVersion}
+ {gtsVersion}
-
+
{gtsVersion}
· 206/206 curated parity
- Changelog
- Playground
- Authoring
+
+
+
}
+// TopNavLink exposes the active tool route to sighted and assistive users.
+func TopNavLink(props any) Node {
+ return <>
+
+
+ {props.Label}
+
+
+
+ {props.Label}
+
+ >
+}
+
// DocsNavLink renders one sidebar `` entry using the
// public/docs.css `.navitem`/`.ndot` contract and a real file-route link.
func DocsNavLink(props any) Node {
@@ -59,7 +73,7 @@ func DocsNavLink(props any) Node {
-
+
{props.Label}
@@ -67,7 +81,7 @@ func DocsNavLink(props any) Node {
-
+
{props.Label}
diff --git a/app/modules.go b/app/modules.go
index 02b0fdd..1281853 100644
--- a/app/modules.go
+++ b/app/modules.go
@@ -2,11 +2,14 @@ package docs
import (
"log"
+ "strings"
"m31labs.dev/gosx/route"
"m31labs.dev/gosx/server"
)
+const SiteURL = "https://gotreesitter.m31labs.dev"
+
var docsPublicAssetURL func(string) string
func BindPublicAssetURL(fn func(string) string) {
@@ -20,6 +23,14 @@ func PublicAssetURL(path string) string {
return server.AssetURL(path)
}
+func CanonicalMetadata(path string) server.Metadata {
+ path = "/" + strings.TrimLeft(strings.TrimSpace(path), "/")
+ return server.Metadata{
+ MetadataBase: SiteURL,
+ Alternates: &server.Alternates{Canonical: SiteURL + path},
+ }
+}
+
func RegisterDocsPage(title, description string, opts route.FileModuleOptions) {
metadata := opts.Metadata
opts.Metadata = func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
@@ -41,13 +52,13 @@ func RegisterDocsPage(title, description string, opts route.FileModuleOptions) {
}
}
-func RegisterStaticDocsPage(title, description string, opts route.FileModuleOptions) {
+func RegisterStaticDocsPage(title, description, canonicalPath string, opts route.FileModuleOptions) {
metaMetadata := opts.Metadata
opts.Metadata = func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
- meta := server.Metadata{
+ meta := mergeDocsMetadata(server.Metadata{
Title: server.Title{Default: title + " | GoTreeSitter Docs"},
Description: description,
- }
+ }, CanonicalMetadata(canonicalPath))
if metaMetadata == nil {
return meta, nil
}
diff --git a/app/modules_test.go b/app/modules_test.go
index 8a28664..3c47aaf 100644
--- a/app/modules_test.go
+++ b/app/modules_test.go
@@ -30,3 +30,15 @@ func TestMergeDocsMetadataPreservesStructuredSocialMetadata(t *testing.T) {
t.Fatalf("base metadata changed unexpectedly: %#v", merged)
}
}
+
+func TestCanonicalMetadataUsesProductionOrigin(t *testing.T) {
+ for _, path := range []string{"changelog", "/changelog"} {
+ meta := CanonicalMetadata(path)
+ if meta.MetadataBase != SiteURL {
+ t.Fatalf("metadata base = %q, want %q", meta.MetadataBase, SiteURL)
+ }
+ if meta.Alternates == nil || meta.Alternates.Canonical != SiteURL+"/changelog" {
+ t.Fatalf("canonical metadata for %q = %#v", path, meta.Alternates)
+ }
+ }
+}
diff --git a/app/page.server.go b/app/page.server.go
index 95527d3..95f59f2 100644
--- a/app/page.server.go
+++ b/app/page.server.go
@@ -9,6 +9,7 @@ func init() {
RegisterStaticDocsPage(
"Overview",
"A pure-Go, byte-exact reimplementation of tree-sitter — no CGo, no C toolchain, 206 grammars built in.",
+ "/",
route.FileModuleOptions{
Bindings: func(ctx *route.RouteContext, page route.FilePage, data any) route.FileTemplateBindings {
return route.FileTemplateBindings{
diff --git a/app/playground/page.server.go b/app/playground/page.server.go
index e9dd367..641f678 100644
--- a/app/playground/page.server.go
+++ b/app/playground/page.server.go
@@ -30,11 +30,11 @@ func init() {
docsapp.RegisterStaticDocsPage(
"Playground",
"Parse source and run tree-sitter queries locally in a GoSX-managed WebAssembly engine.",
+ "/playground",
route.FileModuleOptions{
Load: loadPlayground,
Metadata: func(ctx *route.RouteContext, page route.FilePage, data any) (server.Metadata, error) {
return server.Metadata{
- Title: server.Title{Absolute: "Playground — gotreesitter"},
Links: []server.LinkTag{{
Rel: "stylesheet",
Href: docsapp.PublicAssetURL("playground/playground.css"),
diff --git a/content/docs/authoring-languages.md b/content/docs/authoring-languages.md
index 6425e8b..6defa65 100644
--- a/content/docs/authoring-languages.md
+++ b/content/docs/authoring-languages.md
@@ -43,7 +43,7 @@ The relevant functions are all public:
|---|---|---|
| `grammargen.ImportGrammarJSON(data []byte) (*Grammar, error)` | `grammargen/import_grammarjson.go` | Parse a resolved `grammar.json` (the output of `tree-sitter generate`) into the grammar IR. Rules, extras, conflicts, externals, inline, word, precedences, reserved sets, supertypes are all imported. |
| `grammargen.GenerateLanguage(g *Grammar) (*gotreesitter.Language, error)` | `grammargen/encode.go` | Compile the IR into runtime parse tables. |
-| `grammargen.GenerateLanguageAndBlob(g *Grammar) (*gotreesitter.Language, []byte, error)` | `grammargen/encode.go` | Same, plus the serialized gob+gzip blob in one pass. `...WithContext` variants exist for cancellation. |
+| `grammargen.GenerateLanguageAndBlob(g *Grammar) (*gotreesitter.Language, []byte, error)` | `grammargen/encode.go` | Same, plus a serialized language blob in one pass. Blobs without `LargeStateGotos` keep the legacy gob+gzip format. Map-bearing blobs use a deterministic versioned envelope. Load either form with `gotreesitter.LoadLanguage`. `...WithContext` variants exist for cancellation. |
| `grammargen.EmitGrammarGo(g *Grammar, pkgName, funcName string) ([]byte, error)` | `grammargen/emit_grammar_go.go` | Emit Go DSL source that reconstructs the grammar — useful for vendoring the grammar as reviewable Go code instead of JSON. |
| `gotreesitter.LoadLanguage(data []byte) (*Language, error)` | `load_language.go` | Deserialize a blob at runtime. The only function needed to load a pre-compiled grammar — no grammargen import, no registry. |
| `grammars.LoadLanguage(name string, data []byte)` | `grammars/embedded_loader.go` | Like the above, but also attaches any external scanner / external lex-state tables registered for `name` in the `grammars` registry. |
diff --git a/public/docs.css b/public/docs.css
index 8cea922..8ba810e 100644
--- a/public/docs.css
+++ b/public/docs.css
@@ -24,6 +24,8 @@ a{color:var(--blue);text-decoration:none;font-weight:600}a:hover{color:var(--vio
.tnav{display:flex;gap:6px;align-items:center}
.ghlink{font:600 13px 'JetBrains Mono',monospace;color:var(--ink);background:var(--paper);padding:7px 12px;border-radius:2px;border:2px solid var(--paper)}
.ghlink:hover{background:var(--yellow);color:var(--ink)}
+.ghlink[aria-current="page"]{background:var(--color-yellow);color:var(--color-ink)}
+:where(a,button,input,select,textarea,summary):focus-visible{outline:3px solid var(--color-blue);outline-offset:3px}
.newpill{font:600 11px 'JetBrains Mono',monospace;color:var(--ink);background:var(--yellow);padding:5px 9px;border-radius:2px;text-transform:uppercase;letter-spacing:.5px}
.body{display:flex;align-items:flex-start;gap:0}
.sidebar{position:sticky;top:58px;align-self:flex-start;width:250px;flex:none;height:calc(100vh - 58px);overflow-y:auto;padding:20px 16px 40px;border-right:4px solid var(--ink);background:var(--paper)}
diff --git a/scripts/verify-production.sh b/scripts/verify-production.sh
index ccd2695..78d0551 100755
--- a/scripts/verify-production.sh
+++ b/scripts/verify-production.sh
@@ -12,7 +12,7 @@ fail() {
exit 1
}
-for command in go gosx tinygo curl grep sed awk mktemp find; do
+for command in git go gosx tinygo curl grep sed awk mktemp find sort; do
command -v "$command" >/dev/null 2>&1 || fail "required command not found: $command"
done
@@ -43,10 +43,9 @@ gosx_cli_version="$(gosx version 2>&1)"
grep -Fq "GoSX ${gosx_version#v}" README.md || fail "README GoSX requirement does not match go.mod"
grep -Fq "m31labs.dev/gosx/cmd/gosx@$gosx_version" README.md || fail "README GoSX install command does not match go.mod"
-# Application behavior must be authored in Go/GoSX. Framework-generated
-# runtime assets in dist are allowed; repository-authored JS is not.
-if find . -path './.git' -prune -o -path './dist' -prune -o -path './build' -prune \
- -o -type f \( -name '*.js' -o -name '*.jsx' -o -name '*.ts' -o -name '*.tsx' \) -print -quit | grep -q .; then
+# Application behavior must be authored in Go/GoSX. Ignore generated files
+# through the repository's normal ignore rules. Reject tracked or new source.
+if git ls-files --cached --others --exclude-standard -- '*.js' '*.jsx' '*.ts' '*.tsx' | grep -q .; then
fail "application-authored JavaScript remains in the repository"
fi
if grep -R -n --include='*.gsx' '