From 24cf2f34bac77e958d75644bf60e62e875daa050 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:28:47 +0000 Subject: [PATCH 1/2] feat(registry): support exclude_paths on git sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exclude_paths was declared only on ZipSourceSchema, and both call sites sat inside `source.type === "zip"` branches. Git sources therefore had no way to prune a subtree, so the only lever was narrowing docs_path — which fails when the content you want shares a directory with content you don't. #133 is the case: godot-docs keeps GDScript, C# and C++ tutorials as siblings under tutorials/scripting. C# is 40% of that subtree and outranks GDScript on the queries that matter (signal, await, export), so a GDScript package built from the wider path returns C# results. Without exclude_paths the contributor's only option was a narrow docs_path that also drops the built-in function reference. Patterns match relative to docs_path, as they already do for zip sources, so a definition reads the same either way: the zip builder strips the prefix while extracting, and git file paths still carry it, which excludeFiles accounts for. compileGlob moves to glob.ts and is now shared rather than copied, alongside excludeFiles. Filtering happens before the existing emptiness check, so an over-broad pattern fails the build instead of publishing an empty package. Verified against godot-docs: 129 sections baseline, 103 with two files excluded, and exclude "**" exits 1 with "No documentation files found" rather than succeeding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NBQQpA86yYzwJUiVz8ph2R --- .changeset/git-exclude-paths.md | 5 +++ packages/registry/src/build.ts | 25 ++++++++--- packages/registry/src/definition.ts | 1 + packages/registry/src/glob.test.ts | 66 +++++++++++++++++++++++++++++ packages/registry/src/glob.ts | 42 ++++++++++++++++++ packages/registry/src/zip.ts | 14 +----- registry/README.md | 23 ++++++++++ 7 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 .changeset/git-exclude-paths.md create mode 100644 packages/registry/src/glob.test.ts create mode 100644 packages/registry/src/glob.ts diff --git a/.changeset/git-exclude-paths.md b/.changeset/git-exclude-paths.md new file mode 100644 index 0000000..e1e7304 --- /dev/null +++ b/.changeset/git-exclude-paths.md @@ -0,0 +1,5 @@ +--- +"@neuledge/context": patch +--- + +Registry definitions with a `git` source now support `exclude_paths`, the glob-based pruning that was previously available only to `zip` sources. diff --git a/packages/registry/src/build.ts b/packages/registry/src/build.ts index ddefdf4..092c3f1 100644 --- a/packages/registry/src/build.ts +++ b/packages/registry/src/build.ts @@ -24,6 +24,7 @@ import { type UnversionedDefinition, type VersionedDefinition, } from "./definition.js"; +import { excludeFiles } from "./glob.js"; import { downloadAndExtractZip } from "./zip.js"; export interface RegistryBuildResult extends BuildResult { @@ -82,6 +83,7 @@ export async function buildFromDefinition( entry.source.url, constructTag(entry.tag_pattern, version), entry.source.docs_path, + entry.source.exclude_paths, entry.source.lang, outputPath, definition, @@ -172,10 +174,16 @@ export async function buildUnversioned( stdio: ["pipe", "pipe", "pipe"], }).trim(); - const files = readLocalDocsFiles(tempDir, { - path: source.docs_path, - lang: source.lang, - }); + // Filter before the emptiness check, so an over-broad exclude_paths fails + // loudly here instead of publishing an empty package. + const files = excludeFiles( + readLocalDocsFiles(tempDir, { + path: source.docs_path, + lang: source.lang, + }), + source.exclude_paths, + source.docs_path, + ); if (files.length === 0) { throw new Error( @@ -208,6 +216,7 @@ function buildFromGit( url: string, tag: string, docsPath: string | undefined, + excludePaths: string[] | undefined, lang: string, outputPath: string, definition: VersionedDefinition, @@ -216,7 +225,13 @@ function buildFromGit( const { tempDir, cleanup } = cloneRepository(url, tag); try { - const files = readLocalDocsFiles(tempDir, { path: docsPath, lang }); + // Filter before the emptiness check, so an over-broad exclude_paths fails + // loudly here instead of publishing an empty package. + const files = excludeFiles( + readLocalDocsFiles(tempDir, { path: docsPath, lang }), + excludePaths, + docsPath, + ); if (files.length === 0) { throw new Error(`No documentation files found in ${url} at tag ${tag}`); diff --git a/packages/registry/src/definition.ts b/packages/registry/src/definition.ts index 370f1d5..9381e23 100644 --- a/packages/registry/src/definition.ts +++ b/packages/registry/src/definition.ts @@ -35,6 +35,7 @@ const GitSourceSchema = z.object({ */ ref: z.string().optional(), docs_path: z.string().optional(), + exclude_paths: z.array(z.string()).optional(), // glob patterns to exclude lang: z.string().default("en"), }); diff --git a/packages/registry/src/glob.test.ts b/packages/registry/src/glob.test.ts new file mode 100644 index 0000000..9cb4cfe --- /dev/null +++ b/packages/registry/src/glob.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { excludeFiles } from "./glob.js"; + +const files = (...paths: string[]) => paths.map((path) => ({ path })); + +describe("excludeFiles", () => { + it("returns the input untouched when no patterns are given", () => { + const input = files("a.md", "b.md"); + expect(excludeFiles(input, undefined)).toBe(input); + expect(excludeFiles(input, [])).toBe(input); + }); + + it("matches ** across segments and * within one", () => { + const input = files( + "tutorials/scripting/c_sharp/basics.md", + "tutorials/scripting/gdscript/basics.md", + "index.md", + "nested/index.md", + ); + + expect( + excludeFiles(input, ["tutorials/scripting/c_sharp/**"]).map( + (f) => f.path, + ), + ).toEqual([ + "tutorials/scripting/gdscript/basics.md", + "index.md", + "nested/index.md", + ]); + + // A single * must not cross a separator, so "nested/index.md" survives. + expect(excludeFiles(input, ["*.md"]).map((f) => f.path)).toEqual([ + "tutorials/scripting/c_sharp/basics.md", + "tutorials/scripting/gdscript/basics.md", + "nested/index.md", + ]); + }); + + it("matches relative to docs_path, as zip sources do", () => { + // Git file paths keep the docs_path prefix; the pattern must not have to. + const input = files("docs/api/internal/secret.md", "docs/api/public.md"); + + expect( + excludeFiles(input, ["internal/**"], "docs/api").map((f) => f.path), + ).toEqual(["docs/api/public.md"]); + + // Without the docs_path the same pattern matches nothing, since the + // stored path still carries the prefix. + expect(excludeFiles(input, ["internal/**"]).map((f) => f.path)).toEqual( + input.map((f) => f.path), + ); + }); + + it("can exclude everything, leaving the caller to reject an empty build", () => { + // The builders check for zero files after filtering; an over-broad pattern + // must therefore be able to empty the list rather than silently no-op. + expect(excludeFiles(files("a.md", "b.md"), ["**"])).toHaveLength(0); + }); + + it("treats glob metacharacters in a pattern literally where unsupported", () => { + const input = files("a+b.md", "axb.md"); + expect(excludeFiles(input, ["a+b.md"]).map((f) => f.path)).toEqual([ + "axb.md", + ]); + }); +}); diff --git a/packages/registry/src/glob.ts b/packages/registry/src/glob.ts new file mode 100644 index 0000000..f1fabf8 --- /dev/null +++ b/packages/registry/src/glob.ts @@ -0,0 +1,42 @@ +/** + * Glob matching for `exclude_paths`, shared by the zip and git source builders. + */ + +/** + * Compile a simple glob pattern to a RegExp. + * Supports * (any chars except /) and ** (any chars including /). + */ +export function compileGlob(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*\*/g, "\0") + .replace(/\*/g, "[^/]*") + .replace(/\0/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +/** + * Drop files matching any exclude pattern. + * + * Patterns are matched against the path relative to `docsPath`, so a definition + * reads the same whether its source is a zip or a git clone: the zip builder + * strips the prefix while extracting, and git file paths still carry it. + */ +export function excludeFiles( + files: T[], + excludePaths: string[] | undefined, + docsPath?: string, +): T[] { + if (!excludePaths?.length) return files; + + const patterns = excludePaths.map(compileGlob); + const prefix = docsPath ? `${docsPath.replace(/\/+$/, "")}/` : ""; + + return files.filter((file) => { + const relative = + prefix && file.path.startsWith(prefix) + ? file.path.slice(prefix.length) + : file.path; + return !patterns.some((re) => re.test(relative)); + }); +} diff --git a/packages/registry/src/zip.ts b/packages/registry/src/zip.ts index 35ae5e0..c8ad7e9 100644 --- a/packages/registry/src/zip.ts +++ b/packages/registry/src/zip.ts @@ -7,6 +7,7 @@ */ import { inflateRawSync } from "node:zlib"; +import { compileGlob } from "./glob.js"; const DOCUMENTATION_EXTENSIONS = [ ".md", @@ -99,19 +100,6 @@ export async function downloadAndExtractZip( return files; } -/** - * Compile a simple glob pattern to a RegExp. - * Supports * (any chars except /) and ** (any chars including /). - */ -function compileGlob(pattern: string): RegExp { - const escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*\*/g, "\0") - .replace(/\*/g, "[^/]*") - .replace(/\0/g, ".*"); - return new RegExp(`^${escaped}$`); -} - /** Get lowercase file extension including the dot. */ function getExtension(path: string): string { const lastDot = path.lastIndexOf("."); diff --git a/registry/README.md b/registry/README.md index 79eec78..1beac55 100644 --- a/registry/README.md +++ b/registry/README.md @@ -92,6 +92,29 @@ versions: > > Outside those four directories, use **unversioned** or **versioned-by-zip**. +## Excluding parts of a source + +`docs_path` narrows a source to one directory. When the directory you need also holds +material that belongs to a different package, `exclude_paths` prunes it: + +```yaml +source: + type: git + url: https://github.com/godotengine/godot-docs + ref: stable + exclude_paths: + - "tutorials/scripting/c_sharp/**" +``` + +Patterns are glob-style — `*` matches within a path segment, `**` across segments — and +are matched against the path **relative to `docs_path`** when one is set, or to the +repository root when it is not. Excluding everything is an error rather than an empty +package. + +Reach for this when a wider `docs_path` would drag in a sibling language or framework +that then outranks the docs you actually want; prefer narrowing `docs_path` when the +content you want is already isolated in its own directory. + ## Supported documentation formats Markdown (`.md`, `.mdx`), HTML, AsciiDoc (`.adoc`) and reStructuredText (`.rst`). From d2598360842c95b69e885758061a7da10a62cef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 23:29:56 +0000 Subject: [PATCH 2/2] chore: drop the changeset from the exclude_paths change The changeset declared a patch on @neuledge/context, which this PR does not touch. Left in, it would publish 1.2.5 with a changelog entry describing a feature that is not in that package. The package actually changed is @neuledge/registry, which is private: true. CLAUDE.md scopes changesets to published packages and excludes internal tooling, so this change takes none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NBQQpA86yYzwJUiVz8ph2R --- .changeset/git-exclude-paths.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/git-exclude-paths.md diff --git a/.changeset/git-exclude-paths.md b/.changeset/git-exclude-paths.md deleted file mode 100644 index e1e7304..0000000 --- a/.changeset/git-exclude-paths.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@neuledge/context": patch ---- - -Registry definitions with a `git` source now support `exclude_paths`, the glob-based pruning that was previously available only to `zip` sources.