Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- {"layout": "title-and-body"} -->
Expand All @@ -453,6 +454,11 @@ You can configure individual pages using JSON comments. Available settings:

<!-- {"skip": true} -->
# This slide will be skipped during presentation

---

<!-- {"key": "a7b5"} -->
# This slide can be referenced by the key "a7b5"
```

> [!TIP]
Expand Down Expand Up @@ -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: `<!-- {"layout": "title-and-body"} -->`
- Available settings: `"freeze": true`, `"ignore": true`, `"skip": true`
- Available settings: `"freeze": true`, `"ignore": true`, `"skip": true`, `"key": "<opaque-id>"`
- Speaker notes: `<!-- This is a speaker note -->` (use separate comments for notes)

## Important Notes
Expand Down
22 changes: 22 additions & 0 deletions md/md.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions md/md_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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: "<!-- {\"key\": \"a\"} -->\n\n# A\n\n---\n\n<!-- {\"key\": \"b\"} -->\n\n# B\n",
},
{
name: "mixed keyed and unkeyed",
src: "<!-- {\"key\": \"a\"} -->\n\n# A\n\n---\n\n# B\n",
},
{
name: "empty keys are not duplicates",
src: "<!-- {\"key\": \"\"} -->\n\n# A\n\n---\n\n<!-- {\"key\": \"\"} -->\n\n# B\n",
},
{
name: "duplicate keys",
src: "<!-- {\"key\": \"a\"} -->\n\n# A\n\n---\n\n<!-- {\"key\": \"a\"} -->\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()

Expand Down
9 changes: 9 additions & 0 deletions testdata/key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- {"key": "a7b5"} -->

# First

---

<!-- {"key": "c9d2", "layout": "title-and-body"} -->

# Second
26 changes: 26 additions & 0 deletions testdata/key.md.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[
{
"layout": "",
"key": "a7b5",
"titles": [
"First"
],
"headings": {
"1": [
"First"
]
}
},
{
"layout": "title-and-body",
"key": "c9d2",
"titles": [
"Second"
],
"headings": {
"1": [
"Second"
]
}
}
]
Loading