diff --git a/AGENTS.md b/AGENTS.md index 9c527d4..3354827 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,54 @@ func TestExample(t *testing.T) { } ``` +## Markdown Rendering + +### Architecture +- **Library**: goldmark (extensible markdown parser) +- **Location**: `pkg/markdown/` +- **Entry point**: `ToEnrichedTemplate()` in `pkg/markdown/html.go` + +### View Types +Defined in `pkg/types/types.go`: +- `ViewFeed` - RSS feed items (links open in new tab) +- `ViewSinglePost` - Individual post view +- `ViewEditPreview` - Post editor preview +- `ViewComment` - Comment rendering +- `ViewArticle` - Article view +- `ViewEmail` - Email notifications +- `ViewRSS` - RSS feed output + +### Template Functions +Defined in `cmd/web/main.go` funcmap: +- `markdown_feed` - Renders feed items with `ViewFeed` +- `markdown_single_post` - Renders posts with `ViewSinglePost` +- `markdown_edit_preview` - Renders editor preview with `ViewEditPreview` +- `markdown_comment` - Renders comments with `ViewComment` +- `markdown_article` - Renders articles with `ViewArticle` + +### Custom Renderers +Located in `pkg/markdown/mdext/`: +- **linkrenderer** - Custom link renderer, adds `target="_blank" rel="noopener noreferrer"` for `ViewFeed` +- **lazyload** - Lazy loading for images +- **blocktags** - Custom block tags (gallery, etc.) +- **headershift** - Shifts header levels (h1→h2, etc.) +- **videoembed** - Embeds videos from URLs +- **handle** - Renders @username mentions + +### Extensions +Configured in `NewParser()`: +- **Linkify** - Auto-converts URLs to links (custom regex excludes closing braces) +- **Syntax highlighting** - Only for feed, single post, and edit preview views +- Custom node renderers registered via `util.Prioritized()` with priority 500 + +### Adding Custom Renderers +1. Create renderer in `pkg/markdown/mdext//` +2. Implement `renderer.NodeRenderer` interface with `RegisterFuncs()` +3. Register in `NewParser()` via `nodeRenderers` slice +4. Conditionally add based on view type if needed + ## File Locations - HTML Templates: `/Users/dima/code/pcom/cmd/web/client/html/` - JavaScript: `/Users/dima/code/pcom/cmd/web/client/js/` - Main JS entry: `/Users/dima/code/pcom/cmd/web/client/js/index.js` +- Markdown package: `/Users/dima/code/pcom/pkg/markdown/` diff --git a/pkg/markdown/html.go b/pkg/markdown/html.go index 398f599..ae42a7b 100644 --- a/pkg/markdown/html.go +++ b/pkg/markdown/html.go @@ -10,6 +10,7 @@ import ( "github.com/can3p/pcom/pkg/markdown/mdext/blocktags" "github.com/can3p/pcom/pkg/markdown/mdext/headershift" "github.com/can3p/pcom/pkg/markdown/mdext/lazyload" + "github.com/can3p/pcom/pkg/markdown/mdext/linkrenderer" "github.com/can3p/pcom/pkg/markdown/mdext/videoembed" "github.com/can3p/pcom/pkg/types" "github.com/yuin/goldmark" @@ -69,6 +70,11 @@ func NewParser(view types.HTMLView, mediaReplacer types.Replacer[string], link t }), 500), } + // Add custom link renderer for RSS feed view to open links in new tab + if view == types.ViewFeed { + nodeRenderers = append(nodeRenderers, util.Prioritized(linkrenderer.NewLinkRenderer(true), 500)) + } + if view == types.ViewEditPreview || view == types.ViewFeed || view == types.ViewSinglePost || view == types.ViewEmail || view == types.ViewRSS { extensions = append(extensions, blocktags.NewBlockTagExtender(view, link)) } diff --git a/pkg/markdown/html_test.go b/pkg/markdown/html_test.go new file mode 100644 index 0000000..56d232a --- /dev/null +++ b/pkg/markdown/html_test.go @@ -0,0 +1,143 @@ +package markdown + +import ( + "testing" + + "github.com/can3p/pcom/pkg/types" + "github.com/stretchr/testify/assert" +) + +func TestFeedViewLinksOpenInNewTab(t *testing.T) { + tests := []struct { + name string + input string + contains []string + }{ + { + name: "regular markdown link", + input: "[example](https://example.com)", + contains: []string{ + `example`, + }, + }, + { + name: "autolinked URL", + input: "Check out https://example.com for more info", + contains: []string{ + `https://example.com`, + }, + }, + { + name: "link with title", + input: `[example](https://example.com "Example Site")`, + contains: []string{ + `example`, + }, + }, + { + name: "multiple links", + input: "[first](https://first.com) and [second](https://second.com)", + contains: []string{ + `first`, + `second`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToEnrichedTemplate(tt.input, types.ViewFeed, func(in string) (bool, string) { + return false, in + }, func(name string, args ...string) string { + return "/" + name + }) + + output := string(result) + for _, expected := range tt.contains { + assert.Contains(t, output, expected, "Output should contain expected HTML") + } + }) + } +} + +func TestNonFeedViewLinksDoNotOpenInNewTab(t *testing.T) { + tests := []struct { + name string + view types.HTMLView + }{ + { + name: "single post view", + view: types.ViewSinglePost, + }, + { + name: "edit preview view", + view: types.ViewEditPreview, + }, + { + name: "comment view", + view: types.ViewComment, + }, + } + + input := "[example](https://example.com)" + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToEnrichedTemplate(input, tt.view, func(in string) (bool, string) { + return false, in + }, func(name string, args ...string) string { + return "/" + name + }) + + output := string(result) + // Should NOT contain target="_blank" + assert.NotContains(t, output, `target="_blank"`, "Non-feed views should not open links in new tab") + // Should still contain the link + assert.Contains(t, output, `example`, "Should contain regular link") + }) + } +} + +func TestFeedViewPreservesOtherMarkdownFeatures(t *testing.T) { + input := `# Heading + +This is a paragraph with a [link](https://example.com). + +- List item 1 +- List item 2 + +**Bold text** and *italic text*.` + + result := ToEnrichedTemplate(input, types.ViewFeed, func(in string) (bool, string) { + return false, in + }, func(name string, args ...string) string { + return "/" + name + }) + + output := string(result) + + // Verify link has target="_blank" + assert.Contains(t, output, `target="_blank"`) + + // Verify other markdown features are preserved + assert.Contains(t, output, `

Heading

`) // h2 because of header shift + assert.Contains(t, output, `