Skip to content

Commit 82a9c4f

Browse files
authored
feat(snippets): entrypoint-based render + sdk.yaml reduced to id (#405)
* feat(snippets): entrypoint-based render + sdk.yaml reduced to id Two related schema changes that simplify the contract between this repo and its consumers: 1. Drop the `ld-application.get-started-file(s)` field entirely. The renderer no longer asks each sdk.yaml which file to rewrite. Instead the consumer (gonfalon, ld-docs-private) declares its own list of entrypoint directories, and the renderer walks them recursively for files containing the SDK_SNIPPET:RENDER sentinel. - `Render(sdksFS, entrypoints []string)` and the matching `Verify` replace the old `(sdksFS, appDir)` signatures. - The walk skips `node_modules`, `.git`, `.next`, `dist`, `build`, `.cache`, `coverage`, `out`, `.turbo`. Files are filtered by extension (.tsx/.jsx/.ts/.js/.mdx) and by a fast bytes.Contains pre-check for the marker sentinel before invoking the regex scanner. - Symlinks are skipped, so a malicious symlink farm in a vendored directory can't pull the renderer outside the entrypoint. - Overlapping entrypoints dedupe. - Empty entrypoint list, missing entrypoint, or entrypoint that points at a file all fail loudly. 2. Reduce sdk.yaml to a single field: `id:`. The id matches the SDK's identifier in launchdarkly/sdk-meta's API packages, where the authoritative metadata lives (display name, type, languages, regions, hello-world repo, docs URL). Every previously-modeled field on the descriptor was unread by this codebase; downstream consumers that need that metadata read sdk-meta directly. - The `descriptor` struct + `loadDescriptor` function are deleted. Nothing in the engine reads sdk.yaml today. - Each of the 23 sdk.yaml files reduces to a header comment plus `id: <sdk>`. CLI: Old: snippets render --target=ld-application --out=<consumer> New: snippets render --target=ld-application --entrypoint=<dir> ... --entrypoint is a custom flag.Value that accumulates across repeats (mirrors the standard Go-stdlib stringSliceFlag idiom). End-to-end against gonfalon's static/ld/components/getStarted: - Reset every marker hash to 0 (122 markers in 24 files). - `snippets render --entrypoint=...getStarted` rewrites all 24 files via the embedded sdks/ tree. - Diff against pre-reset stash: byte-identical. - `snippets verify --entrypoint=...getStarted`: ok. Companion changes follow: - launchdarkly/gh-actions PR #82 needs to add an `entrypoints` input that the consumer-side workflow declares. - This PR stacks on #404 (the embedding + release pipeline PR). * feat(snippets): drop ld-application.slot Per the schema audit, slot was a write-only field — declared on the LDApplicationHints struct, decoded from frontmatter, never read by any production code. The marker comment carries the snippet id directly, so the per-page lookup slot was meant to power doesn't happen. - Remove the LDApplicationHints struct and the LDApplication field on Frontmatter. - Strip the `ld-application:\n slot: <name>\n` block from all 122 snippet files (KnownFields(true) would otherwise fail to parse them after the struct change). - Render output unaffected: end-to-end against gonfalon shows `no changes` + `ok` from verify. * fix(snippets): always descend into the entrypoint root skipDirNames prunes well-known noise (node_modules, dist, build, …) under each entrypoint, but filepath.WalkDir invokes the callback for the root itself first. If a consumer pointed `--entrypoint=./build` at a project that emits TSX there, the root's basename matched the skip-list and the walk silently produced zero files. Guard the skip check so it only fires beneath the root, and add a regression test.
1 parent a7bf6af commit 82a9c4f

154 files changed

Lines changed: 350 additions & 971 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

snippets/cmd/snippets/main.go

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io/fs"
77
"os"
8+
"strings"
89

910
"github.com/launchdarkly/sdk-meta/snippets"
1011
"github.com/launchdarkly/sdk-meta/snippets/internal/adapters/ldapplication"
@@ -23,19 +24,30 @@ func resolveSDKsFS(sdksFlag string) fs.FS {
2324
return os.DirFS(sdksFlag)
2425
}
2526

27+
// repeatableString implements flag.Value so a flag like --entrypoint can
28+
// be passed multiple times and accumulate into a slice. Mirrors the
29+
// stringSliceFlag idiom widely used in Go CLIs that stick to the stdlib
30+
// `flag` package.
31+
type repeatableString []string
32+
33+
func (r *repeatableString) String() string { return strings.Join(*r, ",") }
34+
func (r *repeatableString) Set(s string) error { *r = append(*r, s); return nil }
35+
2636
const usage = `snippets — LaunchDarkly SDK snippet generator
2737
2838
usage:
29-
snippets render --target=ld-application --out=<app-checkout> [--sdks=./sdks]
30-
Rewrites the consumer's marked regions in place from the snippet sources.
31-
This is the command authors run after editing a snippet, and the command
32-
that produces the diff in the consumer repo's PR.
39+
snippets render --target=ld-application --entrypoint=<dir> [--entrypoint=<dir2> ...] [--sdks=./sdks]
40+
Walks each --entrypoint directory in the consumer checkout, finds files
41+
that contain SDK_SNIPPET:RENDER markers, and rewrites each marked
42+
region from the snippet sources. --entrypoint may be passed multiple
43+
times. Authors run this after editing a snippet; the consumer's
44+
sync action runs it on every release.
3345
34-
snippets verify --target=ld-application --out=<app-checkout> [--sdks=./sdks]
35-
Read-only check used by CI in the consumer repo. Re-renders every marked
36-
region in memory and fails if the rendered bytes drift from what's on
37-
disk, or if a marker's hash does not match its current region's content.
38-
Never writes; never executes any snippet code.
46+
snippets verify --target=ld-application --entrypoint=<dir> [--entrypoint=<dir2> ...] [--sdks=./sdks]
47+
Read-only counterpart to render, used by CI in the consumer repo.
48+
Fails if the rendered bytes would drift from what's on disk, or if
49+
a marker's hash does not match its current region's content. Never
50+
writes; never executes any snippet code.
3951
4052
snippets validate --sdk=<sdk-id> [--sdks=./sdks] [--validators=./validators]
4153
Builds the SDK's per-language validator (Docker image or native harness),
@@ -73,15 +85,16 @@ func main() {
7385
func runRender(args []string) {
7486
fset := flag.NewFlagSet("render", flag.ExitOnError)
7587
target := fset.String("target", "", "adapter target: `ld-application`")
76-
out := fset.String("out", "", "path to the consumer checkout")
88+
var entrypoints repeatableString
89+
fset.Var(&entrypoints, "entrypoint", "directory in the consumer checkout to walk for marker files (repeatable)")
7790
sdks := fset.String("sdks", "", "path to a sdks/ directory (default: embedded)")
7891
_ = fset.Parse(args)
7992

80-
if *target != "ld-application" || *out == "" {
81-
fmt.Fprintf(os.Stderr, "render: --target=ld-application and --out are required\n")
93+
if *target != "ld-application" || len(entrypoints) == 0 {
94+
fmt.Fprintf(os.Stderr, "render: --target=ld-application and at least one --entrypoint are required\n")
8295
os.Exit(2)
8396
}
84-
changed, err := ldapplication.Render(resolveSDKsFS(*sdks), *out)
97+
changed, err := ldapplication.Render(resolveSDKsFS(*sdks), entrypoints)
8598
if err != nil {
8699
fmt.Fprintf(os.Stderr, "render failed: %v\n", err)
87100
os.Exit(1)
@@ -98,15 +111,16 @@ func runRender(args []string) {
98111
func runVerify(args []string) {
99112
fset := flag.NewFlagSet("verify", flag.ExitOnError)
100113
target := fset.String("target", "", "adapter target: `ld-application`")
101-
out := fset.String("out", "", "path to the consumer checkout")
114+
var entrypoints repeatableString
115+
fset.Var(&entrypoints, "entrypoint", "directory in the consumer checkout to walk for marker files (repeatable)")
102116
sdks := fset.String("sdks", "", "path to a sdks/ directory (default: embedded)")
103117
_ = fset.Parse(args)
104118

105-
if *target != "ld-application" || *out == "" {
106-
fmt.Fprintf(os.Stderr, "verify: --target=ld-application and --out are required\n")
119+
if *target != "ld-application" || len(entrypoints) == 0 {
120+
fmt.Fprintf(os.Stderr, "verify: --target=ld-application and at least one --entrypoint are required\n")
107121
os.Exit(2)
108122
}
109-
if err := ldapplication.Verify(resolveSDKsFS(*sdks), *out); err != nil {
123+
if err := ldapplication.Verify(resolveSDKsFS(*sdks), entrypoints); err != nil {
110124
fmt.Fprintf(os.Stderr, "verify failed: %v\n", err)
111125
os.Exit(1)
112126
}

snippets/docs/AUTHORING.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,17 @@ export LAUNCHDARKLY_SDK_KEY=... # server-side key
7575
export LAUNCHDARKLY_FLAG_KEY=... # flag the snippet evaluates
7676
snippets validate --sdk=python-server-sdk
7777

78-
# Rewrite all marked regions in an ld-application checkout
79-
snippets render --target=ld-application --out=/path/to/ld-application
80-
81-
# Confirm consumer file matches what we'd render (no edits)
82-
snippets verify --target=ld-application --out=/path/to/ld-application
78+
# Rewrite every marked region under one or more entrypoint dirs in the
79+
# consumer checkout. --entrypoint is repeatable; the renderer walks each
80+
# directory recursively, only opens files whose extension it understands
81+
# (.tsx/.jsx/.ts/.js/.mdx) AND that contain the SDK_SNIPPET:RENDER
82+
# sentinel, and skips junk dirs (node_modules, .git, dist, build, ...).
83+
snippets render --target=ld-application \
84+
--entrypoint=/path/to/ld-application/static/ld/components/getStarted
85+
86+
# Confirm consumer files match what we'd render (no edits)
87+
snippets verify --target=ld-application \
88+
--entrypoint=/path/to/ld-application/static/ld/components/getStarted
8389
```
8490

8591
## Validator inputs

snippets/internal/adapters/ldapplication/descriptor.go

Lines changed: 0 additions & 50 deletions
This file was deleted.

snippets/internal/adapters/ldapplication/ldapplication.go

Lines changed: 104 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package ldapplication
22

33
import (
4+
"bytes"
45
"crypto/rand"
56
"encoding/hex"
67
"fmt"
78
"io/fs"
89
"os"
9-
"path"
1010
"path/filepath"
1111
"strings"
1212

@@ -16,104 +16,136 @@ import (
1616
"github.com/launchdarkly/sdk-meta/snippets/internal/version"
1717
)
1818

19-
// Render walks every SDK's get-started TSX file under appDir, finds render
20-
// markers, and rewrites each marked region with the rendered snippet content.
21-
// sdksFS is the fs.FS rooted at the sdks/ directory (either embedded or
22-
// os.DirFS(path)). Returns one entry per file it touched.
23-
func Render(sdksFS fs.FS, appDir string) ([]string, error) {
24-
snippets, err := model.LoadAll(sdksFS)
25-
if err != nil {
26-
return nil, err
27-
}
19+
// markerSentinel is the substring every render marker contains. We use it as
20+
// a fast pre-filter so walking large consumer trees only invokes the marker
21+
// scanner on files that could possibly contain a marker. Cheaper than a
22+
// regex match per file.
23+
var markerSentinel = []byte("SDK_SNIPPET:RENDER:")
2824

29-
files, err := discoverTargetFiles(sdksFS, appDir)
30-
if err != nil {
31-
return nil, err
32-
}
25+
// candidateExtensions are the file extensions whose comment syntax the
26+
// marker scanner understands today: JS, TS, JSX, TSX, plus MDX (whose JSX
27+
// expression comments share the `{/* ... */}` form). Everything else is
28+
// skipped on sight so a `node_modules` walk doesn't eat a million PNGs.
29+
var candidateExtensions = map[string]struct{}{
30+
".tsx": {}, ".jsx": {}, ".ts": {}, ".js": {}, ".mdx": {},
31+
}
3332

34-
var changed []string
35-
for _, path := range files {
36-
ok, err := rewriteFile(path, snippets, false)
37-
if err != nil {
38-
return nil, fmt.Errorf("%s: %w", path, err)
39-
}
40-
if ok {
41-
changed = append(changed, path)
42-
}
43-
}
44-
return changed, nil
33+
// skipDirNames are directories we never descend into. They never carry
34+
// snippet markers and routinely have hundreds of thousands of files
35+
// (node_modules), generated build output, or version-control bookkeeping.
36+
var skipDirNames = map[string]struct{}{
37+
"node_modules": {}, ".git": {}, ".next": {}, "dist": {}, "build": {},
38+
".cache": {}, "coverage": {}, "out": {}, ".turbo": {},
39+
}
40+
41+
// Render walks every entrypoint, finds files that contain render markers,
42+
// and rewrites each marked region with the rendered snippet content. sdksFS
43+
// is the fs.FS rooted at the sdks/ directory (either embedded or
44+
// os.DirFS(path)). entrypoints are absolute or repo-relative directory
45+
// paths the consumer (gonfalon, ld-docs) declares as roots to scan.
46+
// Returns one entry per file it touched.
47+
func Render(sdksFS fs.FS, entrypoints []string) ([]string, error) {
48+
return run(sdksFS, entrypoints, false)
4549
}
4650

4751
// Verify re-renders every marked region in memory and fails if any hash in a
4852
// marker does not match the hash of the bytes currently in the file, or if a
4953
// re-render would change content. Never modifies files.
50-
func Verify(sdksFS fs.FS, appDir string) error {
54+
func Verify(sdksFS fs.FS, entrypoints []string) error {
55+
_, err := run(sdksFS, entrypoints, true)
56+
return err
57+
}
58+
59+
func run(sdksFS fs.FS, entrypoints []string, dryRun bool) ([]string, error) {
5160
snippets, err := model.LoadAll(sdksFS)
5261
if err != nil {
53-
return err
62+
return nil, err
5463
}
5564

56-
files, err := discoverTargetFiles(sdksFS, appDir)
65+
files, err := discoverFilesUnder(entrypoints)
5766
if err != nil {
58-
return err
67+
return nil, err
5968
}
6069

61-
for _, path := range files {
62-
if _, err := rewriteFile(path, snippets, true); err != nil {
63-
return fmt.Errorf("%s: %w", path, err)
70+
var changed []string
71+
for _, p := range files {
72+
ok, err := rewriteFile(p, snippets, dryRun)
73+
if err != nil {
74+
return nil, fmt.Errorf("%s: %w", p, err)
75+
}
76+
if ok {
77+
changed = append(changed, p)
6478
}
6579
}
66-
return nil
80+
return changed, nil
6781
}
6882

69-
// discoverTargetFiles returns every file referenced by an sdk.yaml's
70-
// `ld-application.get-started-file` field, resolved relative to appDir.
71-
//
72-
// Each referenced path must be a clean relative path that stays inside
73-
// appDir. This guards against a malicious sdk.yaml committing
74-
// `get-started-file: ../../../foo` and the renderer overwriting arbitrary
75-
// files outside the consumer checkout.
76-
func discoverTargetFiles(sdksFS fs.FS, appDir string) ([]string, error) {
77-
entries, err := fs.ReadDir(sdksFS, ".")
78-
if err != nil {
79-
return nil, err
80-
}
81-
absAppDir, err := filepath.Abs(appDir)
82-
if err != nil {
83-
return nil, err
83+
// discoverFilesUnder walks every entrypoint directory and returns the set
84+
// of files whose extension the marker scanner understands AND whose contents
85+
// contain the SDK_SNIPPET:RENDER: sentinel. Symlinks are not followed so
86+
// a malicious symlink farm in node_modules can't pull the renderer outside
87+
// its intended scope. Duplicates (when two entrypoints overlap) are
88+
// collapsed.
89+
func discoverFilesUnder(entrypoints []string) ([]string, error) {
90+
if len(entrypoints) == 0 {
91+
return nil, fmt.Errorf("at least one --entrypoint is required")
8492
}
93+
seen := map[string]struct{}{}
8594
var out []string
86-
for _, e := range entries {
87-
if !e.IsDir() {
88-
continue
95+
for _, ep := range entrypoints {
96+
abs, err := filepath.Abs(ep)
97+
if err != nil {
98+
return nil, fmt.Errorf("entrypoint %q: %w", ep, err)
8999
}
90-
descPath := path.Join(e.Name(), "sdk.yaml")
91-
desc, err := loadDescriptor(sdksFS, descPath)
100+
info, err := os.Stat(abs)
92101
if err != nil {
93-
if os.IsNotExist(err) {
94-
continue
95-
}
96-
return nil, err
102+
return nil, fmt.Errorf("entrypoint %q: %w", ep, err)
97103
}
98-
rels := desc.LDApplication.GetStartedFiles
99-
if rel := desc.LDApplication.GetStartedFile; rel != "" {
100-
rels = append([]string{rel}, rels...)
104+
if !info.IsDir() {
105+
return nil, fmt.Errorf("entrypoint %q: not a directory", ep)
101106
}
102-
for _, rel := range rels {
103-
if filepath.IsAbs(rel) {
104-
return nil, fmt.Errorf("descriptor %s: get-started-file %q must be relative", descPath, rel)
107+
err = filepath.WalkDir(abs, func(p string, d os.DirEntry, walkErr error) error {
108+
if walkErr != nil {
109+
return walkErr
110+
}
111+
if d.IsDir() {
112+
// Always descend into the entrypoint root, even if its
113+
// basename happens to be in skipDirNames (e.g.
114+
// `--entrypoint=./build` for a project that lays its
115+
// generated TSX out there). The skip-list is meant to prune
116+
// well-known noise *under* the root, not to silently turn
117+
// the entire walk into a no-op.
118+
if p != abs {
119+
if _, skip := skipDirNames[d.Name()]; skip {
120+
return filepath.SkipDir
121+
}
122+
}
123+
return nil
124+
}
125+
// Skip symlinks — we don't follow links into other parts of the
126+
// repo (or out of it).
127+
if d.Type()&os.ModeSymlink != 0 {
128+
return nil
105129
}
106-
full := filepath.Join(absAppDir, rel)
107-
// Reject any path that escapes appDir. filepath.Rel followed by a
108-
// `..` prefix check is the canonical way to do this.
109-
relCheck, err := filepath.Rel(absAppDir, full)
110-
if err != nil || relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
111-
return nil, fmt.Errorf("descriptor %s: get-started-file %q escapes appDir", descPath, rel)
130+
if _, ok := candidateExtensions[filepath.Ext(p)]; !ok {
131+
return nil
112132
}
113-
if _, err := os.Stat(full); err != nil {
114-
return nil, fmt.Errorf("descriptor %s: target not found: %w", descPath, err)
133+
if _, dup := seen[p]; dup {
134+
return nil
115135
}
116-
out = append(out, full)
136+
data, err := os.ReadFile(p)
137+
if err != nil {
138+
return err
139+
}
140+
if !bytes.Contains(data, markerSentinel) {
141+
return nil
142+
}
143+
seen[p] = struct{}{}
144+
out = append(out, p)
145+
return nil
146+
})
147+
if err != nil {
148+
return nil, err
117149
}
118150
}
119151
return out, nil

0 commit comments

Comments
 (0)