diff --git a/README.md b/README.md index 759f7f9..951bce0 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,7 @@ You can configure individual pages using JSON comments. Available settings: - **`"freeze"`**: Prevents `deck` from modifying the page (useful for slides with completed designs) - **`"ignore"`**: Excludes the page from slide generation (for drafts, notes, or unused content) - **`"skip"`**: Creates the slide but skips it during presentation playback (automatically advances to next slide) +- **`"key"`**: Opaque, stable identifier for the page. Has no effect on rendering; intended as a stable reference that survives reorder/insert/delete (useful when an AI agent or script needs to refer to a specific slide). Must be unique within the deck — duplicate keys are rejected at parse time. ```markdown @@ -453,6 +454,11 @@ You can configure individual pages using JSON comments. Available settings: # This slide will be skipped during presentation + +--- + + +# This slide can be referenced by the key "a7b5" ``` > [!TIP] @@ -662,7 +668,7 @@ By collaborating with AI agents to create Markdown-formatted slides, you may be ## Page Configuration Use HTML comments for page settings and speaker notes: - Page settings: `` - - Available settings: `"freeze": true`, `"ignore": true`, `"skip": true` + - Available settings: `"freeze": true`, `"ignore": true`, `"skip": true`, `"key": ""` - Speaker notes: `` (use separate comments for notes) ## Important Notes diff --git a/md/md.go b/md/md.go index caaf24d..33d4df2 100644 --- a/md/md.go +++ b/md/md.go @@ -84,6 +84,7 @@ type Config struct { Freeze *bool `json:"freeze,omitempty"` // freeze the page Ignore *bool `json:"ignore,omitempty"` // ignore the page (skip slide generation) Skip *bool `json:"skip,omitempty"` // skip the page (do not show in the presentation) + Key string `json:"key,omitempty"` // opaque, stable identifier for the page; unique within the deck } type CodeBlock struct { @@ -97,6 +98,7 @@ type Content struct { Freeze *bool `json:"freeze,omitempty"` Ignore *bool `json:"ignore,omitempty"` Skip *bool `json:"skip,omitempty"` + Key string `json:"key,omitempty"` Titles []string `json:"titles,omitempty"` TitleBodies []*deck.Body `json:"-"` Subtitles []string `json:"subtitles,omitempty"` @@ -181,6 +183,9 @@ func Parse(baseDir string, b []byte, cfg *config.Config) (_ *MD, err error) { if err := md.reflectDefaults(); err != nil { return nil, fmt.Errorf("failed to reflect defaults while parsing: %w", err) } + if err := md.validateKeys(); err != nil { + return nil, err + } return md, nil } @@ -252,6 +257,22 @@ func (md *MD) ToSlides(ctx context.Context, codeBlockToImageCmd string) (_ deck. return md.Contents.toSlides(ctx, codeBlockToImageCmd) } +// validateKeys ensures that page keys are unique within the deck. +// Empty keys are treated as unset and skipped. +func (md *MD) validateKeys() error { + seen := make(map[string]int, len(md.Contents)) + for i, content := range md.Contents { + if content.Key == "" { + continue + } + if prev, ok := seen[content.Key]; ok { + return fmt.Errorf("duplicate page key %q at pages %d and %d", content.Key, prev+1, i+1) + } + seen[content.Key] = i + } + return nil +} + func newParser() goldmark.Markdown { return goldmark.New( goldmark.WithExtensions( @@ -452,6 +473,7 @@ func walkContents(doc ast.Node, baseDir string, b []byte, content *Content, titl content.Freeze = config.Freeze content.Ignore = config.Ignore content.Skip = config.Skip + content.Key = config.Key return ast.WalkContinue, nil } content.Comments = append(content.Comments, block) diff --git a/md/md_test.go b/md/md_test.go index ac3d1b9..3ba44ca 100644 --- a/md/md_test.go +++ b/md/md_test.go @@ -46,6 +46,7 @@ func TestParse(t *testing.T) { {"../testdata/skip.md"}, {"../testdata/hr.md"}, {"../testdata/tables.md"}, + {"../testdata/key.md"}, } for _, tt := range tests { t.Run(tt.in, func(t *testing.T) { @@ -74,6 +75,44 @@ func TestParse(t *testing.T) { } } +func TestValidateKeys(t *testing.T) { + tests := []struct { + name string + src string + wantErr bool + }{ + { + name: "no keys", + src: "# A\n\n---\n\n# B\n", + }, + { + name: "unique keys", + src: "\n\n# A\n\n---\n\n\n\n# B\n", + }, + { + name: "mixed keyed and unkeyed", + src: "\n\n# A\n\n---\n\n# B\n", + }, + { + name: "empty keys are not duplicates", + src: "\n\n# A\n\n---\n\n\n\n# B\n", + }, + { + name: "duplicate keys", + src: "\n\n# A\n\n---\n\n\n\n# B\n", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse("../testdata", []byte(tt.src), nil) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestGenCodeImage(t *testing.T) { ctx := context.Background() diff --git a/testdata/key.md b/testdata/key.md new file mode 100644 index 0000000..e324b08 --- /dev/null +++ b/testdata/key.md @@ -0,0 +1,9 @@ + + +# First + +--- + + + +# Second diff --git a/testdata/key.md.golden b/testdata/key.md.golden new file mode 100644 index 0000000..70e0217 --- /dev/null +++ b/testdata/key.md.golden @@ -0,0 +1,26 @@ +[ + { + "layout": "", + "key": "a7b5", + "titles": [ + "First" + ], + "headings": { + "1": [ + "First" + ] + } + }, + { + "layout": "title-and-body", + "key": "c9d2", + "titles": [ + "Second" + ], + "headings": { + "1": [ + "Second" + ] + } + } +] \ No newline at end of file