From 38544313469dd92882ef950804cf8e6df346d8e0 Mon Sep 17 00:00:00 2001 From: k1LoW Date: Wed, 27 May 2026 10:42:51 +0900 Subject: [PATCH 1/2] feat: reserve "key" field in page configuration for stable slide referencing When editing a deck with an AI agent or external tooling, there has been no stable way to refer to a specific slide across turns: deictic references ("this slide") don't survive turns, page numbers shift on every insert or delete, and titles or body fragments are rarely unique. Reserve "key" as a well-known optional field in the per-page JSON config to give downstream tooling a stable, opaque identifier for each slide. - Optional, opaque string with no rendering effect (treated similarly to "freeze" / "skip" / "ignore" in that it never appears in the rendered output). - Empty values are treated as unset. - Uniqueness is enforced at parse time so that consumers can rely on the identity guarantee instead of treating it as convention. Refs: https://github.com/k1LoW/deck/issues/523 --- README.md | 8 +++++++- md/md.go | 22 ++++++++++++++++++++++ md/md_test.go | 39 +++++++++++++++++++++++++++++++++++++++ testdata/key.md | 9 +++++++++ testdata/key.md.golden | 26 ++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 testdata/key.md create mode 100644 testdata/key.md.golden 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..edb80e8 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,9 +183,28 @@ 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 } +// 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 +} + // ParseContent parses a single markdown content into a Content structure. // It processes headings, lists, paragraphs, and HTML blocks to create a structured representation. func ParseContent(baseDir string, b []byte, breaks bool) (_ *Content, err error) { @@ -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 From a056388cea6ca35b9d0befb714f13faa2f5297a7 Mon Sep 17 00:00:00 2001 From: k1LoW Date: Fri, 29 May 2026 19:28:35 +0900 Subject: [PATCH 2/2] fix: place validateKeys after exported ToSlides to satisfy funcorder golangci-lint funcorder requires unexported methods to come after the struct's exported methods. The unexported validateKeys sat between Parse and ToSlides, which tripped the linter on CI. --- md/md.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/md/md.go b/md/md.go index edb80e8..33d4df2 100644 --- a/md/md.go +++ b/md/md.go @@ -189,22 +189,6 @@ func Parse(baseDir string, b []byte, cfg *config.Config) (_ *MD, err error) { return md, nil } -// 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 -} - // ParseContent parses a single markdown content into a Content structure. // It processes headings, lists, paragraphs, and HTML blocks to create a structured representation. func ParseContent(baseDir string, b []byte, breaks bool) (_ *Content, err error) { @@ -273,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(