diff --git a/.docs.yml b/.docs.yml index 964e28c..6249963 100644 --- a/.docs.yml +++ b/.docs.yml @@ -1,4 +1,5 @@ index: owner: mindovermachine-dev topic: documentation - file: docz/docs-repos.html + visibility: public + format: sidebar diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index a9603e5..a270711 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -72,6 +72,55 @@ func TestIndexCommand(t *testing.T) { }) } +func TestTemplateStarlightScaffold(t *testing.T) { + dir := t.TempDir() + testutil.Chdir(t, dir) + t.Setenv("PATH", t.TempDir()) + stdout, err := run(t, "template", "--starlight") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, filepath.Join("docs", "repository-template.md")) { + t.Errorf("stdout = %q", stdout) + } + if _, err := os.Stat(filepath.Join("docs", "repository-template.md")); err != nil { + t.Fatal(err) + } + 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) { 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..13c3fdf 100644 --- a/cmd/index.go +++ b/cmd/index.go @@ -17,23 +17,30 @@ func newIndexCmd() *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 or MDX 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. 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, base-url, badge-url, limit. +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 { return err } - path, err := index.Run(index.Merge(cfg, flags)) + merged := index.Merge(cfg, flags) + 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,6 +52,9 @@ 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 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") c.MarkFlagsMutuallyExclusive("file", "format") return c diff --git a/cmd/template.go b/cmd/template.go index 2fb010f..184a9a4 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,40 @@ 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 + } + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Wrote %s\n", destination); err != nil { + return err + } + return nil + } + paths, err := template.Jekyll(merged, force) if err != nil { return err } @@ -45,8 +61,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 03b14e2..41f808c 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 or MDX page per repository and a Starlight sidebar module.** ## Usage @@ -15,17 +15,20 @@ 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 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 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 @@ -83,49 +86,20 @@ 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 ``` -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/" }], - }, -]; -``` +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. -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 +115,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/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 2ee76da..8af2c72 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -10,7 +10,10 @@ index: owner: mindovermachine-dev visibility: public topic: documentation - format: yaml + format: sidebar + file: docs/sidebar/repositories.mjs + pages-dir: astro/src/content/docs/repositories + base-url: /repositories limit: 1000 template: @@ -22,17 +25,22 @@ 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 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 `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 | Key | Default | Description | diff --git a/docs/content/output-formats/starlight-sidebar.md b/docs/content/output-formats/starlight-sidebar.md index f7203a4..dd99723 100644 --- a/docs/content/output-formats/starlight-sidebar.md +++ b/docs/content/output-formats/starlight-sidebar.md @@ -1,35 +1,105 @@ --- -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 or MDX 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 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 --file docs/docs-repos.mjs +gh docs index --owner mindovermachine-dev \ + --file docs/sidebar/repositories.mjs \ + --pages-dir astro/src/content/docs/repositories ``` -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//`. +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: -```js -export const sidebar = [ - { - label: "Documentation", - items: [{ label: "remote-astro-theme", link: "https://docs.example.org/remote-astro-theme/" }], - }, -]; +```sh +gh docs template --starlight --output docs +``` + +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 + +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. 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 + +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/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 d2422a0..e36c2bd 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -6,10 +6,10 @@ import ( "encoding/json" "errors" "fmt" - "html/template" + htmltemplate "html/template" "os" - "os/exec" "path/filepath" + "sort" "strings" "github.com/mindovermachine-dev/gh-docs/internal/config" @@ -23,21 +23,51 @@ type Config struct { Topic string `yaml:"topic"` Format string `yaml:"format"` File string `yaml:"file"` + PagesDir string `yaml:"pages-dir"` + 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 +79,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 +145,21 @@ func Merge(base, overrides Config) Config { if overrides.Limit != 0 { out.Limit = overrides.Limit } + if overrides.PagesDir != "" { + out.PagesDir = overrides.PagesDir + } + 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 +198,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 +215,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 +227,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) } - result = append(result, Repo{Name: r.Name, Description: r.Description, HomepageURL: r.HomepageURL, SidebarURL: link}) + 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) 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 +284,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 +334,21 @@ func Run(cfg Config) (string, error) { if err := cfg.Validate(); err != nil { return "", err } + var pageTemplate pageTemplate + if cfg.Format == "sidebar" { + var err error + pageTemplate, err = loadPageTemplate() + 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 +362,13 @@ func Run(cfg Config) (string, error) { } return path, nil } + +// 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. +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..2f44683 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 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}, @@ -157,11 +159,22 @@ 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) { - 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" + if name == "index.mjs" { + testutil.Chdir(t, dir) + testutil.WriteFile(t, dir, filepath.Join("docs", "repository-template.md"), "# [% .Name %]\n") + } path, err := Run(cfg) if err != nil { t.Fatal(err) @@ -174,7 +187,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)) } @@ -205,6 +218,253 @@ func TestRunDefaultsToFormatExtension(t *testing.T) { } } +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) + } + 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) + } + 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 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() + testutil.Chdir(t, dir) + 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", + 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() + 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"), + 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, false); 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, 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) + } + 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() + 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"), + }) + _, err := Run(cfg) + 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) { + 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() + 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) + } + 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", 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.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..a5c0049 --- /dev/null +++ b/internal/index/pages.go @@ -0,0 +1,312 @@ +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-]+)*)?$`) + +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() +} + +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() (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) + } + } + 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 pageTemplate{}, fmt.Errorf("failed to parse template %s: %w", templatePath, err) + } + return pageTemplate{template: tmpl, path: templatePath, extension: filepath.Ext(templatePath)}, nil +} + +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") + 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) + } + 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) + } + 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) + } + 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 pageTemplate) (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+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) + } + 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.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()}) + } + sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name }) + sidebar, err := renderSidebar(repos) + 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) + } + } + 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/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 %] 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/" }, ], }, ];