From e9c150fbf54c8d9b2875f3092dac8f3f454b1d35 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:12:37 +0000 Subject: [PATCH 1/7] Initial plan From 7237bbf4d560ca6bef614f06f2df6be4c0d355c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:20:43 +0000 Subject: [PATCH 2/7] Generate repository pages for sidebar mode --- cmd/cmd_test.go | 19 ++ cmd/index.go | 30 +- docs/content/commands/index.md | 75 ++--- docs/content/configuration.md | 28 +- .../output-formats/starlight-sidebar.md | 103 +++++- internal/index/index.go | 130 ++++++-- internal/index/index_test.go | 182 ++++++++++- internal/index/pages.go | 301 ++++++++++++++++++ internal/index/testdata/golden/index.mjs | 4 +- 9 files changed, 774 insertions(+), 98 deletions(-) create mode 100644 internal/index/pages.go diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index a9603e5..6d4294e 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -72,6 +72,25 @@ func TestIndexCommand(t *testing.T) { }) } +func TestIndexStarlightScaffoldDoesNotInvokeGitHub(t *testing.T) { + dir := t.TempDir() + testutil.Chdir(t, dir) + t.Setenv("PATH", t.TempDir()) + stdout, err := run(t, "index", "--starlight") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, "Wrote repository-template.md") { + t.Errorf("stdout = %q", stdout) + } + if _, err := os.Stat("repository-template.md"); err != nil { + t.Fatal(err) + } + if _, err := run(t, "index", "--starlight"); err == nil { + t.Fatal("expected refusing to overwrite the existing template") + } +} + func TestTemplateCommand(t *testing.T) { dir := t.TempDir() cfg := testutil.WriteFile(t, dir, "cfg.yml", "template:\n output: "+filepath.Join(dir, "site")+"\n") diff --git a/cmd/index.go b/cmd/index.go index e5a4a49..a650af4 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -11,29 +11,48 @@ func newIndexCmd() *cobra.Command { var ( configFile string flags index.Config + starlight bool ) c := &cobra.Command{ Use: "index", Short: "Generate an index of documentation repositories", Long: `List repositories of a user/organization that carry a given topic and -write their name, description and homepage URL to a YAML, JSON, HTML or Starlight sidebar file. +write their name, description and homepage URL to a YAML, JSON or HTML file. +The sidebar format writes repository pages from a Markdown template and a paired +Starlight sidebar module. With --file the extension decides the format; --file and --format are mutually exclusive. Missing parent directories are created. +Use --starlight to copy the built-in Markdown page template without contacting GitHub. Precedence: flags > config file (.docs.yml) > built-in fallbacks. -Config keys, under an "index" node: owner, visibility, topic, format, file, limit.`, +Config keys, under an "index" node: owner, visibility, topic, format, file, pages-dir, template, base-url, badge-url, limit.`, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := index.LoadConfig(configFile, cmd.Flags().Changed("config")) if err != nil { return err } - path, err := index.Run(index.Merge(cfg, flags)) + merged := index.Merge(cfg, flags) + if starlight { + destination := merged.Template + if destination == "" { + destination = "repository-template.md" + } + if err := index.StarlightTemplate(destination); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination) + return nil + } + path, err := index.Run(merged) if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", path) + if merged.Format == "sidebar" { + fmt.Fprintf(cmd.OutOrStdout(), "Wrote pages in %s\n", merged.PagesDir) + } return nil }, } @@ -45,7 +64,12 @@ Config keys, under an "index" node: owner, visibility, topic, format, file, limi f.StringVarP(&flags.Topic, "topic", "t", "", "Repository topic to filter on") f.StringVarP(&flags.Format, "format", "F", "", "Output format: yaml, json, html or sidebar (not valid with --file)") f.StringVarP(&flags.File, "file", "f", "", "Output file path (.yml, .yaml, .json, .html or .mjs); extension sets the format") + f.StringVar(&flags.PagesDir, "pages-dir", "", "Directory for generated Markdown repository pages (sidebar format)") + f.StringVar(&flags.Template, "template", "", "Markdown template for repository pages; with --starlight, destination path") + f.StringVar(&flags.BaseURL, "base-url", "", "Base URL for generated local sidebar links") + f.StringVar(&flags.BadgeURL, "badge-url", "", "Optional status badge URL for repository page templates") f.IntVarP(&flags.Limit, "limit", "l", 0, "Maximum number of repositories to fetch") + f.BoolVar(&starlight, "starlight", false, "Copy the built-in Starlight repository page template and exit") c.MarkFlagsMutuallyExclusive("file", "format") return c } diff --git a/docs/content/commands/index.md b/docs/content/commands/index.md index 03b14e2..11c6c9c 100644 --- a/docs/content/commands/index.md +++ b/docs/content/commands/index.md @@ -1,9 +1,9 @@ --- title: index -description: List documentation repositories and write their metadata to an output file. +description: Export repository data or generate repository pages and a Starlight sidebar. --- -**`gh docs index` lists repositories owned by a GitHub user or organization, filters them by repository topic, and writes each matching repository's name, description, and homepage URL.** +**`gh docs index` lists repositories owned by a GitHub user or organization and filters them by repository topic. YAML, JSON, and HTML produce a single-file export; sidebar mode produces one Markdown page per repository and a Starlight sidebar module.** ## Usage @@ -15,17 +15,22 @@ The owner can be set in `.docs.yml` instead of passed as a flag. The command inv ## Flags -| Flag | Default | Description | -| -------------------- | ---------------- | -------------------------------------------------------------------------------------- | -| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | -| `-o`, `--owner` | None | GitHub user or organization to list. Required unless configured. | -| `-v`, `--visibility` | `public` | Repository visibility: `public`, `internal`, or `private`. | -| `-t`, `--topic` | `documentation` | Repository topic to match. Matching is case-insensitive. | -| `-F`, `--format` | `yaml` | Output format: `yaml`, `json`, `html`, or `sidebar`. Cannot be used with `--file`. | -| `-f`, `--file` | `docs-repos.yml` | Output path. Its extension selects the format. Missing parent directories are created. | -| `-l`, `--limit` | `1000` | Maximum number of repositories returned by `gh repo list`. | - -Supported output extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. The `.mjs` extension selects the Starlight sidebar format. +| Flag | Default | Description | +| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | +| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | +| `-o`, `--owner` | None | GitHub user or organization to list. Required unless configured. | +| `-v`, `--visibility` | `public` | Repository visibility: `public`, `internal`, or `private`. | +| `-t`, `--topic` | `documentation` | Repository topic to match. Matching is case-insensitive. | +| `-F`, `--format` | `yaml` | Output format: `yaml`, `json`, `html`, or `sidebar`. Cannot be used with `--file`. | +| `-f`, `--file` | `docs-repos.yml` | Output path. `.mjs` selects paired sidebar/page generation. | +| `--pages-dir` | `docs/content/repo` | Directory for generated Markdown pages in sidebar mode. | +| `--template` | Built-in Starlight page | Markdown template in sidebar mode; with `--starlight`, destination path. | +| `--base-url` | Inferred | URL prefix for local sidebar links when the filesystem layout does not determine the site route. | +| `--badge-url` | None | Optional status badge URL exposed to the page template. | +| `--starlight` | `false` | Copy the maintained page template and exit without querying GitHub. | +| `-l`, `--limit` | `1000` | Maximum number of repositories returned by `gh repo list`. | + +Supported output extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. YAML, JSON, and HTML are single-file exports. The `.mjs` extension selects the paired sidebar/pages format. ## formats @@ -83,49 +88,21 @@ An empty GitHub homepage URL is emitted as an empty string. The repository descr ### Sidebar (ES module) -The `sidebar` format writes an ES module exporting an Astro Starlight theme compatible sidebar array. Select it with `--format sidebar` or give `--file` a `.mjs` extension. +The `sidebar` format writes a Markdown page for every matching repository and an ES module exporting local links to those pages. Select it with `--format sidebar` or give `--file` a `.mjs` extension. See [Starlight's sidebar reference](https://starlight.astro.build/guides/sidebar/) ```sh -gh docs index --owner mindovermachine-dev --file docs/docs-repos.mjs +gh docs index --owner mindovermachine-dev --file docs/sidebar/repositories.mjs \ + --pages-dir astro/src/content/docs/repositories \ + --template astro/repository-template.md ``` -The export contains a `Documentation` group. Each matching repository becomes a `{ label, link }` item, the form supported for external links by Starlight. `label` is the repository name. `link` uses the GitHub homepage URL when configured; if it is empty, the CLI uses `https://github.com//`. +The generated module exports one `Documentation` group with `{ label, link }` items. Links point to the pages generated in the same invocation, not to GitHub or repository homepage URLs. For the default `docs/content/repo` directory the inferred route is `/repo//`; for a path under `src/content/docs`, the route is inferred relative to `docs`. Use `--base-url` when you need to specify a route explicitly. Missing directories are created and generated pages are overwritten. Pages for repositories no longer selected are not deleted and must be cleaned up manually. -```js -export const sidebar = [ - { - label: "Documentation", - items: [{ label: "remote-astro-theme", link: "https://docs.example.org/remote-astro-theme/" }], - }, -]; -``` +Run `gh docs index --starlight --template astro/repository-template.md` to copy the maintained starter Markdown template without querying GitHub. If omitted, the destination is `repository-template.md` in the current directory. Existing files are never overwritten. Edit the copied file and pass it to a normal sidebar invocation. -Import the named export into the site's Astro configuration and use it in Starlight's main configuration site. The following is an example of how it is used in combination with a [`remote-astro-theme`](https://docs.mindovermachine.dev/remote-astro-theme) - -```js -// from mindovermachine-dev/mindovermachine-dev.github.io -import { sidebar } from "./docs-repos.mjs"; - -export default { - theme: "mindovermachine-dev/mom-doc-theme@main", - astro: { - site: "https://docs.mindovermachine.dev", - base: ".", - server: { host: true }, - }, - title: "Mind over Machine Dev Docs", - social: [ - { - icon: "github", - label: "GitHub", - href: "https://github.com/mindovermachine-dev", - }, - ], - sidebar, -}; -``` +See [Starlight sidebar and page templates](../output-formats/starlight-sidebar.md) for template keywords, delimiters, configuration, and site imports. ## Examples @@ -141,7 +118,7 @@ Write a JSON index at a specific path: gh docs index --owner mindovermachine-dev --file site/data/docs-repos.json ``` -Select an organization and topic from flags, and write a Starlight sidebar module: +Select an organization and topic from flags, and write repository pages plus a Starlight sidebar module: ```sh gh docs index --owner mindovermachine-dev --topic documentation --file docs/docs-repos.mjs diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 2ee76da..9a5de09 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -10,7 +10,11 @@ index: owner: mindovermachine-dev visibility: public topic: documentation - format: yaml + format: sidebar + file: docs/sidebar/repositories.mjs + pages-dir: astro/src/content/docs/repositories + template: astro/repository-template.md + base-url: /repositories limit: 1000 template: @@ -22,17 +26,23 @@ version: ## `index` settings -| Key | Default | Description | -| ------------ | --------------------- | ------------------------------------------------------------------------------------ | -| `owner` | None | GitHub user or organization. Required unless provided with `--owner`. | -| `visibility` | `public` | One of `public`, `internal`, or `private`. | -| `topic` | `documentation` | Repository topic used to select repositories. | -| `format` | `yaml` | One of `yaml`, `json`, `html`, or `sidebar`. | -| `file` | Derived from `format` | Output path. Supported extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. | -| `limit` | `1000` | Maximum repositories fetched from GitHub. | +| Key | Default | Description | +| ------------ | ----------------------- | --------------------------------------------------------------------------------------------------- | +| `owner` | None | GitHub user or organization. Required unless provided with `--owner`. | +| `visibility` | `public` | One of `public`, `internal`, or `private`. | +| `topic` | `documentation` | Repository topic used to select repositories. | +| `format` | `yaml` | `yaml`, `json`, and `html` are single-file exports; `sidebar` generates pages and a sidebar module. | +| `file` | Derived from `format` | Output path. `.mjs` selects paired sidebar/page generation. | +| `pages-dir` | `docs/content/repo` | Markdown page output directory (sidebar mode). | +| `template` | Built-in Starlight page | User-authored Markdown template (sidebar mode); also the `--starlight` scaffold destination. | +| `base-url` | Inferred | URL prefix for local sidebar links when not inferable from the content path. | +| `badge-url` | None | Optional status badge URL exposed to templates. | +| `limit` | `1000` | Maximum repositories fetched from GitHub. | Do not set both `index.file` and `index.format`; the configuration is rejected. Likewise, `--file` and `--format` cannot be passed together. When a flag supplies one of these settings, it overrides the config value for the other and selects the output accordingly. +Flags override `.docs.yml`, which overrides built-in defaults. Relative output and template paths are resolved from the current working directory. `pages-dir`, `template`, `base-url`, and `badge-url` apply to sidebar mode; they are ignored for YAML, JSON, and HTML exports. Without `template`, sidebar mode uses the maintained Starlight starter. Generated pages are replaced on later runs, but stale pages are not deleted automatically. + ## `template` settings | Key | Default | Description | diff --git a/docs/content/output-formats/starlight-sidebar.md b/docs/content/output-formats/starlight-sidebar.md index f7203a4..77327c9 100644 --- a/docs/content/output-formats/starlight-sidebar.md +++ b/docs/content/output-formats/starlight-sidebar.md @@ -1,35 +1,106 @@ --- -title: Starlight sidebar output -description: Generate an importable Starlight sidebar module from matching repositories. +title: Starlight sidebar and page templates +description: Generate repository pages from Markdown templates and link them in a Starlight sidebar. --- -The `sidebar` format writes an ES module exporting a Starlight-compatible sidebar array. Select it with `--format sidebar` or give `--file` a `.mjs` extension. +YAML, JSON, and HTML formats write one data or index file. `sidebar` mode writes a Markdown page for every matching repository plus an ES module containing the Starlight sidebar entries. Use `--format sidebar` or a `.mjs` output path: ```sh -gh docs index --owner mindovermachine-dev --file docs/docs-repos.mjs +gh docs index --owner mindovermachine-dev \ + --file docs/sidebar/repositories.mjs \ + --pages-dir astro/src/content/docs/repositories \ + --template astro/repository-template.md ``` -The export contains a `Documentation` group. Each matching repository becomes a `{ label, link }` item, the form supported for external links by Starlight. `label` is the repository name. `link` uses the GitHub homepage URL when configured; if it is empty, the CLI uses `https://github.com//`. +If `--template` is omitted, the command uses the maintained Starlight starter template in `internal/index/templates/starlight.md`. To copy it for editing, without contacting GitHub, run: -```js -export const sidebar = [ - { - label: "Documentation", - items: [{ label: "remote-astro-theme", link: "https://docs.example.org/remote-astro-theme/" }], - }, -]; +```sh +gh docs index --starlight --template astro/repository-template.md +``` + +The default destination is `repository-template.md` in the current directory. Scaffolding refuses to overwrite an existing file. Pass the edited file to a normal `sidebar` invocation. + +## Template syntax and fields + +Templates are regular Markdown files parsed with Go `text/template`. The gh-docs-specific delimiters are `[%` and `%]`, not Astro, Liquid, or Jekyll syntax. Markdown is rendered as-is (it is not HTML-escaped). The `yaml` helper safely encodes values for YAML front matter; use `[% yaml .Title %]` or `[% .Title | yaml %]`. + +The template receives a `Repo` with these fields. Optional metadata is an empty string, empty list, or zero when unavailable and can be checked with `if`. + +| Expression | Meaning | +| ----------------------------- | ------------------------------------------------- | +| `[% .Owner %]` | GitHub owner or organization. | +| `[% .Name %]` | Repository name. | +| `[% .Title %]` | Display title (repository name by default). | +| `[% .Description %]` | Description, if available. | +| `[% .Slug %]` | Safe generated page slug. | +| `[% .RepoURL %]` | Canonical GitHub repository URL. | +| `[% .HomepageURL %]` | Configured documentation/homepage URL. | +| `[% .Topics %]` | Repository topic names; iterate with `range`. | +| `[% .BadgeURL %]` | Configured status badge URL, or empty. | +| `[% .DefaultBranch %]` | Default branch, if available. | +| `[% .LatestCommit.SHA %]` | Short latest default-branch commit SHA. | +| `[% .LatestCommit.Date %]` | Commit date in RFC 3339 format. | +| `[% .LatestCommit.Message %]` | First line of the latest commit message. | +| `[% .LatestVersion %]` | Highest stable SemVer tag, excluding prereleases. | +| `[% .Stars %]` | Star count, when collected. | +| `[% .License %]` | SPDX license identifier, if available. | +| `[% .UpdatedAt %]` | Repository update timestamp in RFC 3339 format. | + +For example, this is a complete user-authored Markdown page template: + +```markdown +--- +title: [% yaml .Title %] +description: [% yaml .Description %] +--- + +# [% .Title %] + +[% if .Description %][% .Description %][% end %] + +Source: [% .RepoURL %] + +[% if .HomepageURL %] +Documentation: [% .HomepageURL %] +[% end %] + +[% if .Topics %] +Topics: +[% range .Topics %]- [% . %] +[% end %] +[% end %] + +[% if .LatestCommit.SHA %] +Latest commit: [% .LatestCommit.SHA %] โ€” [% .LatestCommit.Message %] +[% end %] ``` -Import the named export into the site's Astro configuration and pass it to Starlight: +The complete maintained starter demonstrates every supported field, including optional commit, version, license, stars, and badge values. Template parse and execution errors identify the template and (for execution errors) the repository. Pages and sidebar content are rendered before output files are written. Metadata lookup failures are warnings; discovery, rendering, and required output write failures stop the command. + +## Sidebar links and site setup + +The generated module exports one `Documentation` group with one `{ label, link }` item per page. Labels and local links use JSON-safe encoding. Links never point to GitHub or repository homepages: they point to the pages created in the same run. + +For the default `docs/content/repo` directory, links have the form `/repo//`. When a path includes `content/docs`, the route is inferred relative to that `docs` directory; for other layouts, the last directory name is used. Set `base-url` / `--base-url` if the inferred route is not the public Starlight route. For example, `astro/src/content/docs/repositories` generates `/repositories//`. + +Import the generated export into the site's configuration. Existing groups can be combined with a spread: ```js import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; -import { sidebar } from "./docs/docs-repos.mjs"; +import { sidebar } from "./docs/sidebar/repositories.mjs"; export default defineConfig({ - integrations: [starlight({ title: "Documentation", sidebar })], + integrations: [ + starlight({ + title: "Documentation", + sidebar: [ + ...sidebar, + { label: "Guides", items: [{ label: "Getting started", link: "/guides/start/" }] }, + ], + }), + ], }); ``` -See [Starlight's sidebar reference](https://starlight.astro.build/reference/configuration/#sidebar) for the supported item schema, and [YAML](./yaml.md), [JSON](./json.md), and [HTML](./html.md) for other CLI output formats. +Generated page files are overwritten on later runs. Pages for repositories that are no longer selected are not deleted; remove stale pages manually. YAML, JSON, and HTML modes do not generate pages or fetch sidebar-only metadata. diff --git a/internal/index/index.go b/internal/index/index.go index d2422a0..82bad3e 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -6,11 +6,12 @@ import ( "encoding/json" "errors" "fmt" - "html/template" + htmltemplate "html/template" "os" - "os/exec" "path/filepath" + "sort" "strings" + texttemplate "text/template" "github.com/mindovermachine-dev/gh-docs/internal/config" "gopkg.in/yaml.v3" @@ -23,21 +24,52 @@ type Config struct { Topic string `yaml:"topic"` Format string `yaml:"format"` File string `yaml:"file"` + PagesDir string `yaml:"pages-dir"` + Template string `yaml:"template"` + BaseURL string `yaml:"base-url"` + BadgeURL string `yaml:"badge-url"` Limit int `yaml:"limit"` } // Repo is a single entry in the generated index. type Repo struct { - Name string `yaml:"name" json:"name"` - Description string `yaml:"description" json:"description"` - HomepageURL string `yaml:"homepageUrl" json:"homepageUrl"` - SidebarURL string `yaml:"-" json:"-"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description"` + HomepageURL string `yaml:"homepageUrl" json:"homepageUrl"` + Owner string `yaml:"-" json:"-"` + Title string `yaml:"-" json:"-"` + Slug string `yaml:"-" json:"-"` + RepoURL string `yaml:"-" json:"-"` + Topics []string `yaml:"-" json:"-"` + BadgeURL string `yaml:"-" json:"-"` + DefaultBranch string `yaml:"-" json:"-"` + LatestCommit LatestCommit `yaml:"-" json:"-"` + LatestVersion string `yaml:"-" json:"-"` + Stars int `yaml:"-" json:"-"` + License string `yaml:"-" json:"-"` + UpdatedAt string `yaml:"-" json:"-"` + SidebarURL string `yaml:"-" json:"-"` +} + +// LatestCommit contains metadata for a repository's default-branch head commit. +type LatestCommit struct { + SHA string + Date string + Message string } type ghRepo struct { Name string `json:"name"` Description string `json:"description"` HomepageURL string `json:"homepageUrl"` + DefaultBranchRef *struct { + Name string `json:"name"` + } `json:"defaultBranchRef"` + StargazerCount int `json:"stargazerCount"` + LicenseInfo *struct { + SPDXID string `json:"spdxId"` + } `json:"licenseInfo"` + UpdatedAt string `json:"updatedAt"` RepositoryTopics []struct { Name string `json:"name"` } `json:"repositoryTopics"` @@ -49,6 +81,7 @@ var fallback = Config{ Topic: "documentation", Format: "yaml", File: "docs-repos", // extension is appended from the format + PagesDir: "docs/content/repo", Limit: 1000, } @@ -114,9 +147,24 @@ func Merge(base, overrides Config) Config { if overrides.Limit != 0 { out.Limit = overrides.Limit } + if overrides.PagesDir != "" { + out.PagesDir = overrides.PagesDir + } + if overrides.Template != "" { + out.Template = overrides.Template + } + if overrides.BaseURL != "" { + out.BaseURL = overrides.BaseURL + } + if overrides.BadgeURL != "" { + out.BadgeURL = overrides.BadgeURL + } if out.Visibility == "" { out.Visibility = fallback.Visibility } + if out.PagesDir == "" { + out.PagesDir = fallback.PagesDir + } if out.Topic == "" { out.Topic = fallback.Topic } @@ -155,11 +203,15 @@ func (c Config) Validate() error { return nil } -func ghList(owner, visibility string, limit int) ([]byte, error) { - out, err := exec.Command("gh", "repo", "list", owner, - "--visibility", visibility, - "--limit", fmt.Sprint(limit), - "--json", "name,homepageUrl,description,repositoryTopics").Output() +func ghList(cfg Config) ([]byte, error) { + fields := "name,homepageUrl,description,repositoryTopics" + if cfg.Format == "sidebar" { + fields += ",defaultBranchRef,stargazerCount,licenseInfo,updatedAt" + } + out, err := runGH("repo", "list", cfg.Owner, + "--visibility", cfg.Visibility, + "--limit", fmt.Sprint(cfg.Limit), + "--json", fields) if err != nil { return nil, fmt.Errorf("gh repo list failed: %w", err) } @@ -168,7 +220,7 @@ func ghList(owner, visibility string, limit int) ([]byte, error) { // Collect lists repos and keeps those carrying the configured topic. func Collect(cfg Config) ([]Repo, error) { - raw, err := ghList(cfg.Owner, cfg.Visibility, cfg.Limit) + raw, err := ghList(cfg) if err != nil { return nil, err } @@ -180,19 +232,37 @@ func Collect(cfg Config) ([]Repo, error) { for _, r := range repos { for _, t := range r.RepositoryTopics { if strings.EqualFold(t.Name, cfg.Topic) { - link := r.HomepageURL - if link == "" { - link = fmt.Sprintf("https://github.com/%s/%s", cfg.Owner, r.Name) + repo := Repo{ + Name: r.Name, Title: r.Name, Owner: cfg.Owner, + Description: r.Description, HomepageURL: r.HomepageURL, + RepoURL: fmt.Sprintf("https://github.com/%s/%s", cfg.Owner, r.Name), + BadgeURL: cfg.BadgeURL, + } + for _, topic := range r.RepositoryTopics { + repo.Topics = append(repo.Topics, topic.Name) + } + if cfg.Format == "sidebar" { + if r.DefaultBranchRef != nil { + repo.DefaultBranch = r.DefaultBranchRef.Name + } + if r.LicenseInfo != nil { + repo.License = r.LicenseInfo.SPDXID + } + repo.Stars, repo.UpdatedAt = r.StargazerCount, r.UpdatedAt } - result = append(result, Repo{Name: r.Name, Description: r.Description, HomepageURL: r.HomepageURL, SidebarURL: link}) + result = append(result, repo) break } } } + if cfg.Format == "sidebar" { + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + collectMetadata(cfg.Owner, result) + } return result, nil } -var htmlTmpl = template.Must(template.New("index").Parse(` +var htmlTmpl = htmltemplate.Must(htmltemplate.New("index").Parse(` @@ -219,7 +289,10 @@ func renderSidebar(repos []Repo) ([]byte, error) { } linkValue := repo.SidebarURL if linkValue == "" { - linkValue = repo.HomepageURL + linkValue = pageURL("docs/content/repo", "", repo.Slug) + if repo.Slug == "" { + linkValue = pageURL("docs/content/repo", "", slug(repo.Name)) + } } link, err := json.Marshal(linkValue) if err != nil { @@ -266,10 +339,21 @@ func Run(cfg Config) (string, error) { if err := cfg.Validate(); err != nil { return "", err } + var pageTemplate *texttemplate.Template + if cfg.Format == "sidebar" { + var err error + pageTemplate, err = loadPageTemplate(cfg.Template) + if err != nil { + return "", err + } + } repos, err := Collect(cfg) if err != nil { return "", err } + if cfg.Format == "sidebar" { + return writePagesAndSidebar(cfg, repos, pageTemplate) + } data, err := Render(repos, cfg.Format) if err != nil { return "", err @@ -283,3 +367,13 @@ func Run(cfg Config) (string, error) { } return path, nil } + +// StarlightTemplate copies the maintained starter page template without overwriting files. +func StarlightTemplate(destination string) error { + return scaffoldStarlight(destination) +} + +// PageURL returns the local Starlight route for a generated repository page. +func PageURL(pagesDir, baseURL, repoSlug string) string { + return pageURL(pagesDir, baseURL, repoSlug) +} diff --git a/internal/index/index_test.go b/internal/index/index_test.go index b864728..521ec02 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "github.com/mindovermachine-dev/gh-docs/internal/testutil" @@ -76,6 +77,7 @@ func TestLoadConfig(t *testing.T) { wantErr bool }{ {"index node", "index:\n owner: acme\n topic: docs\n limit: 5\n", true, Config{Owner: "acme", Topic: "docs", Limit: 5}, false}, + {"page settings", "index:\n pages-dir: site/docs/repositories\n template: repository.md\n base-url: /repositories\n", true, Config{PagesDir: "site/docs/repositories", Template: "repository.md", BaseURL: "/repositories"}, false}, {"other nodes ignored", "template:\n output: x\n", true, Config{}, false}, {"missing optional", "", false, Config{}, false}, {"missing required", "", true, Config{}, true}, @@ -160,8 +162,11 @@ func TestRunGolden(t *testing.T) { for _, name := range []string{"index.yml", "index.yaml", "index.json", "index.html", "index.mjs"} { t.Run(name, func(t *testing.T) { - out := filepath.Join(t.TempDir(), "nested", "dir", name) + dir := t.TempDir() + out := filepath.Join(dir, "nested", "dir", name) cfg := Merge(Config{}, Config{Owner: "o", File: out}) + cfg.PagesDir = filepath.Join(dir, "pages") + cfg.BaseURL = "/repo" path, err := Run(cfg) if err != nil { t.Fatal(err) @@ -205,6 +210,181 @@ func TestRunDefaultsToFormatExtension(t *testing.T) { } } +func TestRunSidebarFormatUsesPairedDefaults(t *testing.T) { + testutil.FakeGH(t, testutil.ReposFixture(), 0) + dir := t.TempDir() + testutil.Chdir(t, dir) + path, err := Run(Merge(Config{}, Config{Owner: "o", Format: "sidebar"})) + if err != nil { + t.Fatal(err) + } + if path != "docs-repos.mjs" { + t.Fatalf("path = %s", path) + } + if _, err := os.Stat(filepath.Join("docs", "content", "repo", "alpha.md")); err != nil { + t.Fatal(err) + } +} + +func TestRunSidebarGeneratesPagesAndLocalSidebar(t *testing.T) { + testutil.FakeGH(t, testutil.ReposFixture(), 0) + dir := t.TempDir() + templatePath := testutil.WriteFile(t, dir, "repo.md", "# [% .Owner %]/[% .Name %]\n[% yaml .Description %]\n") + cfg := Merge(Config{}, Config{ + Owner: "o", File: filepath.Join(dir, "sidebar.mjs"), + PagesDir: filepath.Join(dir, "pages"), Template: templatePath, + BaseURL: "/repositories", + }) + if _, err := Run(cfg); err != nil { + t.Fatal(err) + } + page, err := os.ReadFile(filepath.Join(dir, "pages", "alpha.md")) + if err != nil { + t.Fatal(err) + } + if string(page) != "# o/alpha\nAlpha docs\n" { + t.Errorf("page = %q", page) + } + sidebar, err := os.ReadFile(cfg.File) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(sidebar), `{ label: "alpha", link: "/repositories/alpha/" }`) || + strings.Contains(string(sidebar), "https://") { + t.Errorf("unexpected sidebar: %s", sidebar) + } +} + +func TestStarlightTemplate(t *testing.T) { + dir := t.TempDir() + destination := filepath.Join(dir, "nested", "template.md") + if err := StarlightTemplate(destination); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + want, err := os.ReadFile("templates/starlight.md") + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatal("scaffold differs from maintained template") + } + if err := StarlightTemplate(destination); err == nil { + t.Fatal("expected existing destination error") + } +} + +func TestSidebarTemplateErrorsIdentifyRepository(t *testing.T) { + testutil.FakeGH(t, testutil.ReposFixture(), 0) + dir := t.TempDir() + templatePath := testutil.WriteFile(t, dir, "bad.md", "[% .UnknownField %]") + cfg := Merge(Config{}, Config{ + Owner: "o", File: filepath.Join(dir, "sidebar.mjs"), + PagesDir: filepath.Join(dir, "pages"), Template: templatePath, + }) + _, err := Run(cfg) + if err == nil || !strings.Contains(err.Error(), templatePath) || !strings.Contains(err.Error(), "repository alpha") { + t.Fatalf("error = %v", err) + } + if _, err := os.Stat(cfg.File); !os.IsNotExist(err) { + t.Fatalf("sidebar should not be written after render failure, stat error = %v", err) + } +} + +func TestSlugAndPageURL(t *testing.T) { + if got := slug("../My Repo__"); got != "my-repo" { + t.Errorf("slug = %q", got) + } + if got := PageURL("astro/src/content/docs/repositories", "", "alpha"); got != "/repositories/alpha/" { + t.Errorf("page URL = %q", got) + } + if got := PageURL("docs/content/repo", "/catalog/", "alpha"); got != "/catalog/alpha/" { + t.Errorf("configured page URL = %q", got) + } +} + +func TestSlugCollisionsFailBeforeWriting(t *testing.T) { + dir := t.TempDir() + tmpl, err := loadPageTemplate("") + if err != nil { + t.Fatal(err) + } + cfg := Merge(Config{}, Config{ + Format: "sidebar", File: filepath.Join(dir, "sidebar.mjs"), + PagesDir: filepath.Join(dir, "pages"), + }) + _, err = writePagesAndSidebar(cfg, []Repo{{Name: "repo.one"}, {Name: "repo-one"}}, tmpl) + if err == nil || !strings.Contains(err.Error(), "same page slug") { + t.Fatalf("error = %v", err) + } + if _, err := os.Stat(cfg.File); !os.IsNotExist(err) { + t.Fatalf("sidebar should not be written, stat error = %v", err) + } +} + +func TestHighestStableVersion(t *testing.T) { + tags := []struct { + Name string `json:"name"` + }{{"v1.9.0"}, {"v1.10.0"}, {"v2.0.0-rc.1"}, {"2.0.0+build.1"}, {"latest"}} + if got := highestStableVersion(tags); got != "2.0.0+build.1" { + t.Errorf("version = %q, want 2.0.0+build.1", got) + } +} + +func TestMergePageOptions(t *testing.T) { + base := Config{PagesDir: "configured/pages", Template: "configured.md", BaseURL: "/configured", BadgeURL: "configured.svg"} + overrides := Config{PagesDir: "flag/pages", Template: "flag.md", BaseURL: "/flag", BadgeURL: "flag.svg"} + got := Merge(base, overrides) + if got.PagesDir != "flag/pages" || got.Template != "flag.md" || got.BaseURL != "/flag" || got.BadgeURL != "flag.svg" { + t.Errorf("flag options did not override config: %+v", got) + } + got = Merge(Config{}, Config{}) + if got.PagesDir != "docs/content/repo" { + t.Errorf("default pages directory = %q", got.PagesDir) + } +} + +func TestSingleFileFormatsDoNotFetchSidebarMetadata(t *testing.T) { + dir := t.TempDir() + scriptDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + logPath := filepath.Join(dir, "calls") + script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$GH_CALL_LOG\"\ncat \"$GH_FIXTURE\"\n" + scriptPath := filepath.Join(scriptDir, "gh") + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("GH_CALL_LOG", logPath) + t.Setenv("GH_FIXTURE", testutil.ReposFixture()) + + for _, format := range []string{"yaml", "json", "html"} { + t.Run(format, func(t *testing.T) { + if err := os.WriteFile(logPath, nil, 0o600); err != nil { + t.Fatal(err) + } + cfg := Merge(Config{}, Config{ + Owner: "o", Format: format, File: filepath.Join(dir, "out."+extForFormat(format)), + }) + if _, err := Run(cfg); err != nil { + t.Fatal(err) + } + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if lines := strings.Split(strings.TrimSpace(string(calls)), "\n"); len(lines) != 1 || !strings.HasPrefix(lines[0], "repo list ") { + t.Errorf("unexpected gh calls: %q", calls) + } + }) + } +} + func TestRunErrors(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() diff --git a/internal/index/pages.go b/internal/index/pages.go new file mode 100644 index 0000000..ba598d3 --- /dev/null +++ b/internal/index/pages.go @@ -0,0 +1,301 @@ +package index + +import ( + "bytes" + "embed" + "encoding/json" + "fmt" + "log" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "text/template" + "time" + + "gopkg.in/yaml.v3" +) + +//go:embed templates/starlight.md +var starterFS embed.FS + +var safeName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +var stableVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) + +func runGH(args ...string) ([]byte, error) { + return exec.Command("gh", args...).Output() +} + +func slug(name string) string { + var out strings.Builder + lastDash := false + for _, r := range strings.ToLower(name) { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + out.WriteRune(r) + lastDash = false + } else if !lastDash { + out.WriteByte('-') + lastDash = true + } + } + return strings.Trim(out.String(), "-") +} + +func pageURL(pagesDir, baseURL, repoSlug string) string { + base := strings.TrimSpace(baseURL) + if base == "" { + clean := filepath.ToSlash(filepath.Clean(pagesDir)) + parts := strings.Split(clean, "/") + for i := len(parts) - 2; i >= 0; i-- { + if parts[i] == "content" && parts[i+1] == "docs" { + base = strings.Join(parts[i+2:], "/") + break + } + } + if base == "" { + base = filepath.Base(clean) + } + } + base = strings.Trim(base, "/") + if base == "" || base == "." { + return "/" + repoSlug + "/" + } + return "/" + base + "/" + repoSlug + "/" +} + +func loadPageTemplate(filename string) (*template.Template, error) { + var source []byte + var err error + if filename == "" { + source, err = starterFS.ReadFile("templates/starlight.md") + } else { + source, err = os.ReadFile(filename) + } + if err != nil { + if filename == "" { + return nil, fmt.Errorf("failed to read embedded Starlight template: %w", err) + } + return nil, fmt.Errorf("failed to read template %s: %w", filename, err) + } + name := filename + if name == "" { + name = "built-in Starlight template" + } + tmpl, err := template.New(name).Delims("[%", "%]").Funcs(template.FuncMap{ + "yaml": func(value any) (string, error) { + data, err := yaml.Marshal(value) + return strings.TrimSuffix(string(data), "\n"), err + }, + }).Parse(string(source)) + if err != nil { + return nil, fmt.Errorf("failed to parse template %s: %w", name, err) + } + return tmpl, nil +} + +func scaffoldStarlight(destination string) error { + if destination == "" { + destination = "repository-template.md" + } + if _, err := os.Lstat(destination); err == nil { + return fmt.Errorf("template destination %s already exists", destination) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check template destination %s: %w", destination, err) + } + source, err := starterFS.ReadFile("templates/starlight.md") + if err != nil { + return fmt.Errorf("failed to read embedded Starlight template: %w", err) + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", destination, err) + } + file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("failed to write template %s: %w", destination, err) + } + if _, err := file.Write(source); err != nil { + _ = file.Close() + _ = os.Remove(destination) + return fmt.Errorf("failed to write template %s: %w", destination, err) + } + if err := file.Close(); err != nil { + _ = os.Remove(destination) + return fmt.Errorf("failed to close template %s: %w", destination, err) + } + return nil +} + +func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (string, error) { + var err error + pagesDir, err := filepath.Abs(cfg.PagesDir) + if err != nil { + return "", fmt.Errorf("invalid pages directory %s: %w", cfg.PagesDir, err) + } + type page struct { + path string + data []byte + } + pages := make([]page, 0, len(repos)) + seen := make(map[string]string, len(repos)) + for i := range repos { + repo := &repos[i] + repo.Slug = slug(repo.Name) + if repo.Slug == "" { + return "", fmt.Errorf("repository %q has no safe page slug", repo.Name) + } + if previous, ok := seen[repo.Slug]; ok { + return "", fmt.Errorf("repositories %q and %q produce the same page slug %q", previous, repo.Name, repo.Slug) + } + seen[repo.Slug] = repo.Name + target := filepath.Join(pagesDir, repo.Slug+".md") + relative, err := filepath.Rel(pagesDir, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("page path for repository %q escapes pages directory", repo.Name) + } + if info, err := os.Lstat(target); err == nil && info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("page path %s is a symlink", target) + } else if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("failed to inspect page path %s: %w", target, err) + } + repo.SidebarURL = pageURL(cfg.PagesDir, cfg.BaseURL, repo.Slug) + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, *repo); err != nil { + name := cfg.Template + if name == "" { + name = "built-in Starlight template" + } + return "", fmt.Errorf("failed to render template %s for repository %s: %w", name, repo.Name, err) + } + pages = append(pages, page{path: target, data: rendered.Bytes()}) + } + sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name }) + sidebar, err := renderSidebar(repos) + if err != nil { + return "", err + } + for _, p := range pages { + if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil { + return "", fmt.Errorf("failed to create directory for %s: %w", p.path, err) + } + } + if err := os.MkdirAll(filepath.Dir(cfg.File), 0o755); err != nil { + return "", fmt.Errorf("failed to create directory for %s: %w", cfg.File, err) + } + for _, p := range pages { + if err := os.WriteFile(p.path, p.data, 0o644); err != nil { + return "", fmt.Errorf("failed to write %s: %w", p.path, err) + } + } + if err := os.WriteFile(cfg.File, sidebar, 0o644); err != nil { + return "", fmt.Errorf("failed to write %s: %w", cfg.File, err) + } + return cfg.File, nil +} + +func collectMetadata(owner string, repos []Repo) { + jobs := make(chan int) + var workers sync.WaitGroup + for range 8 { + workers.Add(1) + go func() { + defer workers.Done() + for i := range jobs { + repo := &repos[i] + if !safeName.MatchString(owner) || !safeName.MatchString(repo.Name) { + log.Printf("warning: skipping GitHub metadata for invalid repository path %q/%q", owner, repo.Name) + continue + } + collectRepoMetadata(owner, repo) + } + }() + } + for i := range repos { + jobs <- i + } + close(jobs) + workers.Wait() +} + +func collectRepoMetadata(owner string, repo *Repo) { + endpoint := "repos/" + owner + "/" + repo.Name + commitQuery := "per_page=1" + if repo.DefaultBranch != "" { + commitQuery = "sha=" + url.QueryEscape(repo.DefaultBranch) + "&" + commitQuery + } + commitData, err := runGH("api", endpoint+"/commits?"+commitQuery) + if err != nil { + log.Printf("warning: failed to fetch latest commit for %s/%s: %v", owner, repo.Name, err) + } else { + var commits []struct { + SHA string `json:"sha"` + Commit struct { + Author struct { + Date string `json:"date"` + } `json:"author"` + Message string `json:"message"` + } `json:"commit"` + } + if err := json.Unmarshal(commitData, &commits); err != nil { + log.Printf("warning: failed to parse latest commit for %s/%s: %v", owner, repo.Name, err) + } else if len(commits) > 0 { + sha := commits[0].SHA + if len(sha) > 7 { + sha = sha[:7] + } + date := "" + if parsed, err := time.Parse(time.RFC3339, commits[0].Commit.Author.Date); err == nil { + date = parsed.Format(time.RFC3339) + } + message := strings.SplitN(commits[0].Commit.Message, "\n", 2)[0] + repo.LatestCommit = LatestCommit{SHA: sha, Date: date, Message: message} + } + } + + tagsData, err := runGH("api", endpoint+"/tags?per_page=100") + if err != nil { + log.Printf("warning: failed to fetch tags for %s/%s: %v", owner, repo.Name, err) + return + } + var tags []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(tagsData, &tags); err != nil { + log.Printf("warning: failed to parse tags for %s/%s: %v", owner, repo.Name, err) + return + } + repo.LatestVersion = highestStableVersion(tags) +} + +func highestStableVersion(tags []struct { + Name string `json:"name"` +}) string { + best := "" + var bestParts [3]string + for _, tag := range tags { + match := stableVersion.FindStringSubmatch(tag.Name) + if match == nil { + continue + } + current := [3]string{match[1], match[2], match[3]} + if best == "" || greaterVersion(current, bestParts) { + best, bestParts = tag.Name, current + } + } + return best +} + +func greaterVersion(a, b [3]string) bool { + for i := range a { + if len(a[i]) != len(b[i]) { + return len(a[i]) > len(b[i]) + } + if a[i] != b[i] { + return a[i] > b[i] + } + } + return false +} diff --git a/internal/index/testdata/golden/index.mjs b/internal/index/testdata/golden/index.mjs index 6699053..8ee4036 100644 --- a/internal/index/testdata/golden/index.mjs +++ b/internal/index/testdata/golden/index.mjs @@ -2,8 +2,8 @@ export const sidebar = [ { label: "Documentation", items: [ - { label: "alpha", link: "https://example.github.io/alpha" }, - { label: "delta", link: "https://github.com/o/delta" }, + { label: "alpha", link: "/repo/alpha/" }, + { label: "delta", link: "/repo/delta/" }, ], }, ]; From 28116473c7d12bdcb631b310cc3b8f003d5c8932 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:21:58 +0000 Subject: [PATCH 3/7] Create pages directory for empty sidebar results --- internal/index/index_test.go | 16 ++++++++++++++++ internal/index/pages.go | 3 +++ 2 files changed, 19 insertions(+) diff --git a/internal/index/index_test.go b/internal/index/index_test.go index 521ec02..1b9a7f7 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -226,6 +226,22 @@ func TestRunSidebarFormatUsesPairedDefaults(t *testing.T) { } } +func TestRunSidebarCreatesPagesDirectoryWithNoMatches(t *testing.T) { + testutil.FakeGH(t, testutil.ReposFixture(), 0) + dir := t.TempDir() + pagesDir := filepath.Join(dir, "pages") + cfg := Merge(Config{}, Config{ + Owner: "o", Topic: "no-matches", Format: "sidebar", + File: filepath.Join(dir, "sidebar.mjs"), PagesDir: pagesDir, + }) + if _, err := Run(cfg); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(pagesDir); err != nil || !info.IsDir() { + t.Fatalf("pages directory was not created: %v", err) + } +} + func TestRunSidebarGeneratesPagesAndLocalSidebar(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() diff --git a/internal/index/pages.go b/internal/index/pages.go index ba598d3..52661b3 100644 --- a/internal/index/pages.go +++ b/internal/index/pages.go @@ -177,6 +177,9 @@ func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (st if err != nil { return "", err } + if err := os.MkdirAll(pagesDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create pages directory %s: %w", pagesDir, err) + } for _, p := range pages { if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil { return "", fmt.Errorf("failed to create directory for %s: %w", p.path, err) From ed6f3a35d728a73377705222cd08124da1d91c44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:58:12 +0000 Subject: [PATCH 4/7] Move Starlight scaffold to template command --- cmd/cmd_test.go | 40 +++++++-- cmd/index.go | 18 +---- cmd/template.go | 26 ++++-- docs/content/commands/index.md | 31 ++++--- docs/content/commands/template.md | 29 ++++--- docs/content/configuration.md | 26 +++--- .../output-formats/starlight-sidebar.md | 11 ++- internal/index/index.go | 12 +-- internal/index/index_test.go | 81 +++++++++++++++---- internal/index/pages.go | 49 +++++------ internal/index/templates/starlight.md | 7 ++ 11 files changed, 207 insertions(+), 123 deletions(-) diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 6d4294e..a270711 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -72,23 +72,53 @@ func TestIndexCommand(t *testing.T) { }) } -func TestIndexStarlightScaffoldDoesNotInvokeGitHub(t *testing.T) { +func TestTemplateStarlightScaffold(t *testing.T) { dir := t.TempDir() testutil.Chdir(t, dir) t.Setenv("PATH", t.TempDir()) - stdout, err := run(t, "index", "--starlight") + stdout, err := run(t, "template", "--starlight") if err != nil { t.Fatal(err) } - if !strings.Contains(stdout, "Wrote repository-template.md") { + if !strings.Contains(stdout, filepath.Join("docs", "repository-template.md")) { t.Errorf("stdout = %q", stdout) } - if _, err := os.Stat("repository-template.md"); err != nil { + if _, err := os.Stat(filepath.Join("docs", "repository-template.md")); err != nil { t.Fatal(err) } - if _, err := run(t, "index", "--starlight"); err == nil { + if _, err := run(t, "template", "--starlight"); err == nil { t.Fatal("expected refusing to overwrite the existing template") } + if _, err := run(t, "template", "--starlight", "--force"); err != nil { + t.Fatalf("force overwrite failed: %v", err) + } + + customOutput := filepath.Join(dir, "custom") + if _, err := run(t, "template", "--starlight", "--output", customOutput); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(customOutput, "repository-template.md")); err != nil { + t.Fatal(err) + } +} + +func TestIndexRequiresScaffoldedTemplateAndHidesScaffoldFlags(t *testing.T) { + dir := t.TempDir() + testutil.Chdir(t, dir) + t.Setenv("PATH", t.TempDir()) + if _, err := run(t, "index", "--owner", "acme", "--format", "sidebar"); err == nil || + !strings.Contains(err.Error(), "gh docs template --starlight --output docs") { + t.Fatalf("expected actionable missing-template error, got %v", err) + } + out, err := run(t, "index", "--help") + if err != nil { + t.Fatal(err) + } + for _, removed := range []string{"--template", "--starlight"} { + if strings.Contains(out, removed) { + t.Errorf("index help still advertises %s", removed) + } + } } func TestTemplateCommand(t *testing.T) { diff --git a/cmd/index.go b/cmd/index.go index a650af4..b19decf 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -11,7 +11,6 @@ func newIndexCmd() *cobra.Command { var ( configFile string flags index.Config - starlight bool ) c := &cobra.Command{ @@ -25,26 +24,15 @@ Starlight sidebar module. With --file the extension decides the format; --file and --format are mutually exclusive. Missing parent directories are created. -Use --starlight to copy the built-in Markdown page template without contacting GitHub. Precedence: flags > config file (.docs.yml) > built-in fallbacks. -Config keys, under an "index" node: owner, visibility, topic, format, file, pages-dir, template, base-url, badge-url, limit.`, +Config keys, under an "index" node: owner, visibility, topic, format, file, pages-dir, base-url, badge-url, limit. +Sidebar mode requires the user-authored docs/repository-template.md file; see "gh docs template".`, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := index.LoadConfig(configFile, cmd.Flags().Changed("config")) if err != nil { return err } merged := index.Merge(cfg, flags) - if starlight { - destination := merged.Template - if destination == "" { - destination = "repository-template.md" - } - if err := index.StarlightTemplate(destination); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination) - return nil - } path, err := index.Run(merged) if err != nil { return err @@ -65,11 +53,9 @@ Config keys, under an "index" node: owner, visibility, topic, format, file, page f.StringVarP(&flags.Format, "format", "F", "", "Output format: yaml, json, html or sidebar (not valid with --file)") f.StringVarP(&flags.File, "file", "f", "", "Output file path (.yml, .yaml, .json, .html or .mjs); extension sets the format") f.StringVar(&flags.PagesDir, "pages-dir", "", "Directory for generated Markdown repository pages (sidebar format)") - f.StringVar(&flags.Template, "template", "", "Markdown template for repository pages; with --starlight, destination path") f.StringVar(&flags.BaseURL, "base-url", "", "Base URL for generated local sidebar links") f.StringVar(&flags.BadgeURL, "badge-url", "", "Optional status badge URL for repository page templates") f.IntVarP(&flags.Limit, "limit", "l", 0, "Maximum number of repositories to fetch") - f.BoolVar(&starlight, "starlight", false, "Copy the built-in Starlight repository page template and exit") c.MarkFlagsMutuallyExclusive("file", "format") return c } diff --git a/cmd/template.go b/cmd/template.go index 2fb010f..481cf01 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -3,7 +3,9 @@ package cmd import ( "errors" "fmt" + "path/filepath" + "github.com/mindovermachine-dev/gh-docs/internal/index" "github.com/mindovermachine-dev/gh-docs/internal/template" "github.com/spf13/cobra" ) @@ -13,26 +15,38 @@ func newTemplateCmd() *cobra.Command { configFile string flags template.Config jekyll bool + starlight bool force bool ) c := &cobra.Command{ Use: "template", Short: "Generate starter files for the documentation site", - Long: `Generate a minimal GitHub Pages site that renders the list produced by -"index". Files are plain Jekyll sources; GitHub Pages builds them itself. + Long: `Generate starter files for the documentation site. + +Use --jekyll to generate a GitHub Pages index, or --starlight to copy a Markdown +repository page template used by "index --format sidebar". Precedence: flags > config file (.docs.yml) > built-in fallbacks. Config keys, under a "template" node: output.`, RunE: func(cmd *cobra.Command, _ []string) error { - if !jekyll { - return errors.New("a template type is required: use --jekyll") + if !jekyll && !starlight { + return errors.New("a template type is required: use --jekyll or --starlight") } cfg, err := template.LoadConfig(configFile, cmd.Flags().Changed("config")) if err != nil { return err } - paths, err := template.Jekyll(template.Merge(cfg, flags), force) + merged := template.Merge(cfg, flags) + if starlight { + destination := filepath.Join(merged.Output, "repository-template.md") + if err := index.StarlightTemplate(destination, force); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination) + return nil + } + paths, err := template.Jekyll(merged, force) if err != nil { return err } @@ -45,8 +59,10 @@ Config keys, under a "template" node: output.`, f := c.Flags() f.BoolVar(&jekyll, "jekyll", false, "Generate a Jekyll (GitHub Pages) index.md and _config.yml") + f.BoolVar(&starlight, "starlight", false, "Copy a Starlight repository page template into the output folder") f.StringVarP(&configFile, "config", "c", ".docs.yml", "Path to the config file") f.StringVarP(&flags.Output, "output", "o", "", "Output folder") f.BoolVar(&force, "force", false, "Overwrite existing files") + c.MarkFlagsMutuallyExclusive("jekyll", "starlight") return c } diff --git a/docs/content/commands/index.md b/docs/content/commands/index.md index 11c6c9c..ac7135a 100644 --- a/docs/content/commands/index.md +++ b/docs/content/commands/index.md @@ -15,20 +15,18 @@ The owner can be set in `.docs.yml` instead of passed as a flag. The command inv ## Flags -| Flag | Default | Description | -| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | -| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | -| `-o`, `--owner` | None | GitHub user or organization to list. Required unless configured. | -| `-v`, `--visibility` | `public` | Repository visibility: `public`, `internal`, or `private`. | -| `-t`, `--topic` | `documentation` | Repository topic to match. Matching is case-insensitive. | -| `-F`, `--format` | `yaml` | Output format: `yaml`, `json`, `html`, or `sidebar`. Cannot be used with `--file`. | -| `-f`, `--file` | `docs-repos.yml` | Output path. `.mjs` selects paired sidebar/page generation. | -| `--pages-dir` | `docs/content/repo` | Directory for generated Markdown pages in sidebar mode. | -| `--template` | Built-in Starlight page | Markdown template in sidebar mode; with `--starlight`, destination path. | -| `--base-url` | Inferred | URL prefix for local sidebar links when the filesystem layout does not determine the site route. | -| `--badge-url` | None | Optional status badge URL exposed to the page template. | -| `--starlight` | `false` | Copy the maintained page template and exit without querying GitHub. | -| `-l`, `--limit` | `1000` | Maximum number of repositories returned by `gh repo list`. | +| Flag | Default | Description | +| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------ | +| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | +| `-o`, `--owner` | None | GitHub user or organization to list. Required unless configured. | +| `-v`, `--visibility` | `public` | Repository visibility: `public`, `internal`, or `private`. | +| `-t`, `--topic` | `documentation` | Repository topic to match. Matching is case-insensitive. | +| `-F`, `--format` | `yaml` | Output format: `yaml`, `json`, `html`, or `sidebar`. Cannot be used with `--file`. | +| `-f`, `--file` | `docs-repos.yml` | Output path. `.mjs` selects paired sidebar/page generation. | +| `--pages-dir` | `docs/content/repo` | Directory for generated Markdown pages in sidebar mode. | +| `--base-url` | Inferred | URL prefix for local sidebar links when the filesystem layout does not determine the site route. | +| `--badge-url` | None | Optional status badge URL exposed to the page template. | +| `-l`, `--limit` | `1000` | Maximum number of repositories returned by `gh repo list`. | Supported output extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. YAML, JSON, and HTML are single-file exports. The `.mjs` extension selects the paired sidebar/pages format. @@ -94,13 +92,12 @@ See [Starlight's sidebar reference](https://starlight.astro.build/guides/sidebar ```sh gh docs index --owner mindovermachine-dev --file docs/sidebar/repositories.mjs \ - --pages-dir astro/src/content/docs/repositories \ - --template astro/repository-template.md + --pages-dir astro/src/content/docs/repositories ``` The generated module exports one `Documentation` group with `{ label, link }` items. Links point to the pages generated in the same invocation, not to GitHub or repository homepage URLs. For the default `docs/content/repo` directory the inferred route is `/repo//`; for a path under `src/content/docs`, the route is inferred relative to `docs`. Use `--base-url` when you need to specify a route explicitly. Missing directories are created and generated pages are overwritten. Pages for repositories no longer selected are not deleted and must be cleaned up manually. -Run `gh docs index --starlight --template astro/repository-template.md` to copy the maintained starter Markdown template without querying GitHub. If omitted, the destination is `repository-template.md` in the current directory. Existing files are never overwritten. Edit the copied file and pass it to a normal sidebar invocation. +Sidebar mode reads `docs/repository-template.md`. Create it with `gh docs template --starlight --output docs`; edit the copied file, then run `gh docs index --format sidebar` again. See [Starlight sidebar and page templates](../output-formats/starlight-sidebar.md) for template keywords, delimiters, configuration, and site imports. diff --git a/docs/content/commands/template.md b/docs/content/commands/template.md index 8e858c0..a15d163 100644 --- a/docs/content/commands/template.md +++ b/docs/content/commands/template.md @@ -1,9 +1,9 @@ --- title: template -description: Copy embedded starter files for a GitHub Pages Jekyll site. +description: Copy starter files for Jekyll sites or Starlight repository pages. --- -`gh docs template` copies embedded site templates into an output folder. The current template type is Jekyll, intended for GitHub Pages to build without a separate local Jekyll build step. +`gh docs template` copies starter files into an output folder. Use Jekyll for a simple GitHub Pages site or Starlight to create the Markdown template used by `gh docs index --format sidebar`. ## Usage @@ -13,23 +13,34 @@ gh docs template --jekyll --output docz By default, the output folder is `docs`. Use another folder when that name conflicts with the extension binary or an existing file. +Create the Starlight repository page template with: + +```sh +gh docs template --starlight +``` + +This writes `docs/repository-template.md`. Edit this standalone Markdown file, then use `gh docs index --format sidebar`; sidebar mode reads exactly that path. + ## Flags -| Flag | Default | Description | -| ---------------- | ----------- | ------------------------------------------------ | -| `--jekyll` | Not enabled | Required. Selects the Jekyll template. | -| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | -| `-o`, `--output` | `docs` | Destination folder. | -| `--force` | Not enabled | Overwrite generated files if they already exist. | +| Flag | Default | Description | +| ---------------- | ----------- | ----------------------------------------------------------------- | +| `--jekyll` | Not enabled | Select the Jekyll starter. Mutually exclusive with `--starlight`. | +| `--starlight` | Not enabled | Copy `repository-template.md` from the Starlight starter source. | +| `-c`, `--config` | `.docs.yml` | Path to the configuration file. | +| `-o`, `--output` | `docs` | Destination folder. | +| `--force` | Not enabled | Overwrite generated files if they already exist. | The `template` node in `.docs.yml` supports the `output` setting. The `--output` flag takes precedence over that setting. ## Generated files -The command copies the embedded files `index.md` and `_config.yml`. The index reads `docs-repos.yml` from the site root and renders each entry as a link when it has a homepage URL. +`--jekyll` copies the embedded files `index.md` and `_config.yml`. The index reads `docs-repos.yml` from the site root and renders each entry as a link when it has a homepage URL. If either generated file already exists, the command stops before writing unless `--force` is supplied. Use `--force` only when replacing those files is intended. ```sh gh docs template --jekyll --output docz --force ``` + +`--starlight` copies the maintained Markdown source from `internal/index/templates/starlight.md` to `/repository-template.md`. The default output folder therefore creates `docs/repository-template.md`; an alternate `--output` directory is honored. If the file exists, the command refuses to overwrite it unless `--force` is provided. diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 9a5de09..2477134 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -13,7 +13,6 @@ index: format: sidebar file: docs/sidebar/repositories.mjs pages-dir: astro/src/content/docs/repositories - template: astro/repository-template.md base-url: /repositories limit: 1000 @@ -26,22 +25,21 @@ version: ## `index` settings -| Key | Default | Description | -| ------------ | ----------------------- | --------------------------------------------------------------------------------------------------- | -| `owner` | None | GitHub user or organization. Required unless provided with `--owner`. | -| `visibility` | `public` | One of `public`, `internal`, or `private`. | -| `topic` | `documentation` | Repository topic used to select repositories. | -| `format` | `yaml` | `yaml`, `json`, and `html` are single-file exports; `sidebar` generates pages and a sidebar module. | -| `file` | Derived from `format` | Output path. `.mjs` selects paired sidebar/page generation. | -| `pages-dir` | `docs/content/repo` | Markdown page output directory (sidebar mode). | -| `template` | Built-in Starlight page | User-authored Markdown template (sidebar mode); also the `--starlight` scaffold destination. | -| `base-url` | Inferred | URL prefix for local sidebar links when not inferable from the content path. | -| `badge-url` | None | Optional status badge URL exposed to templates. | -| `limit` | `1000` | Maximum repositories fetched from GitHub. | +| Key | Default | Description | +| ------------ | --------------------- | --------------------------------------------------------------------------------------------------- | +| `owner` | None | GitHub user or organization. Required unless provided with `--owner`. | +| `visibility` | `public` | One of `public`, `internal`, or `private`. | +| `topic` | `documentation` | Repository topic used to select repositories. | +| `format` | `yaml` | `yaml`, `json`, and `html` are single-file exports; `sidebar` generates pages and a sidebar module. | +| `file` | Derived from `format` | Output path. `.mjs` selects paired sidebar/page generation. | +| `pages-dir` | `docs/content/repo` | Markdown page output directory (sidebar mode). | +| `base-url` | Inferred | URL prefix for local sidebar links when not inferable from the content path. | +| `badge-url` | None | Optional status badge URL exposed to templates. | +| `limit` | `1000` | Maximum repositories fetched from GitHub. | Do not set both `index.file` and `index.format`; the configuration is rejected. Likewise, `--file` and `--format` cannot be passed together. When a flag supplies one of these settings, it overrides the config value for the other and selects the output accordingly. -Flags override `.docs.yml`, which overrides built-in defaults. Relative output and template paths are resolved from the current working directory. `pages-dir`, `template`, `base-url`, and `badge-url` apply to sidebar mode; they are ignored for YAML, JSON, and HTML exports. Without `template`, sidebar mode uses the maintained Starlight starter. Generated pages are replaced on later runs, but stale pages are not deleted automatically. +Flags override `.docs.yml`, which overrides built-in defaults. Relative output paths are resolved from the current working directory. `pages-dir`, `base-url`, and `badge-url` apply to sidebar mode; they are ignored for YAML, JSON, and HTML exports. Sidebar mode reads the user-editable Markdown template at `docs/repository-template.md`, created with `gh docs template --starlight --output docs`. Generated pages are replaced on later runs, but stale pages are not deleted automatically. ## `template` settings diff --git a/docs/content/output-formats/starlight-sidebar.md b/docs/content/output-formats/starlight-sidebar.md index 77327c9..42b1518 100644 --- a/docs/content/output-formats/starlight-sidebar.md +++ b/docs/content/output-formats/starlight-sidebar.md @@ -8,17 +8,16 @@ YAML, JSON, and HTML formats write one data or index file. `sidebar` mode writes ```sh gh docs index --owner mindovermachine-dev \ --file docs/sidebar/repositories.mjs \ - --pages-dir astro/src/content/docs/repositories \ - --template astro/repository-template.md + --pages-dir astro/src/content/docs/repositories ``` -If `--template` is omitted, the command uses the maintained Starlight starter template in `internal/index/templates/starlight.md`. To copy it for editing, without contacting GitHub, run: +Sidebar mode requires `docs/repository-template.md`; it does not use an embedded fallback. Create the standalone Markdown file from the maintained starter source without contacting GitHub: ```sh -gh docs index --starlight --template astro/repository-template.md +gh docs template --starlight --output docs ``` -The default destination is `repository-template.md` in the current directory. Scaffolding refuses to overwrite an existing file. Pass the edited file to a normal `sidebar` invocation. +The default output folder is `docs`, so this creates `docs/repository-template.md`. Edit that file and run the index command. For another output folder, pass `--output `; an existing template is never replaced unless `--force` is supplied. ## Template syntax and fields @@ -75,7 +74,7 @@ Latest commit: [% .LatestCommit.SHA %] โ€” [% .LatestCommit.Message %] [% end %] ``` -The complete maintained starter demonstrates every supported field, including optional commit, version, license, stars, and badge values. Template parse and execution errors identify the template and (for execution errors) the repository. Pages and sidebar content are rendered before output files are written. Metadata lookup failures are warnings; discovery, rendering, and required output write failures stop the command. +The complete maintained starter demonstrates every supported field, including optional commit, version, license, stars, and badge values. Near the top, it displays a documentation action link when `.HomepageURL` is set and always displays a GitHub repository action link. Template parse and execution errors identify the template and (for execution errors) the repository. Pages and sidebar content are rendered before output files are written. Metadata lookup failures are warnings; discovery, rendering, and required output write failures stop the command. ## Sidebar links and site setup diff --git a/internal/index/index.go b/internal/index/index.go index 82bad3e..9101961 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -25,7 +25,6 @@ type Config struct { Format string `yaml:"format"` File string `yaml:"file"` PagesDir string `yaml:"pages-dir"` - Template string `yaml:"template"` BaseURL string `yaml:"base-url"` BadgeURL string `yaml:"badge-url"` Limit int `yaml:"limit"` @@ -150,9 +149,6 @@ func Merge(base, overrides Config) Config { if overrides.PagesDir != "" { out.PagesDir = overrides.PagesDir } - if overrides.Template != "" { - out.Template = overrides.Template - } if overrides.BaseURL != "" { out.BaseURL = overrides.BaseURL } @@ -342,7 +338,7 @@ func Run(cfg Config) (string, error) { var pageTemplate *texttemplate.Template if cfg.Format == "sidebar" { var err error - pageTemplate, err = loadPageTemplate(cfg.Template) + pageTemplate, err = loadPageTemplate() if err != nil { return "", err } @@ -368,9 +364,9 @@ func Run(cfg Config) (string, error) { return path, nil } -// StarlightTemplate copies the maintained starter page template without overwriting files. -func StarlightTemplate(destination string) error { - return scaffoldStarlight(destination) +// StarlightTemplate copies the maintained starter page template to destination. +func StarlightTemplate(destination string, force bool) error { + return scaffoldStarlight(destination, force) } // PageURL returns the local Starlight route for a generated repository page. diff --git a/internal/index/index_test.go b/internal/index/index_test.go index 1b9a7f7..f59bd7e 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -77,7 +77,7 @@ func TestLoadConfig(t *testing.T) { wantErr bool }{ {"index node", "index:\n owner: acme\n topic: docs\n limit: 5\n", true, Config{Owner: "acme", Topic: "docs", Limit: 5}, false}, - {"page settings", "index:\n pages-dir: site/docs/repositories\n template: repository.md\n base-url: /repositories\n", true, Config{PagesDir: "site/docs/repositories", Template: "repository.md", BaseURL: "/repositories"}, false}, + {"page settings", "index:\n pages-dir: site/docs/repositories\n base-url: /repositories\n", true, Config{PagesDir: "site/docs/repositories", BaseURL: "/repositories"}, false}, {"other nodes ignored", "template:\n output: x\n", true, Config{}, false}, {"missing optional", "", false, Config{}, false}, {"missing required", "", true, Config{}, true}, @@ -159,6 +159,10 @@ func TestValidate(t *testing.T) { // Golden files live in testdata/golden; refresh them with `go test ./internal/index -update`. func TestRunGolden(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) + goldenDir, err := filepath.Abs(filepath.Join("testdata", "golden")) + if err != nil { + t.Fatal(err) + } for _, name := range []string{"index.yml", "index.yaml", "index.json", "index.html", "index.mjs"} { t.Run(name, func(t *testing.T) { @@ -167,6 +171,15 @@ func TestRunGolden(t *testing.T) { cfg := Merge(Config{}, Config{Owner: "o", File: out}) cfg.PagesDir = filepath.Join(dir, "pages") cfg.BaseURL = "/repo" + if name == "index.mjs" { + testutil.Chdir(t, dir) + if err := os.MkdirAll("docs", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "repository-template.md"), []byte("# [% .Name %]\n"), 0o644); err != nil { + t.Fatal(err) + } + } path, err := Run(cfg) if err != nil { t.Fatal(err) @@ -179,7 +192,7 @@ func TestRunGolden(t *testing.T) { t.Fatal(err) } - golden := filepath.Join("testdata", "golden", name) + golden := filepath.Join(goldenDir, name) if *update { testutil.WriteFile(t, "", golden, string(got)) } @@ -214,6 +227,9 @@ func TestRunSidebarFormatUsesPairedDefaults(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() testutil.Chdir(t, dir) + if err := StarlightTemplate(filepath.Join("docs", "repository-template.md"), false); err != nil { + t.Fatal(err) + } path, err := Run(Merge(Config{}, Config{Owner: "o", Format: "sidebar"})) if err != nil { t.Fatal(err) @@ -224,11 +240,34 @@ func TestRunSidebarFormatUsesPairedDefaults(t *testing.T) { if _, err := os.Stat(filepath.Join("docs", "content", "repo", "alpha.md")); err != nil { t.Fatal(err) } + alpha, err := os.ReadFile(filepath.Join("docs", "content", "repo", "alpha.md")) + if err != nil { + t.Fatal(err) + } + delta, err := os.ReadFile(filepath.Join("docs", "content", "repo", "delta.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(alpha), "[๐Ÿ“š **Documentation**]()") || + !strings.Contains(string(alpha), "[๐Ÿ’ป **GitHub repository**]()") { + t.Errorf("missing repository actions: %s", alpha) + } + if strings.Contains(string(delta), "**Documentation**") || + !strings.Contains(string(delta), "[๐Ÿ’ป **GitHub repository**]()") { + t.Errorf("unexpected actions without a homepage: %s", delta) + } } func TestRunSidebarCreatesPagesDirectoryWithNoMatches(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() + testutil.Chdir(t, dir) + if err := os.MkdirAll("docs", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("docs", "repository-template.md"), []byte("# Repositories\n"), 0o644); err != nil { + t.Fatal(err) + } pagesDir := filepath.Join(dir, "pages") cfg := Merge(Config{}, Config{ Owner: "o", Topic: "no-matches", Format: "sidebar", @@ -245,11 +284,12 @@ func TestRunSidebarCreatesPagesDirectoryWithNoMatches(t *testing.T) { func TestRunSidebarGeneratesPagesAndLocalSidebar(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() - templatePath := testutil.WriteFile(t, dir, "repo.md", "# [% .Owner %]/[% .Name %]\n[% yaml .Description %]\n") + testutil.Chdir(t, dir) + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "# [% .Owner %]/[% .Name %]\n[% yaml .Description %]\n") cfg := Merge(Config{}, Config{ Owner: "o", File: filepath.Join(dir, "sidebar.mjs"), - PagesDir: filepath.Join(dir, "pages"), Template: templatePath, - BaseURL: "/repositories", + PagesDir: filepath.Join(dir, "pages"), + BaseURL: "/repositories", }) if _, err := Run(cfg); err != nil { t.Fatal(err) @@ -274,7 +314,7 @@ func TestRunSidebarGeneratesPagesAndLocalSidebar(t *testing.T) { func TestStarlightTemplate(t *testing.T) { dir := t.TempDir() destination := filepath.Join(dir, "nested", "template.md") - if err := StarlightTemplate(destination); err != nil { + if err := StarlightTemplate(destination, false); err != nil { t.Fatal(err) } got, err := os.ReadFile(destination) @@ -288,21 +328,32 @@ func TestStarlightTemplate(t *testing.T) { if string(got) != string(want) { t.Fatal("scaffold differs from maintained template") } - if err := StarlightTemplate(destination); err == nil { + if err := StarlightTemplate(destination, false); err == nil { t.Fatal("expected existing destination error") } + afterRefusal, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(afterRefusal) != string(want) { + t.Fatal("existing template was changed without --force") + } + if err := StarlightTemplate(destination, true); err != nil { + t.Fatalf("force overwrite failed: %v", err) + } } func TestSidebarTemplateErrorsIdentifyRepository(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() - templatePath := testutil.WriteFile(t, dir, "bad.md", "[% .UnknownField %]") + testutil.Chdir(t, dir) + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "[% .UnknownField %]") cfg := Merge(Config{}, Config{ Owner: "o", File: filepath.Join(dir, "sidebar.mjs"), - PagesDir: filepath.Join(dir, "pages"), Template: templatePath, + PagesDir: filepath.Join(dir, "pages"), }) _, err := Run(cfg) - if err == nil || !strings.Contains(err.Error(), templatePath) || !strings.Contains(err.Error(), "repository alpha") { + if err == nil || !strings.Contains(err.Error(), filepath.Join("docs", "repository-template.md")) || !strings.Contains(err.Error(), "repository alpha") { t.Fatalf("error = %v", err) } if _, err := os.Stat(cfg.File); !os.IsNotExist(err) { @@ -324,7 +375,9 @@ func TestSlugAndPageURL(t *testing.T) { func TestSlugCollisionsFailBeforeWriting(t *testing.T) { dir := t.TempDir() - tmpl, err := loadPageTemplate("") + testutil.Chdir(t, dir) + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "# [% .Name %]\n") + tmpl, err := loadPageTemplate() if err != nil { t.Fatal(err) } @@ -351,10 +404,10 @@ func TestHighestStableVersion(t *testing.T) { } func TestMergePageOptions(t *testing.T) { - base := Config{PagesDir: "configured/pages", Template: "configured.md", BaseURL: "/configured", BadgeURL: "configured.svg"} - overrides := Config{PagesDir: "flag/pages", Template: "flag.md", BaseURL: "/flag", BadgeURL: "flag.svg"} + base := Config{PagesDir: "configured/pages", BaseURL: "/configured", BadgeURL: "configured.svg"} + overrides := Config{PagesDir: "flag/pages", BaseURL: "/flag", BadgeURL: "flag.svg"} got := Merge(base, overrides) - if got.PagesDir != "flag/pages" || got.Template != "flag.md" || got.BaseURL != "/flag" || got.BadgeURL != "flag.svg" { + if got.PagesDir != "flag/pages" || got.BaseURL != "/flag" || got.BadgeURL != "flag.svg" { t.Errorf("flag options did not override config: %+v", got) } got = Merge(Config{}, Config{}) diff --git a/internal/index/pages.go b/internal/index/pages.go index 52661b3..62e9df4 100644 --- a/internal/index/pages.go +++ b/internal/index/pages.go @@ -26,6 +26,8 @@ var starterFS embed.FS var safeName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) var stableVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) +const defaultPageTemplate = "docs/repository-template.md" + func runGH(args ...string) ([]byte, error) { return exec.Command("gh", args...).Output() } @@ -67,43 +69,30 @@ func pageURL(pagesDir, baseURL, repoSlug string) string { return "/" + base + "/" + repoSlug + "/" } -func loadPageTemplate(filename string) (*template.Template, error) { - var source []byte - var err error - if filename == "" { - source, err = starterFS.ReadFile("templates/starlight.md") - } else { - source, err = os.ReadFile(filename) - } +func loadPageTemplate() (*template.Template, error) { + source, err := os.ReadFile(defaultPageTemplate) if err != nil { - if filename == "" { - return nil, fmt.Errorf("failed to read embedded Starlight template: %w", err) + if os.IsNotExist(err) { + return nil, fmt.Errorf("template %s is missing; create it with `gh docs template --starlight --output docs`", defaultPageTemplate) } - return nil, fmt.Errorf("failed to read template %s: %w", filename, err) - } - name := filename - if name == "" { - name = "built-in Starlight template" + return nil, fmt.Errorf("failed to read template %s: %w", defaultPageTemplate, err) } - tmpl, err := template.New(name).Delims("[%", "%]").Funcs(template.FuncMap{ + tmpl, err := template.New(defaultPageTemplate).Delims("[%", "%]").Funcs(template.FuncMap{ "yaml": func(value any) (string, error) { data, err := yaml.Marshal(value) return strings.TrimSuffix(string(data), "\n"), err }, }).Parse(string(source)) if err != nil { - return nil, fmt.Errorf("failed to parse template %s: %w", name, err) + return nil, fmt.Errorf("failed to parse template %s: %w", defaultPageTemplate, err) } return tmpl, nil } -func scaffoldStarlight(destination string) error { - if destination == "" { - destination = "repository-template.md" - } - if _, err := os.Lstat(destination); err == nil { - return fmt.Errorf("template destination %s already exists", destination) - } else if !os.IsNotExist(err) { +func scaffoldStarlight(destination string, force bool) error { + if _, err := os.Lstat(destination); err == nil && !force { + return fmt.Errorf("template destination %s already exists; use --force to overwrite", destination) + } else if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to check template destination %s: %w", destination, err) } source, err := starterFS.ReadFile("templates/starlight.md") @@ -113,6 +102,12 @@ func scaffoldStarlight(destination string) error { if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { return fmt.Errorf("failed to create directory for %s: %w", destination, err) } + if force { + if err := os.WriteFile(destination, source, 0o644); err != nil { + return fmt.Errorf("failed to write template %s: %w", destination, err) + } + return nil + } file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return fmt.Errorf("failed to write template %s: %w", destination, err) @@ -164,11 +159,7 @@ func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (st repo.SidebarURL = pageURL(cfg.PagesDir, cfg.BaseURL, repo.Slug) var rendered bytes.Buffer if err := tmpl.Execute(&rendered, *repo); err != nil { - name := cfg.Template - if name == "" { - name = "built-in Starlight template" - } - return "", fmt.Errorf("failed to render template %s for repository %s: %w", name, repo.Name, err) + return "", fmt.Errorf("failed to render template %s for repository %s: %w", defaultPageTemplate, repo.Name, err) } pages = append(pages, page{path: target, data: rendered.Bytes()}) } diff --git a/internal/index/templates/starlight.md b/internal/index/templates/starlight.md index 315930e..605d192 100644 --- a/internal/index/templates/starlight.md +++ b/internal/index/templates/starlight.md @@ -3,6 +3,13 @@ title: [% .Title | yaml %] description: [% .Description | yaml %] --- +[% if .HomepageURL %] + +> [๐Ÿ“š **Documentation**](<[% .HomepageURL %]>) ยท [๐Ÿ’ป **GitHub repository**](<[% .RepoURL %]>) +> [% else %] +> [๐Ÿ’ป **GitHub repository**](<[% .RepoURL %]>) +> [% end %] + [% if .BadgeURL %] Build status [% end %] From db544391006e2d3e9bf09900f68c2ca52438fe3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:01:52 +0000 Subject: [PATCH 5/7] Handle template output and satisfy new lint checks --- cmd/template.go | 4 +++- internal/index/index_test.go | 15 +++------------ internal/index/pages.go | 1 + 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/cmd/template.go b/cmd/template.go index 481cf01..184a9a4 100644 --- a/cmd/template.go +++ b/cmd/template.go @@ -43,7 +43,9 @@ Config keys, under a "template" node: output.`, if err := index.StarlightTemplate(destination, force); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination) + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination); err != nil { + return err + } return nil } paths, err := template.Jekyll(merged, force) diff --git a/internal/index/index_test.go b/internal/index/index_test.go index f59bd7e..d635e3a 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -173,12 +173,7 @@ func TestRunGolden(t *testing.T) { cfg.BaseURL = "/repo" if name == "index.mjs" { testutil.Chdir(t, dir) - if err := os.MkdirAll("docs", 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join("docs", "repository-template.md"), []byte("# [% .Name %]\n"), 0o644); err != nil { - t.Fatal(err) - } + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "# [% .Name %]\n") } path, err := Run(cfg) if err != nil { @@ -262,12 +257,7 @@ func TestRunSidebarCreatesPagesDirectoryWithNoMatches(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() testutil.Chdir(t, dir) - if err := os.MkdirAll("docs", 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join("docs", "repository-template.md"), []byte("# Repositories\n"), 0o644); err != nil { - t.Fatal(err) - } + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "# Repositories\n") pagesDir := filepath.Join(dir, "pages") cfg := Merge(Config{}, Config{ Owner: "o", Topic: "no-matches", Format: "sidebar", @@ -331,6 +321,7 @@ func TestStarlightTemplate(t *testing.T) { if err := StarlightTemplate(destination, false); err == nil { t.Fatal("expected existing destination error") } + // #nosec G304 -- destination is inside the temporary test directory. afterRefusal, err := os.ReadFile(destination) if err != nil { t.Fatal(err) diff --git a/internal/index/pages.go b/internal/index/pages.go index 62e9df4..9ed35c9 100644 --- a/internal/index/pages.go +++ b/internal/index/pages.go @@ -103,6 +103,7 @@ func scaffoldStarlight(destination string, force bool) error { return fmt.Errorf("failed to create directory for %s: %w", destination, err) } if force { + // #nosec G306 -- Generated Markdown must be readable and non-executable. if err := os.WriteFile(destination, source, 0o644); err != nil { return fmt.Errorf("failed to write template %s: %w", destination, err) } From 79d4b671f2a0bea462912ecb5600b793819208fd Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Thu, 5 Nov 2026 11:27:04 +0000 Subject: [PATCH 6/7] Related to #19 --- .docs.yml | 5 +- cmd/index.go | 6 +- docs/content/commands/index.md | 8 +- docs/content/configuration.md | 4 +- .../output-formats/starlight-sidebar.md | 6 +- docs/repository-template.mdx | 78 +++++++++++++++++++ internal/index/index.go | 3 +- internal/index/index_test.go | 20 +++++ internal/index/pages.go | 42 ++++++---- 9 files changed, 144 insertions(+), 28 deletions(-) create mode 100644 docs/repository-template.mdx diff --git a/.docs.yml b/.docs.yml index 964e28c..2208cd0 100644 --- a/.docs.yml +++ b/.docs.yml @@ -1,4 +1,7 @@ index: owner: mindovermachine-dev topic: documentation - file: docz/docs-repos.html + visibility: public + format: sidebar + template: + diff --git a/cmd/index.go b/cmd/index.go index b19decf..13c3fdf 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -18,7 +18,7 @@ func newIndexCmd() *cobra.Command { Short: "Generate an index of documentation repositories", Long: `List repositories of a user/organization that carry a given topic and write their name, description and homepage URL to a YAML, JSON or HTML file. -The sidebar format writes repository pages from a Markdown template and a paired +The sidebar format writes repository pages from a Markdown or MDX template and a paired Starlight sidebar module. With --file the extension decides the format; --file and --format are mutually @@ -26,7 +26,7 @@ exclusive. Missing parent directories are created. Precedence: flags > config file (.docs.yml) > built-in fallbacks. Config keys, under an "index" node: owner, visibility, topic, format, file, pages-dir, base-url, badge-url, limit. -Sidebar mode requires the user-authored docs/repository-template.md file; see "gh docs template".`, +Sidebar mode requires docs/repository-template.md or docs/repository-template.mdx; .md takes precedence when both exist. See "gh docs template".`, RunE: func(cmd *cobra.Command, _ []string) error { cfg, err := index.LoadConfig(configFile, cmd.Flags().Changed("config")) if err != nil { @@ -52,7 +52,7 @@ Sidebar mode requires the user-authored docs/repository-template.md file; see "g f.StringVarP(&flags.Topic, "topic", "t", "", "Repository topic to filter on") f.StringVarP(&flags.Format, "format", "F", "", "Output format: yaml, json, html or sidebar (not valid with --file)") f.StringVarP(&flags.File, "file", "f", "", "Output file path (.yml, .yaml, .json, .html or .mjs); extension sets the format") - f.StringVar(&flags.PagesDir, "pages-dir", "", "Directory for generated Markdown repository pages (sidebar format)") + f.StringVar(&flags.PagesDir, "pages-dir", "", "Directory for generated Markdown or MDX repository pages (sidebar format)") f.StringVar(&flags.BaseURL, "base-url", "", "Base URL for generated local sidebar links") f.StringVar(&flags.BadgeURL, "badge-url", "", "Optional status badge URL for repository page templates") f.IntVarP(&flags.Limit, "limit", "l", 0, "Maximum number of repositories to fetch") diff --git a/docs/content/commands/index.md b/docs/content/commands/index.md index ac7135a..41f808c 100644 --- a/docs/content/commands/index.md +++ b/docs/content/commands/index.md @@ -3,7 +3,7 @@ title: index description: Export repository data or generate repository pages and a Starlight sidebar. --- -**`gh docs index` lists repositories owned by a GitHub user or organization and filters them by repository topic. YAML, JSON, and HTML produce a single-file export; sidebar mode produces one Markdown page per repository and a Starlight sidebar module.** +**`gh docs index` lists repositories owned by a GitHub user or organization and filters them by repository topic. YAML, JSON, and HTML produce a single-file export; sidebar mode produces one Markdown or MDX page per repository and a Starlight sidebar module.** ## Usage @@ -23,12 +23,12 @@ The owner can be set in `.docs.yml` instead of passed as a flag. The command inv | `-t`, `--topic` | `documentation` | Repository topic to match. Matching is case-insensitive. | | `-F`, `--format` | `yaml` | Output format: `yaml`, `json`, `html`, or `sidebar`. Cannot be used with `--file`. | | `-f`, `--file` | `docs-repos.yml` | Output path. `.mjs` selects paired sidebar/page generation. | -| `--pages-dir` | `docs/content/repo` | Directory for generated Markdown pages in sidebar mode. | +| `--pages-dir` | `docs/content/repo` | Directory for generated Markdown or MDX pages in sidebar mode. | | `--base-url` | Inferred | URL prefix for local sidebar links when the filesystem layout does not determine the site route. | | `--badge-url` | None | Optional status badge URL exposed to the page template. | | `-l`, `--limit` | `1000` | Maximum number of repositories returned by `gh repo list`. | -Supported output extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. YAML, JSON, and HTML are single-file exports. The `.mjs` extension selects the paired sidebar/pages format. +Supported index output extensions are `.yml`, `.yaml`, `.json`, `.html`, and `.mjs`. YAML, JSON, and HTML are single-file exports. The `.mjs` extension selects the paired sidebar/pages format. Page templates are read from `docs/repository-template.md` or `docs/repository-template.mdx`; `.md` is preferred if both exist, and generated pages use the selected template's extension. ## formats @@ -97,7 +97,7 @@ gh docs index --owner mindovermachine-dev --file docs/sidebar/repositories.mjs \ The generated module exports one `Documentation` group with `{ label, link }` items. Links point to the pages generated in the same invocation, not to GitHub or repository homepage URLs. For the default `docs/content/repo` directory the inferred route is `/repo//`; for a path under `src/content/docs`, the route is inferred relative to `docs`. Use `--base-url` when you need to specify a route explicitly. Missing directories are created and generated pages are overwritten. Pages for repositories no longer selected are not deleted and must be cleaned up manually. -Sidebar mode reads `docs/repository-template.md`. Create it with `gh docs template --starlight --output docs`; edit the copied file, then run `gh docs index --format sidebar` again. +Sidebar mode reads `docs/repository-template.md` or `docs/repository-template.mdx`. If both exist, it uses `.md`. Generated pages keep the selected template's extension. Create the default `.md` template with `gh docs template --starlight --output docs`; edit or rename it to `.mdx` as needed, then run `gh docs index --format sidebar` again. See [Starlight sidebar and page templates](../output-formats/starlight-sidebar.md) for template keywords, delimiters, configuration, and site imports. diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 2477134..8af2c72 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -32,14 +32,14 @@ version: | `topic` | `documentation` | Repository topic used to select repositories. | | `format` | `yaml` | `yaml`, `json`, and `html` are single-file exports; `sidebar` generates pages and a sidebar module. | | `file` | Derived from `format` | Output path. `.mjs` selects paired sidebar/page generation. | -| `pages-dir` | `docs/content/repo` | Markdown page output directory (sidebar mode). | +| `pages-dir` | `docs/content/repo` | Markdown or MDX page output directory (sidebar mode). | | `base-url` | Inferred | URL prefix for local sidebar links when not inferable from the content path. | | `badge-url` | None | Optional status badge URL exposed to templates. | | `limit` | `1000` | Maximum repositories fetched from GitHub. | Do not set both `index.file` and `index.format`; the configuration is rejected. Likewise, `--file` and `--format` cannot be passed together. When a flag supplies one of these settings, it overrides the config value for the other and selects the output accordingly. -Flags override `.docs.yml`, which overrides built-in defaults. Relative output paths are resolved from the current working directory. `pages-dir`, `base-url`, and `badge-url` apply to sidebar mode; they are ignored for YAML, JSON, and HTML exports. Sidebar mode reads the user-editable Markdown template at `docs/repository-template.md`, created with `gh docs template --starlight --output docs`. Generated pages are replaced on later runs, but stale pages are not deleted automatically. +Flags override `.docs.yml`, which overrides built-in defaults. Relative output paths are resolved from the current working directory. `pages-dir`, `base-url`, and `badge-url` apply to sidebar mode; they are ignored for YAML, JSON, and HTML exports. Sidebar mode reads `docs/repository-template.md` or `docs/repository-template.mdx`, preferring `.md` if both exist. Generated pages use the selected template's extension. Create the starter Markdown template with `gh docs template --starlight --output docs`. Generated pages are replaced on later runs, but stale pages are not deleted automatically. ## `template` settings diff --git a/docs/content/output-formats/starlight-sidebar.md b/docs/content/output-formats/starlight-sidebar.md index 42b1518..dd99723 100644 --- a/docs/content/output-formats/starlight-sidebar.md +++ b/docs/content/output-formats/starlight-sidebar.md @@ -1,9 +1,9 @@ --- title: Starlight sidebar and page templates -description: Generate repository pages from Markdown templates and link them in a Starlight sidebar. +description: Generate repository pages from Markdown or MDX templates and link them in a Starlight sidebar. --- -YAML, JSON, and HTML formats write one data or index file. `sidebar` mode writes a Markdown page for every matching repository plus an ES module containing the Starlight sidebar entries. Use `--format sidebar` or a `.mjs` output path: +YAML, JSON, and HTML formats write one data or index file. `sidebar` mode writes a Markdown or MDX page for every matching repository plus an ES module containing the Starlight sidebar entries. Use `--format sidebar` or a `.mjs` output path: ```sh gh docs index --owner mindovermachine-dev \ @@ -11,7 +11,7 @@ gh docs index --owner mindovermachine-dev \ --pages-dir astro/src/content/docs/repositories ``` -Sidebar mode requires `docs/repository-template.md`; it does not use an embedded fallback. Create the standalone Markdown file from the maintained starter source without contacting GitHub: +Sidebar mode requires `docs/repository-template.md` or `docs/repository-template.mdx`; it does not use an embedded fallback. If both exist, `.md` is preferred. Generated pages use the same extension as the selected template. Create the standalone Markdown starter from the maintained source without contacting GitHub: ```sh gh docs template --starlight --output docs diff --git a/docs/repository-template.mdx b/docs/repository-template.mdx new file mode 100644 index 0000000..d486c8d --- /dev/null +++ b/docs/repository-template.mdx @@ -0,0 +1,78 @@ +--- +title: [% .Title | yaml %] +description: [% .Description | yaml %] +tableOfContents: false +hero: + tagline: Index of reference documentation for the products stewarded by Mind over Machine. + actions: + - text: Documentation + link: [% .HomepageURL %] + icon: right-arrow + - text: Github repository + link: [% .RepoURL %] + icon: right-arrow + variant: minimal +--- + +[% if .HomepageURL %] + +> [๐Ÿ“š **Documentation**](<[% .HomepageURL %]>) ยท [๐Ÿ’ป **GitHub repository**](<[% .RepoURL %]>) +> [% else %] +> [๐Ÿ’ป **GitHub repository**](<[% .RepoURL %]>) +> [% end %] + +[% if .BadgeURL %] + +Build status +[% end %] + +## [% .Title %] + +[% .Description %] + +## Repository + +- Owner: [% .Owner %] +- Name: [% .Name %] +- Page slug: `[% .Slug %]` +- Source: <[% .RepoURL %]> + [% if .HomepageURL %] +- Documentation: [% .HomepageURL %] + [% end %] + [% if .DefaultBranch %]- Default branch: `[% .DefaultBranch %]`[% end %] +- Stars: [% .Stars %] + [% if .License %] +- License: [% .License %] + [% end %] +- Last updated: [% .UpdatedAt %] + +## Topics + +[% if .Topics %] +[% range .Topics %]- [% . %] +[% end %] +[% else %]No topics listed. +[% end %] + +## Activity + +[% if .LatestVersion %] + +- Latest stable version: [% .LatestVersion %] + +[% end %] +[% if .LatestCommit.SHA %] + +- Latest commit: [% .LatestCommit.SHA %] + +[% end %] +[% if .LatestCommit.Date %] + +- Commit date: [% .LatestCommit.Date %] + +[% end %] +[% if .LatestCommit.Message %] + +- Commit message: [% .LatestCommit.Message %] + +[% end %] diff --git a/internal/index/index.go b/internal/index/index.go index 9101961..e36c2bd 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -11,7 +11,6 @@ import ( "path/filepath" "sort" "strings" - texttemplate "text/template" "github.com/mindovermachine-dev/gh-docs/internal/config" "gopkg.in/yaml.v3" @@ -335,7 +334,7 @@ func Run(cfg Config) (string, error) { if err := cfg.Validate(); err != nil { return "", err } - var pageTemplate *texttemplate.Template + var pageTemplate pageTemplate if cfg.Format == "sidebar" { var err error pageTemplate, err = loadPageTemplate() diff --git a/internal/index/index_test.go b/internal/index/index_test.go index d635e3a..2f44683 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -253,6 +253,26 @@ func TestRunSidebarFormatUsesPairedDefaults(t *testing.T) { } } +func TestRunSidebarPageExtensionMatchesTemplate(t *testing.T) { + for _, extension := range []string{".md", ".mdx"} { + t.Run(extension, func(t *testing.T) { + testutil.FakeGH(t, testutil.ReposFixture(), 0) + dir := t.TempDir() + testutil.Chdir(t, dir) + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template"+extension), "# [% .Name %]\n") + + cfg := Merge(Config{}, Config{Owner: "o", Format: "sidebar"}) + if _, err := Run(cfg); err != nil { + t.Fatal(err) + } + pagePath := filepath.Join(cfg.PagesDir, "alpha"+extension) + if _, err := os.Stat(pagePath); err != nil { + t.Fatalf("generated page %s: %v", pagePath, err) + } + }) + } +} + func TestRunSidebarCreatesPagesDirectoryWithNoMatches(t *testing.T) { testutil.FakeGH(t, testutil.ReposFixture(), 0) dir := t.TempDir() diff --git a/internal/index/pages.go b/internal/index/pages.go index 9ed35c9..a5c0049 100644 --- a/internal/index/pages.go +++ b/internal/index/pages.go @@ -27,6 +27,13 @@ var safeName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) var stableVersion = regexp.MustCompile(`^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`) const defaultPageTemplate = "docs/repository-template.md" +const defaultMDXPageTemplate = "docs/repository-template.mdx" + +type pageTemplate struct { + template *template.Template + path string + extension string +} func runGH(args ...string) ([]byte, error) { return exec.Command("gh", args...).Output() @@ -69,24 +76,33 @@ func pageURL(pagesDir, baseURL, repoSlug string) string { return "/" + base + "/" + repoSlug + "/" } -func loadPageTemplate() (*template.Template, error) { - source, err := os.ReadFile(defaultPageTemplate) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("template %s is missing; create it with `gh docs template --starlight --output docs`", defaultPageTemplate) +func loadPageTemplate() (pageTemplate, error) { + templatePath := "" + for _, candidate := range []string{defaultPageTemplate, defaultMDXPageTemplate} { + if _, err := os.Stat(candidate); err == nil { + templatePath = candidate + break + } else if !os.IsNotExist(err) { + return pageTemplate{}, fmt.Errorf("failed to inspect template %s: %w", candidate, err) } - return nil, fmt.Errorf("failed to read template %s: %w", defaultPageTemplate, err) } - tmpl, err := template.New(defaultPageTemplate).Delims("[%", "%]").Funcs(template.FuncMap{ + if templatePath == "" { + return pageTemplate{}, fmt.Errorf("template %s or %s is missing; create one with `gh docs template --starlight --output docs`", defaultPageTemplate, defaultMDXPageTemplate) + } + source, err := os.ReadFile(templatePath) + if err != nil { + return pageTemplate{}, fmt.Errorf("failed to read template %s: %w", templatePath, err) + } + tmpl, err := template.New(templatePath).Delims("[%", "%]").Funcs(template.FuncMap{ "yaml": func(value any) (string, error) { data, err := yaml.Marshal(value) return strings.TrimSuffix(string(data), "\n"), err }, }).Parse(string(source)) if err != nil { - return nil, fmt.Errorf("failed to parse template %s: %w", defaultPageTemplate, err) + return pageTemplate{}, fmt.Errorf("failed to parse template %s: %w", templatePath, err) } - return tmpl, nil + return pageTemplate{template: tmpl, path: templatePath, extension: filepath.Ext(templatePath)}, nil } func scaffoldStarlight(destination string, force bool) error { @@ -125,7 +141,7 @@ func scaffoldStarlight(destination string, force bool) error { return nil } -func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (string, error) { +func writePagesAndSidebar(cfg Config, repos []Repo, tmpl pageTemplate) (string, error) { var err error pagesDir, err := filepath.Abs(cfg.PagesDir) if err != nil { @@ -147,7 +163,7 @@ func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (st return "", fmt.Errorf("repositories %q and %q produce the same page slug %q", previous, repo.Name, repo.Slug) } seen[repo.Slug] = repo.Name - target := filepath.Join(pagesDir, repo.Slug+".md") + target := filepath.Join(pagesDir, repo.Slug+tmpl.extension) relative, err := filepath.Rel(pagesDir, target) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return "", fmt.Errorf("page path for repository %q escapes pages directory", repo.Name) @@ -159,8 +175,8 @@ func writePagesAndSidebar(cfg Config, repos []Repo, tmpl *template.Template) (st } repo.SidebarURL = pageURL(cfg.PagesDir, cfg.BaseURL, repo.Slug) var rendered bytes.Buffer - if err := tmpl.Execute(&rendered, *repo); err != nil { - return "", fmt.Errorf("failed to render template %s for repository %s: %w", defaultPageTemplate, repo.Name, err) + if err := tmpl.template.Execute(&rendered, *repo); err != nil { + return "", fmt.Errorf("failed to render template %s for repository %s: %w", tmpl.path, repo.Name, err) } pages = append(pages, page{path: target, data: rendered.Bytes()}) } From 08c259ce5c1ac8b4bd3959101302c2115d27d84c Mon Sep 17 00:00:00 2001 From: Lars Kruse Date: Thu, 5 Nov 2026 11:37:42 +0000 Subject: [PATCH 7/7] Linting #19 --- .docs.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.docs.yml b/.docs.yml index 2208cd0..6249963 100644 --- a/.docs.yml +++ b/.docs.yml @@ -3,5 +3,3 @@ index: topic: documentation visibility: public format: sidebar - template: -