Skip to content

generate community extensions index from catalog#2564

Draft
DyanGalih wants to merge 6 commits into
github:mainfrom
DyanGalih:003-generate-community-docs
Draft

generate community extensions index from catalog#2564
DyanGalih wants to merge 6 commits into
github:mainfrom
DyanGalih:003-generate-community-docs

Conversation

@DyanGalih
Copy link
Copy Markdown
Contributor

What changed

  • Added a generator for docs/community/extensions.md backed by extensions/catalog.community.json.
  • Replaced the hand-maintained community extensions table with a generated index block.
  • Added a regression test that checks the committed page stays in sync with the generator and that --check mode catches drift.

Why

  • The community extensions page was a large manual table and was easy to drift from the catalog source of truth.
  • Moving the page to generated output keeps the community index aligned with the catalog as entries change.
  • This reduces maintainer overhead and makes the published list more trustworthy for contributors and users.

Impact

  • Contributors now update the catalog JSON instead of editing the rendered table by hand.
  • The community extensions page is more consistent and less likely to go stale.
  • CI and local tests can detect drift before it lands.

Validation

  • python scripts/generate_community_extensions_index.py --check
  • pytest tests/test_community_catalog_docs.py -q

Copilot AI review requested due to automatic review settings May 14, 2026 16:43
@DyanGalih DyanGalih changed the title [codex] generate community extensions index from catalog generate community extensions index from catalog May 14, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR introduces an automated generator for the community extensions reference documentation. The generator reads extensions/catalog.community.json and renders a Markdown table into docs/community/extensions.md between sentinel markers, with a CLI supporting --write and --check modes plus tests verifying drift detection.

Changes:

  • Adds a community_catalog_docs helper module with catalog loading, row iteration, and table/markdown rendering.
  • Adds a CLI script (scripts/generate_community_extensions_index.py) supporting print/--write/--check modes.
  • Regenerates docs/community/extensions.md from the catalog, replacing the manually curated table with generated content between sentinel markers; adds tests for sync, sort, and CLI check mode.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 9 comments.

File Description
src/specify_cli/community_catalog_docs.py Core helpers to load catalog, iterate rows, and render the generated table/index markdown.
scripts/generate_community_extensions_index.py CLI entry point wrapping the helpers with write/check/print modes.
docs/community/extensions.md Replaces the curated table with a generator-driven section delimited by markers; removes Categories/Effect legends.
tests/test_community_catalog_docs.py Adds tests for sync, alphabetical ordering, and CLI --check behavior.
Comments suppressed due to low confidence (1)

scripts/generate_community_extensions_index.py:1

  • --check and --write are not declared mutually exclusive, and if both are passed --check silently wins (write is skipped). Use parser.add_mutually_exclusive_group() or validate explicitly to make the contract clear.
#!/usr/bin/env python3

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +18 to +26
def load_community_catalog(path: Path = COMMUNITY_CATALOG_PATH) -> dict[str, Any]:
"""Load and validate the community catalog JSON file."""
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Expected {path} to contain a JSON object")
extensions = data.get("extensions")
if not isinstance(extensions, dict):
raise ValueError(f"Expected {path} to contain an 'extensions' object")
return data
Comment thread docs/community/extensions.md Outdated
@@ -1,124 +1,114 @@
# Community Extensions

> [!NOTE]
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
> Community extensions are independently created and maintained by their respective authors. Maintainers only verify that catalog entries are complete and correctly formatted - they do **not review, audit, endorse, or support the extension code itself**. The Community Extensions website is also a third-party resource. Review extension source code before installation and use at your own discretion.
Comment thread docs/community/extensions.md Outdated

🔍 **Browse and search community extensions on the [Community Extensions website](https://speckit-community.github.io/extensions/).**

The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):

**Categories:**
<!-- BEGIN GENERATED COMMUNITY EXTENSIONS TABLE -->
Comment on lines +87 to +90
widths = [
max(len(header), *(len(_render_cell(row[index])) for row in table_rows))
for index, header in enumerate(("Extension", "ID", "Description", "Tags", "Verified"))
]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _render_cell now normalizes \r\n and \r to spaces (in addition to \n) and escapes literal | as \| to prevent broken markdown table cells. This was already included in the latest push (b47ad1a).

Comment on lines +116 to +119
if start == -1 or end == -1 or end < start:
raise ValueError(f"Could not find generated table markers in {doc_path}")

start_end = start + len(GENERATED_START_MARKER)
Comment on lines +53 to +57
if args.check:
current = args.doc.read_text(encoding="utf-8")
if current != generated:
return 1
return 0
Comment thread tests/test_community_catalog_docs.py Outdated
Comment on lines +32 to +37
def test_community_extensions_generator_check_mode() -> None:
"""The CLI check mode should fail when the committed index drifts."""
result = subprocess.run(
[
sys.executable,
"scripts/generate_community_extensions_index.py",
Comment on lines +33 to +35
def _format_tags(tags: Any) -> str:
if not isinstance(tags, list) or not tags:
return "—"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. list_community_extensions() now returns the raw tags list (data-pure — "tags": ext.get("tags") or []). The _format_tags() call has been moved to render_community_extensions_table() where it belongs in the render layer. This keeps the iteration/sort code free of display concerns and simplifies testing.

Comment on lines +10 to +12
ROOT_DIR = Path(__file__).resolve().parents[2]
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"
COMMUNITY_INDEX_PATH = ROOT_DIR / "docs" / "community" / "extensions.md"
Copilot AI review requested due to automatic review settings May 14, 2026 22:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.

Comment on lines +3934 to 3941
if markdown:
from .community_catalog_docs import render_community_extensions_table
typer.echo(render_community_extensions_table())
return

from .extensions import ExtensionCatalog, ExtensionError

project_root = _require_specify_project()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). Added a warning when --markdown is combined with any filter options (query, --tag, --author, --verified): the user now sees Warning: --markdown outputs the full community catalog and ignores filters. The --markdown path short-circuits before _require_specify_project() intentionally (it renders the canonical community catalog for doc generation, not the project's installed catalog), so filters are irrelevant — but we now make that explicit rather than silently discarding them.

Comment on lines +10 to +11
ROOT_DIR = Path(__file__).resolve().parents[2]
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). list_community_extensions() now checks COMMUNITY_CATALOG_PATH.exists() before reading and raises a FileNotFoundError with an actionable message: "Community catalog not found: <path>. The --markdown flag requires a spec-kit source checkout." This replaces the raw, confusing FileNotFoundError with a traceback into site-packages that a pip-installed user would otherwise see.

Comment on lines +76 to +88
widths = [
max(len(header), *(len(_render_cell(row[index])) for row in table_rows))
for index, header in enumerate(headers)
]

def render_row(values: list[str]) -> str:
return "| " + " | ".join(
_render_cell(value).ljust(widths[index]) for index, value in enumerate(values)
) + " |"

lines = [
render_row(list(headers)),
"| " + " | ".join("-" * width for width in widths) + " |",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). render_community_extensions_table() now uses a simple | value | fixed separator (| --- |) instead of computing per-column widths with ljust padding. Markdown renderers ignore column alignment, and the padded output was inflating file size and diff noise significantly (the old table had cells padded to hundreds of characters).

Comment on lines +18 to +22
def _format_tags(tags: Any) -> str:
if not isinstance(tags, list) or not tags:
return "—"
cleaned = [f"`{str(tag)}`" for tag in tags if str(tag).strip()]
return ", ".join(cleaned) if cleaned else "—"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). _format_tags() now strips | from tag values before wrapping them in backticks: str(tag).replace('|', '').strip(). This prevents the _render_cell pipe-escape from producing \| inside a backtick code span, which would render literally as \| in Markdown rather than being invisible. Tags with pipes will have the pipe silently removed.

Comment on lines +35 to +37
for ext_id, ext in extensions.items():
if not isinstance(ext, dict):
raise ValueError(f"Community extension {ext_id!r} must be a mapping")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). The --markdown handler in extension_search now wraps render_community_extensions_table() in a try/except (ValueError, FileNotFoundError) block and surfaces the error via console.print(f"[red]Error:[/red] {exc}") + typer.Exit(1), consistent with the rest of the CLI's error handling pattern.

The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):

**Categories:**
> Run `specify extension search --markdown` to regenerate this table.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. CI sync enforcement (e.g. a pre-commit hook or CI job that runs specify extension search --markdown and diffs against the committed file) is a valid follow-up. It's deferred to a separate PR/issue as it involves workflow/CI infrastructure changes beyond the scope of this feature.

The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):

**Categories:**
> Run `specify extension search --markdown` to regenerate this table.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional. The old hand-maintained table had separate Category and Effect columns, but the community catalog JSON doesn't have structured category/effect fields — it only has free-form tags. Preserving the old legends would mean either duplicating static prose (which drifts) or fabricating a mapping from tags to categories (which adds complexity without a reliable source). The tags column surfaces the same taxonomy signals (e.g. docs, code, process, integration, etc.) without the maintenance burden of curated categories. This is a deliberate trade-off.

Comment on lines +6 to +7
def test_community_extensions_table_renders() -> None:
table = render_community_extensions_table()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed (ea06181). Added 9 edge-case tests using synthetic catalogs (patching COMMUNITY_CATALOG_PATH via unittest.mock.patch):

  • test_missing_catalog_fileFileNotFoundError with actionable message
  • test_malformed_json — raises on invalid JSON
  • test_non_dict_rootValueError when root is not a dict
  • test_missing_extensions_keyValueError when extensions key is absent
  • test_non_dict_extension_valueValueError when an extension entry is not a mapping
  • test_empty_catalog_raisesValueError when catalog has no extensions
  • test_extension_without_repository — name rendered as plain text (no link)
  • test_tags_containing_pipe_do_not_break_table — pipe stripped from tag values
  • test_non_list_tags_renders_em_dash — non-list tags fall back to

All 11 tests pass.

Comment on lines +25 to +27
def list_community_extensions() -> list[dict[str, Any]]:
"""Return community extensions sorted alphabetically by name then ID."""
data = json.loads(COMMUNITY_CATALOG_PATH.read_text(encoding="utf-8"))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. list_community_extensions() and render_community_extensions_table() now both accept an explicit path: Path = COMMUNITY_CATALOG_PATH parameter. Tests have been updated to pass the path directly (e.g. list_community_extensions(path=tmp_path / \"missing.json\")) instead of monkeypatching the module global, which is the cleaner and more reusable approach the reviewer suggested.

DyanGalih added 4 commits May 14, 2026 23:09
…arkdown

- Remove standalone scripts/generate_community_extensions_index.py
- Strip doc injection machinery from community_catalog_docs.py; keep only table rendering
- Rename iter_community_extensions -> list_community_extensions (public, reflects return type)
- Fix _render_cell to also normalize \r\n/\r and escape | as \| (parity with catalog_docs.py)
- Wire render_community_extensions_table() into new --markdown flag of extension search
- Simplify tests: drop subprocess/doc-path tests, keep table rendering and sort tests
- Clean up docs/community/extensions.md: remove generated markers, add regen note
- Restore em dash in docs/community/extensions.md (was accidentally changed to hyphen)
- Move _format_tags() call from list_community_extensions() to render layer:
  list_community_extensions() now returns raw tags list (data-pure);
  render_community_extensions_table() formats tags at render time
…alog_docs

- Remove width-padding from render_community_extensions_table(); use simple
  '| value |' separators — Markdown ignores column alignment padding and it
  inflates diff noise considerably (r3244775940)
- Strip '|' from tag values in _format_tags() so tags wrapped in backtick code
  spans don't produce malformed escaped sequences like '\|' inside code spans
  (r3244775963)
- Add FileNotFoundError with actionable message when catalog file is missing
  (e.g. when package is installed via pip/pipx outside a source checkout)
  (r3244775915)
- Warn when --markdown is combined with filters (query/--tag/--author/--verified)
  which are silently ignored; catch ValueError/FileNotFoundError and surface
  clean error via console instead of raw traceback (r3244775855, r3244775979)
- Add 9 edge-case tests covering: missing file, malformed JSON, non-dict root,
  missing extensions key, non-dict extension value, empty catalog, missing
  repository, tags with pipes, and non-list tags (r3244776046)
- Regenerate docs/community/extensions.md with compact table format
Copilot AI review requested due to automatic review settings May 14, 2026 23:10
@DyanGalih DyanGalih force-pushed the 003-generate-community-docs branch from ea06181 to 0cb36ea Compare May 14, 2026 23:10
…ions

Rebased onto upstream/main (6322a4d) which added Architecture Workflow
extension (c1a1653). Regenerate table from updated catalog.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 12 comments.

Comment on lines +3991 to +4003
if markdown:
if query or tag or author or verified:
console.print(
"[yellow]Warning:[/yellow] --markdown outputs the full community catalog "
"and ignores filters (query, --tag, --author, --verified)."
)
from .community_catalog_docs import render_community_extensions_table
try:
typer.echo(render_community_extensions_table())
except (ValueError, FileNotFoundError) as exc:
console.print(f"[red]Error:[/red] {exc}")
raise typer.Exit(1)
return
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The warning is already emitted to stdout before the unfiltered table output. Piping to a file will mix the warning into the table, which is not ideal, but since the warning is printed via console.print to stderr-equivalent and typer.echo outputs the table to stdout they should be separable in practice. Adding --markdown filter rejection with non-zero exit would break the common doc-generation workflow of specify extension search --markdown > file.md. The current behavior (warn + emit) is the chosen trade-off, consistent with how this was also resolved in PR #2563 for integration_search.

Comment on lines +10 to +11
ROOT_DIR = Path(__file__).resolve().parents[2]
COMMUNITY_CATALOG_PATH = ROOT_DIR / "extensions" / "catalog.community.json"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. This is a known limitation documented in the FileNotFoundError message: \"The --markdown flag requires a spec-kit source checkout.\" The flag is intentionally a contributor/maintainer tool for regenerating the reference table, not an end-user feature. Packaging via importlib.resources or shipping the catalog as package data would be a separate improvement tracked as a follow-up.

Comment on lines +14 to +15
def _render_cell(value: str) -> str:
return value.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("|", "\\|")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Raw field values (name, description) are now escaped with _render_cell() before composing Markdown link syntax. The render_row() helper no longer re-escapes already-escaped values — it concatenates them directly. This ensures a pipe inside a name or description cannot produce [name\\|...]({url}) which would break the link target.

if not isinstance(tags, list) or not tags:
return "—"
# Strip | from tag values so they don't break table syntax inside backtick spans
cleaned = [f"`{str(tag).replace('|', '').strip()}`" for tag in tags if str(tag).strip()]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. _format_tags() now cleans first and filters on the cleaned value: [f\"{c}\" for tag in tags if (c := str(tag).replace(\"|\", \"\").strip())]. A tag like \" | \" that previously passed the str(tag).strip() check but would produce `` after pipe removal is now filtered out correctly.

Comment thread tests/test_community_catalog_docs.py Outdated
Comment on lines +102 to +105
# pipe stripped from tag — table should render cleanly
assert "`foobar`" in table


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. test_tags_containing_pipe_do_not_break_table now also asserts: (1) the data row is well-formed — foo_row.count(\"|\") == 6 for a 5-column table, and (2) the id field falls back to ext_id when the \"id\" key is absent from the extension entry — assert \"\foo`" in table`.


**Categories:**
> Run `specify extension search --markdown` to regenerate this table.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — addressed previously in this thread. The community catalog JSON has only free-form tags, no structured category/effect fields. Preserving the old static legends would require either drift-prone prose duplication or a fabricated tag-to-category mapping. The tags column surfaces the same taxonomy (e.g. docs, code, process, integration) without maintenance overhead. This is a deliberate trade-off.

Comment on lines 8 to +10
The following community-contributed extensions are available in [`catalog.community.json`](https://github.com/github/spec-kit/blob/main/extensions/catalog.community.json):

**Categories:**
> Run `specify extension search --markdown` to regenerate this table.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — deferred to a follow-up PR/issue. Adding CI enforcement (e.g. a job that runs specify extension search --markdown, diffs against the committed file, and fails on drift) is valid and desirable but involves CI/workflow infrastructure changes beyond the scope of this feature.

Comment on lines +61 to +62
if not rows:
raise ValueError("Community catalog has no extensions")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. Raising on an empty catalog is intentional — an empty community catalog most likely means something went wrong (wrong path, corrupt file, etc.) rather than a legitimate state. Emitting a header-only table would silently replace the doc with content-free output, which is harder to notice. The ValueError keeps it loud and explicit.

Comment on lines +46 to +47
"name": str(ext.get("name") or ext_id),
"id": str(ext.get("id") or ext_id),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — lower priority. The or fallback (ext.get(\"name\") or ext_id) intentionally treats empty string as missing (same behaviour as None), which is the right default since an extension with an empty name should display its key. Falsy non-string values like 0 or False for a name field are not valid per the catalog schema, so the edge case is hypothetical. Adding explicit is None checks or schema validation can be done in a follow-up if schema enforcement is added.

Comment thread src/specify_cli/__init__.py Outdated
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag"),
author: Optional[str] = typer.Option(None, "--author", help="Filter by author"),
verified: bool = typer.Option(False, "--verified", help="Show only verified extensions"),
markdown: bool = typer.Option(False, "--markdown", help="Output results as a markdown table"),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Updated to: \"Output the full community catalog as a markdown table (ignores query/tag/author/verified filters)\". The behavior is now explicit from --help without needing to discover the runtime warning.

…log_docs

- Parameterize list_community_extensions(path=) and render_community_extensions_table(path=)
  so callers can supply a custom catalog path without monkeypatching module globals;
  update tests to use explicit path= args instead of unittest.mock.patch (r3244776065)
- Escape raw field values (name, description) with _render_cell() BEFORE composing
  Markdown link syntax [name](url) so a pipe inside a name/description cannot break
  a link target; render_row() no longer re-escapes already-escaped values (r3244829318)
- Fix _format_tags() filter order: clean (replace+strip) first, then filter on the
  cleaned value — a tag like '  |  ' previously passed the filter but produced an
  empty backtick span after pipe removal (r3244829330)
- Update test_tags_containing_pipe: assert row is well-formed (6 pipe separators for
  5-column table), assert id falls back to ext_id when 'id' field is absent (r3244829343)
- Change pytest.raises(Exception) to pytest.raises(json.JSONDecodeError) for the
  malformed JSON test — the broad Exception catch masks unrelated failures (r3244829358)
- render_community_extensions_table() now returns a trailing newline so Markdown
  renderers see a blank line between the last table row and subsequent paragraphs;
  restore footer paragraph in extensions.md that was previously crammed onto same
  line as last table row (r3244829373)
- Update --markdown help text to make 'ignores filters' behavior explicit rather than
  relying on the runtime warning alone (r3244829452)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants