Skip to content
Merged
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
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/`
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/`
6 changes: 6 additions & 0 deletions pkg/markdown/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
Expand Down
143 changes: 143 additions & 0 deletions pkg/markdown/html_test.go
Original file line number Diff line number Diff line change
@@ -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{
`<a href="https://example.com" target="_blank" rel="noopener noreferrer">example</a>`,
},
},
{
name: "autolinked URL",
input: "Check out https://example.com for more info",
contains: []string{
`<a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>`,
},
},
{
name: "link with title",
input: `[example](https://example.com "Example Site")`,
contains: []string{
`<a href="https://example.com" title="Example Site" target="_blank" rel="noopener noreferrer">example</a>`,
},
},
{
name: "multiple links",
input: "[first](https://first.com) and [second](https://second.com)",
contains: []string{
`<a href="https://first.com" target="_blank" rel="noopener noreferrer">first</a>`,
`<a href="https://second.com" target="_blank" rel="noopener noreferrer">second</a>`,
},
},
}

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, `<a href="https://example.com">example</a>`, "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, `<h2>Heading</h2>`) // h2 because of header shift
assert.Contains(t, output, `<ul>`)
assert.Contains(t, output, `<li>List item 1</li>`)
assert.Contains(t, output, `<strong>Bold text</strong>`)
assert.Contains(t, output, `<em>italic text</em>`)
}

func TestFeedViewEmailAutolink(t *testing.T) {
input := "<user@example.com>"

result := ToEnrichedTemplate(input, types.ViewFeed, func(in string) (bool, string) {
return false, in
}, func(name string, args ...string) string {
return "/" + name
})

output := string(result)

// Email links should also open in new tab
assert.Contains(t, output, `<a href="mailto:user@example.com" target="_blank" rel="noopener noreferrer">user@example.com</a>`)
}
74 changes: 74 additions & 0 deletions pkg/markdown/mdext/linkrenderer/link_renderer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package linkrenderer

import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/util"
)

type LinkRenderer struct {
html.Config
openInNewTab bool
}

func NewLinkRenderer(openInNewTab bool, opts ...html.Option) renderer.NodeRenderer {
r := &LinkRenderer{
Config: html.NewConfig(),
openInNewTab: openInNewTab,
}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
}
return r
}

func (r *LinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindAutoLink, r.renderAutoLink)
}

func (r *LinkRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Link)
if entering {
_, _ = w.WriteString("<a href=\"")
if r.Unsafe || !html.IsDangerousURL(n.Destination) {
_, _ = w.Write(util.EscapeHTML(util.URLEscape(n.Destination, true)))
}
_ = w.WriteByte('"')
if n.Title != nil {
_, _ = w.WriteString(` title="`)
r.Writer.Write(w, n.Title)
_ = w.WriteByte('"')
}
if r.openInNewTab {
_, _ = w.WriteString(` target="_blank" rel="noopener noreferrer"`)
}
_ = w.WriteByte('>')
} else {
_, _ = w.WriteString("</a>")
}
return ast.WalkContinue, nil
}

func (r *LinkRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.AutoLink)
if !entering {
return ast.WalkContinue, nil
}
_, _ = w.WriteString("<a href=\"")
url := n.URL(source)
label := n.Label(source)
if n.AutoLinkType == ast.AutoLinkEmail && len(url) >= 7 && string(url[:7]) != "mailto:" {
_, _ = w.WriteString("mailto:")
}
_, _ = w.Write(util.EscapeHTML(util.URLEscape(url, false)))
if r.openInNewTab {
_, _ = w.WriteString(`" target="_blank" rel="noopener noreferrer">`)
} else {
_, _ = w.WriteString(`">`)
}
_, _ = w.Write(util.EscapeHTML(label))
_, _ = w.WriteString("</a>")
return ast.WalkContinue, nil
}
Loading
Loading