-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathparser.go
More file actions
1469 lines (1265 loc) · 50.3 KB
/
Copy pathparser.go
File metadata and controls
1469 lines (1265 loc) · 50.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package tinkerdown
import (
"bytes"
"encoding/json"
"fmt"
"html"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/livetemplate/tinkerdown/internal/schedule"
"github.com/livetemplate/tinkerdown/internal/slug"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
gmhtml "github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"gopkg.in/yaml.v3"
)
// SourceConfig represents a data source configuration for lvt-source blocks.
type SourceConfig struct {
Type string `yaml:"type"` // exec, pg, rest, csv, json, markdown, sqlite, wasm
Cmd string `yaml:"cmd,omitempty"` // For exec type
Query string `yaml:"query,omitempty"` // For pg type
From string `yaml:"from,omitempty"` // For rest type: API endpoint URL
File string `yaml:"file,omitempty"` // For csv/json/markdown types
Anchor string `yaml:"anchor,omitempty"` // For markdown: section anchor (e.g., "#todos")
DB string `yaml:"db,omitempty"` // For sqlite: database file path
Table string `yaml:"table,omitempty"` // For sqlite: table name
Path string `yaml:"path,omitempty"` // For wasm: path to .wasm file
QueryFile string `yaml:"query_file,omitempty"` // For graphql: path to .graphql file
Variables map[string]interface{} `yaml:"variables,omitempty"` // For graphql: query variables
Headers map[string]string `yaml:"headers,omitempty"` // For rest: HTTP headers (env vars expanded)
QueryParams map[string]string `yaml:"query_params,omitempty"` // For rest: URL query parameters
ResultPath string `yaml:"result_path,omitempty"` // For rest: dot-path to extract array (e.g., "data.items")
Readonly *bool `yaml:"readonly,omitempty"` // For markdown/sqlite: read-only mode (default: true)
Options map[string]string `yaml:"options,omitempty"`
Manual bool `yaml:"manual,omitempty"` // For exec: require Run button click
Format string `yaml:"format,omitempty"` // For exec: output format (json, lines, csv)
Delimiter string `yaml:"delimiter,omitempty"` // For exec CSV: field delimiter (default ",")
Env map[string]string `yaml:"env,omitempty"` // For exec: environment variables (env vars expanded)
Timeout string `yaml:"timeout,omitempty"` // For exec/rest: timeout (e.g., "30s", "1m")
AutoBind *bool `yaml:"auto_bind,omitempty"` // Set to false to exclude from auto-table matching
// For computed sources
GroupBy string `yaml:"group_by,omitempty"` // Field to group by
Aggregate map[string]string `yaml:"aggregate,omitempty"` // Field → aggregation expression
Filter string `yaml:"filter,omitempty"` // Optional filter expression
}
// StylingConfig represents styling/theme configuration.
type StylingConfig struct {
Theme string `yaml:"theme"`
PrimaryColor string `yaml:"primary_color"`
Font string `yaml:"font"`
}
// BlocksConfig represents code block display configuration.
type BlocksConfig struct {
AutoID bool `yaml:"auto_id"`
IDFormat string `yaml:"id_format"`
ShowLineNumbers bool `yaml:"show_line_numbers"`
}
// FeaturesConfig represents feature flags.
type FeaturesConfig struct {
HotReload bool `yaml:"hot_reload"`
Sidebar bool `yaml:"sidebar"` // Show navigation sidebar
}
// Action defines a custom action that can be triggered via button name routing or lvt-on:click.
type Action struct {
Kind string `yaml:"kind"` // Action kind: "sql", "http", "exec"
Source string `yaml:"source,omitempty"` // For sql: source name to execute against
Statement string `yaml:"statement,omitempty"` // For sql: SQL statement with :param placeholders
URL string `yaml:"url,omitempty"` // For http: request URL (supports template expressions)
Method string `yaml:"method,omitempty"` // For http: HTTP method (default: POST)
Body string `yaml:"body,omitempty"` // For http: request body template
Cmd string `yaml:"cmd,omitempty"` // For exec: command to run
Params map[string]ParamDef `yaml:"params,omitempty"` // Parameter definitions
Confirm string `yaml:"confirm,omitempty"` // Confirmation message (triggers dialog)
}
// ParamDef defines a parameter for an action.
type ParamDef struct {
Type string `yaml:"type,omitempty"` // Parameter type: "string", "number", "date", "bool"
Required bool `yaml:"required,omitempty"` // Whether the parameter is required
Default string `yaml:"default,omitempty"` // Default value
}
// Frontmatter represents the YAML frontmatter at the top of a markdown file.
type Frontmatter struct {
// Page metadata
Title string `yaml:"title"`
Description string `yaml:"description,omitempty"` // Used for <meta name="description"> + og:description
Image string `yaml:"image,omitempty"` // Path/URL used for og:image (falls back to site logo)
Type string `yaml:"type"` // tutorial, guide, reference, playground
Persist PersistMode `yaml:"persist"` // none, localstorage, server
Steps int `yaml:"steps"`
// Top-level convenience options
Sidebar *bool `yaml:"sidebar,omitempty"` // Show navigation sidebar (overrides features.sidebar)
// Layout selects the page shell. "" or "docs" (default) renders the full
// docs chrome (sidebar, breadcrumbs, opinionated typography). "landing"
// renders a minimal full-bleed shell for bespoke marketing pages — no
// sidebar, no content-wrapper clamp, no docs typography — while still
// loading the client JS (so embed-lvt demos work) and any styling.custom_css.
// Unknown values fall back to the docs layout.
Layout string `yaml:"layout,omitempty"`
// LvtShowSource toggles the page-level default for ` ```lvt ` block source
// display. When true, every ` ```lvt ` block on the page renders both its
// template source as a syntax-highlighted code listing AND the live
// interactive widget. Per-block `show-source` / `hide-source` flags
// override this default. Defaults to nil/false to preserve existing
// behavior — opt-in for documentation pages.
LvtShowSource *bool `yaml:"lvt_show_source,omitempty"`
// Source provenance — used to render an "Edit this page" link that
// points at the canonical source file in its origin repo. Useful when
// a page was synced from another repo and the docs site is not the
// canonical home of the content. Both default to "" (use site-level
// repository + the page's own relative path).
SourceRepo string `yaml:"source_repo,omitempty"` // e.g. "https://github.com/livetemplate/livetemplate"
SourcePath string `yaml:"source_path,omitempty"` // e.g. "docs/guides/progressive-complexity.md"
// SourceRef pins the git ref used in include source-link footers
// (tag/branch/commit). Resolution order: this field if set;
// otherwise tinkerdown.DefaultSourceRef (populated from the
// binary's release version by cmd/tinkerdown/main.go); otherwise
// "main".
SourceRef string `yaml:"source_ref,omitempty"`
// SourceCommit records the exact upstream commit at sync time. It is
// displayed as provenance in site chrome when present.
SourceCommit string `yaml:"source_commit,omitempty"`
// Chart customization (keyed by heading slug)
Charts map[string]ChartOptions `yaml:"charts,omitempty"`
// Config options (can override livemdtools.yaml)
Sources map[string]SourceConfig `yaml:"sources,omitempty"`
Actions map[string]Action `yaml:"actions,omitempty"`
Styling *StylingConfig `yaml:"styling,omitempty"`
Blocks *BlocksConfig `yaml:"blocks,omitempty"`
Features *FeaturesConfig `yaml:"features,omitempty"`
// Computed expressions found in the markdown content (populated during parsing)
// Map of expression ID to expression string (e.g., "expr-1" -> "count(tasks where done)")
Expressions map[string]string `yaml:"-"`
// Schedule tokens found in the markdown content (populated during parsing)
Schedules []*schedule.Token `yaml:"-"`
// Imperative commands (Notify, Run action) found in the markdown (populated during parsing)
Imperatives []*schedule.Imperative `yaml:"-"`
// Schedule parsing warnings (populated during parsing)
ScheduleWarnings []schedule.ParseWarning `yaml:"-"`
// HasCharts indicates the page has {chart:...} annotations (populated during parsing)
HasCharts bool `yaml:"-"`
// HasMermaid indicates the page has ```mermaid fenced blocks (populated during parsing)
HasMermaid bool `yaml:"-"`
}
// CodeBlock represents a code block extracted from markdown.
type CodeBlock struct {
Type string // "server", "wasm", "lvt"
Language string // "go", etc.
Flags []string // "readonly", "editable"
Metadata map[string]string // id, state, etc.
Content string
Line int // Line number in source file
}
// newGoldmarkParser builds the shared goldmark configuration used by every
// parse path in this package. allowRawHTML controls whether raw HTML in the
// markdown body passes through to the rendered output (CommonMark default
// is to omit it for safety).
//
// Pass true for content authored by the site owner (file-based pages, build
// tools, programmatic test input) — that's the same trust posture as a Go
// template and matches what Hugo, MkDocs, Docusaurus, mdBook do by default.
//
// Pass false for content sourced from arbitrary user input (the playground
// receives markdown over HTTP). Without this guard a user can submit
// <script>...</script> and execute JS on the same origin, since the docs
// CSP allows 'unsafe-inline' for the document itself.
func newGoldmarkParser(allowRawHTML bool) goldmark.Markdown {
opts := []goldmark.Option{
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
}
if allowRawHTML {
opts = append(opts, goldmark.WithRendererOptions(
gmhtml.WithUnsafe(),
))
}
return goldmark.New(opts...)
}
// ParseMarkdown parses a markdown file and extracts frontmatter and code blocks.
// Treats the input as trusted (file-based) content — raw HTML in the markdown
// body is preserved. For untrusted input use ParseMarkdownWithPartials with
// allowRawHTML=false.
func ParseMarkdown(content []byte) (*Frontmatter, []*CodeBlock, string, error) {
// Extract frontmatter
frontmatter, remaining, err := extractFrontmatter(content)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to parse frontmatter: %w", err)
}
md := newGoldmarkParser(true)
reader := text.NewReader(remaining)
doc := md.Parser().Parse(reader)
// Extract and collect livemdtools code blocks (but don't remove from AST)
var codeBlocks []*CodeBlock
blockMap := make(map[ast.Node]*CodeBlock) // Map AST nodes to CodeBlocks
lineOffset := bytes.Count(content[:len(content)-len(remaining)], []byte("\n"))
// Walk AST and identify livemdtools code blocks
err = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if fenced, ok := n.(*ast.FencedCodeBlock); ok {
block, parseErr := parseCodeBlock(fenced, remaining, lineOffset)
if parseErr != nil {
return ast.WalkStop, parseErr
}
if block != nil {
// This is a livemdtools block - collect it and map it to AST node
codeBlocks = append(codeBlocks, block)
blockMap[n] = block
} else if fenced.Info != nil {
// Non-livemdtools fenced block — check for mermaid so the
// server can conditionally load the heavy Mermaid runtime.
info := strings.Fields(string(fenced.Info.Text(remaining)))
if len(info) > 0 && info[0] == "mermaid" {
frontmatter.HasMermaid = true
}
}
}
return ast.WalkContinue, nil
})
if err != nil {
return nil, nil, "", fmt.Errorf("failed to walk AST: %w", err)
}
// Generate HTML (basic rendering first)
var htmlBuf bytes.Buffer
if err := md.Renderer().Render(&htmlBuf, remaining, doc); err != nil {
return nil, nil, "", fmt.Errorf("failed to render HTML: %w", err)
}
// Post-process HTML to add data attributes to livemdtools blocks
html := htmlBuf.String()
html = injectBlockAttributes(html, codeBlocks, frontmatter)
// Process status banners (> ✅ message)
html = processStatusBanners(html)
// Process computed expressions (`=expr` code spans)
html, expressions := processExpressions(html)
if len(expressions) > 0 {
frontmatter.Expressions = expressions
}
// Process tabbed headings (## [Tab1] | [Tab2] filter)
html = processTabbedHeadings(html)
// Process chart headings (## Title {chart:bar} followed by table)
html, hasCharts := processCharts(html, frontmatter.Charts)
if hasCharts {
frontmatter.HasCharts = true
}
// Pre-render mermaid blocks to inline SVG when a renderer is registered
// (server enables this via SetMermaidRenderer at startup based on
// features.prerender_diagrams). Re-set HasMermaid based on blocks that
// the renderer COULD NOT handle — only those still need the runtime.
if frontmatter.HasMermaid {
var remainingMermaid int
html, remainingMermaid = processMermaid(html, mermaidRenderer)
frontmatter.HasMermaid = remainingMermaid > 0
}
// Parse schedule tokens and imperatives from markdown content
schedules, scheduleWarnings := parseScheduleTokens(remaining)
if len(schedules) > 0 {
frontmatter.Schedules = schedules
}
if len(scheduleWarnings) > 0 {
frontmatter.ScheduleWarnings = scheduleWarnings
}
// Extract imperative commands (Notify, Run action)
imperatives := parseImperatives(remaining)
if len(imperatives) > 0 {
frontmatter.Imperatives = imperatives
}
return frontmatter, codeBlocks, html, nil
}
// extractFrontmatter extracts YAML frontmatter from the beginning of content.
// Returns the parsed frontmatter and the remaining content.
func extractFrontmatter(content []byte) (*Frontmatter, []byte, error) {
if !bytes.HasPrefix(content, []byte("---\n")) {
// No frontmatter, use defaults
return &Frontmatter{
Type: "tutorial",
Persist: PersistLocalStorage,
}, content, nil
}
// Find the closing ---
endIdx := bytes.Index(content[4:], []byte("\n---\n"))
if endIdx == -1 {
return nil, nil, fmt.Errorf("unclosed frontmatter")
}
yamlContent := content[4 : 4+endIdx]
remaining := content[4+endIdx+5:] // Skip "\n---\n"
var fm Frontmatter
if err := yaml.Unmarshal(yamlContent, &fm); err != nil {
return nil, nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Set defaults
if fm.Type == "" {
fm.Type = "tutorial"
}
if fm.Persist == "" {
fm.Persist = PersistLocalStorage
}
return &fm, remaining, nil
}
// injectBlockAttributes post-processes HTML to wrap livemdtools code blocks with data attributes.
func injectBlockAttributes(html string, blocks []*CodeBlock, fm *Frontmatter) string {
var sources map[string]SourceConfig
if fm != nil {
sources = fm.Sources
}
// For each livemdtools block, find its HTML representation and wrap it
for i, block := range blocks {
// Determine readonly/editable
readonly := containsFlag(block.Flags, "readonly")
editable := containsFlag(block.Flags, "editable")
if !readonly && !editable {
if block.Type == "server" {
readonly = true
} else if block.Type == "wasm" {
editable = true
}
}
// Get block ID (use same logic as page.go getBlockID)
blockID := block.Metadata["id"]
if blockID == "" {
// Auto-generate: type-index (e.g., "server-0", "lvt-1")
// Must match the index used in buildBlocks()
blockID = fmt.Sprintf("%s-%d", block.Type, i)
}
// For embed-lvt blocks, emit a placeholder div carrying the
// upstream coordinates. The server-side fetcher (ProcessEmbedLvt
// in embed_lvt.go, called from servePage) replaces this
// placeholder with the live remote HTML at request time, so
// each visitor gets a fresh upstream session and inherits any
// shared cookies for auth.
if block.Type == "embed-lvt" {
placeholder := fmt.Sprintf(
`<div class="tinkerdown-embed-lvt" data-tinkerdown-block data-block-id="%s" data-block-type="embed-lvt"`,
escapeHTML(blockID),
)
if v, ok := block.Metadata["server"]; ok {
placeholder += fmt.Sprintf(` data-embed-server="%s"`, escapeHTML(v))
}
if v, ok := block.Metadata["path"]; ok {
placeholder += fmt.Sprintf(` data-embed-path="%s"`, escapeHTML(v))
}
if v, ok := block.Metadata["session"]; ok {
placeholder += fmt.Sprintf(` data-embed-session="%s"`, escapeHTML(v))
}
if v, ok := block.Metadata["height"]; ok {
placeholder += fmt.Sprintf(` style="min-height:%s"`, escapeHTML(v))
}
if v, ok := block.Metadata["timeout"]; ok {
placeholder += fmt.Sprintf(` data-embed-timeout="%s"`, escapeHTML(v))
}
if v, ok := block.Metadata["upstream"]; ok {
placeholder += fmt.Sprintf(` data-embed-upstream="%s"`, escapeHTML(v))
}
// Honor show-source / hide-source for embed-lvt the same way
// as for local lvt blocks. ProcessEmbedLvt reads the data
// attribute and pairs the upstream HTML source with the live
// wrapper in a tinkerdown-lvt-demo card.
if effectiveLvtShowSource(block, fm) {
placeholder += ` data-show-source="true"`
}
placeholder += `></div>`
oldPre := fmt.Sprintf(`<pre><code class="language-%s">`, block.Language)
preStart := strings.Index(html, oldPre)
if preStart != -1 {
codeEnd := strings.Index(html[preStart:], "</code></pre>")
if codeEnd != -1 {
before := html[:preStart]
after := html[preStart+codeEnd+len("</code></pre>"):]
html = before + placeholder + after
}
}
continue
}
// For interactive (lvt) blocks, replace with a container div instead of code block
if block.Type == "lvt" {
// Build container div with data attributes
container := fmt.Sprintf(
`<div class="tinkerdown-interactive-block" data-tinkerdown-block data-block-id="%s" data-block-type="lvt" data-language="lvt"`,
escapeHTML(blockID),
)
if stateRef, ok := block.Metadata["state"]; ok {
container += fmt.Sprintf(` data-state-ref="%s"`, escapeHTML(stateRef))
}
// Check if this block has an exec source and add toolbar attributes
if sources != nil {
sourceName := getLvtSourceFromContent(block.Content)
if sourceName != "" {
if srcCfg, ok := sources[sourceName]; ok && srcCfg.Type == "exec" {
container += ` data-exec-source="true"`
container += fmt.Sprintf(` data-exec-command="%s"`, escapeHTML(srcCfg.Cmd))
}
}
}
// Add a placeholder that will be replaced by WebSocket initial state
container += ` data-interactive-content><div class="loading">Connecting...</div></div>`
// Find and replace the <pre><code> block with our container
oldPre := fmt.Sprintf(`<pre><code class="language-%s">`, block.Language)
// Find the closing tags
preStart := strings.Index(html, oldPre)
if preStart != -1 {
// Find the end of this code block
codeEnd := strings.Index(html[preStart:], "</code></pre>")
if codeEnd != -1 {
preEnd := preStart + codeEnd + len("</code></pre>")
before := html[:preStart]
original := html[preStart:preEnd]
after := html[preEnd:]
if effectiveLvtShowSource(block, fm) {
// Render template source as syntax-highlighted code
// AND the live container, paired in a demo wrapper.
// Re-tag the source view as language-html so client
// highlighters give it sensible coloring (lvt isn't
// a known language to most highlighters).
sourceView := strings.Replace(
original,
`<pre><code class="language-lvt">`,
`<pre><code class="language-html">`,
1,
)
html = before +
`<div class="tinkerdown-lvt-demo tinkerdown-lvt-demo-stacked">` +
sourceView +
container +
`</div>` +
after
} else {
html = before + container + after
}
}
}
continue
}
// For server/wasm blocks, wrap the existing <pre><code> with attributes
wrapper := fmt.Sprintf(
`<div data-tinkerdown-block data-block-id="%s" data-block-type="%s" data-language="%s"`,
escapeHTML(blockID),
escapeHTML(block.Type),
escapeHTML(block.Language),
)
if readonly {
wrapper += ` data-readonly="true"`
}
if editable {
wrapper += ` data-editable="true"`
}
wrapper += ">"
// Find <pre><code> blocks and wrap the first match
oldPre := fmt.Sprintf(`<pre><code class="language-%s">`, block.Language)
newPre := wrapper + oldPre
// Only replace the first occurrence (to handle multiple blocks)
html = strings.Replace(html, oldPre, newPre, 1)
// Close the wrapper after </pre>
html = strings.Replace(html, "</pre>", "</pre></div>", 1)
}
return html
}
// getLvtSourceFromContent extracts the lvt-source attribute value from block content.
// Returns empty string if not found.
func getLvtSourceFromContent(content string) string {
// Look for lvt-source="name" on any element
sourceRegex := regexp.MustCompile(`lvt-source="([^"]+)"`)
match := sourceRegex.FindStringSubmatch(content)
if match != nil && len(match) > 1 {
return match[1]
}
return ""
}
// containsFlag checks if a flag is in the flags slice.
func containsFlag(flags []string, flag string) bool {
for _, f := range flags {
if f == flag {
return true
}
}
return false
}
// effectiveLvtShowSource resolves the per-block "show template source"
// decision. Per-block flags win over the page-level default; the page
// default wins over the built-in default of false.
func effectiveLvtShowSource(block *CodeBlock, fm *Frontmatter) bool {
if containsFlag(block.Flags, "show-source") {
return true
}
if containsFlag(block.Flags, "hide-source") {
return false
}
if fm != nil && fm.LvtShowSource != nil {
return *fm.LvtShowSource
}
return false
}
// escapeHTML escapes HTML special characters.
func escapeHTML(s string) string {
return html.EscapeString(s)
}
// exprCodePattern matches inline code spans: <code>...</code>
// We look for <code> tags containing content starting with =
var exprCodePattern = regexp.MustCompile(`<code>([^<]+)</code>`)
// escapedExprPattern matches literal escaped expressions: `\=...`
// These should NOT be treated as expressions
var escapedExprPattern = regexp.MustCompile(`<code>\\=([^<]+)</code>`)
// processExpressions scans HTML for expression code spans (`=expr`) and replaces
// them with span placeholders that will be evaluated at runtime.
// Returns the modified HTML and a map of expression ID to expression string.
func processExpressions(htmlStr string) (string, map[string]string) {
expressions := make(map[string]string)
exprCounter := 0
// First, temporarily protect escaped expressions
// Replace `\=something` with a placeholder
escapedPlaceholders := make(map[string]string)
htmlStr = escapedExprPattern.ReplaceAllStringFunc(htmlStr, func(match string) string {
// Extract the content after \=
submatch := escapedExprPattern.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
placeholder := fmt.Sprintf("__ESCAPED_EXPR_%d__", len(escapedPlaceholders))
// Store the original content (without the backslash)
escapedPlaceholders[placeholder] = fmt.Sprintf("<code>=%s</code>", submatch[1])
return placeholder
})
// Now process actual expressions
htmlStr = exprCodePattern.ReplaceAllStringFunc(htmlStr, func(match string) string {
submatch := exprCodePattern.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
content := submatch[1]
// Check if this is an expression (starts with =)
if !strings.HasPrefix(content, "=") {
return match // Not an expression, leave as-is
}
// Extract the expression (remove leading =)
expr := strings.TrimPrefix(content, "=")
if expr == "" {
return match // Empty expression, leave as-is
}
// Generate unique ID
id := fmt.Sprintf("expr-%d", exprCounter)
exprCounter++
// Store the expression
expressions[id] = expr
// Return a span placeholder that will be filled at runtime
return fmt.Sprintf(
`<span class="tinkerdown-expr" data-expr-id="%s" data-expr="%s"><span class="expr-loading">…</span></span>`,
id,
html.EscapeString(expr),
)
})
// Restore escaped expressions (now shown as literal `=something`)
for placeholder, original := range escapedPlaceholders {
htmlStr = strings.ReplaceAll(htmlStr, placeholder, original)
}
return htmlStr, expressions
}
// StatusType represents the type of status banner
type StatusType string
const (
StatusSuccess StatusType = "success"
StatusWarning StatusType = "warning"
StatusError StatusType = "error"
StatusInfo StatusType = "info"
)
type statusEmojiEntry struct {
emoji string
statusType StatusType
}
var statusEmojiOrder = []statusEmojiEntry{
{"⚠️", StatusWarning},
{"ℹ️", StatusInfo},
{"✅", StatusSuccess},
{"❌", StatusError},
{"📊", StatusInfo},
{"🟢", StatusSuccess},
{"🟡", StatusWarning},
{"🔴", StatusError},
{"⚠", StatusWarning},
{"ℹ", StatusInfo},
}
var statusRoleMap = map[StatusType]string{
StatusSuccess: "status",
StatusWarning: "alert",
StatusError: "alert",
StatusInfo: "status",
}
var blockquotePattern = regexp.MustCompile(`(?s)<blockquote>\s*<p>([^<]*(?:<[^>]+>[^<]*)*)</p>\s*</blockquote>`)
func processStatusBanners(htmlStr string) string {
return blockquotePattern.ReplaceAllStringFunc(htmlStr, func(match string) string {
submatch := blockquotePattern.FindStringSubmatch(match)
if len(submatch) < 2 {
return match
}
content := submatch[1]
for _, entry := range statusEmojiOrder {
if strings.HasPrefix(content, entry.emoji) {
msg := strings.TrimPrefix(content, entry.emoji)
msg = strings.TrimLeft(msg, " ")
role := statusRoleMap[entry.statusType]
return fmt.Sprintf(
`<div class="tinkerdown-status-banner tinkerdown-status-%s" role="%s"><span class="status-icon" aria-hidden="true">%s</span><span class="status-content">%s</span></div>`,
entry.statusType, role, entry.emoji, msg,
)
}
}
return match
})
}
// Tab represents a single tab definition parsed from a heading.
type Tab struct {
Name string // Display name of the tab
Filter string // Filter expression (empty for "All" tab)
}
// tabbedHeadingPattern matches headings with tab syntax: ## [Tab1] | [Tab2] filter
// Captures: 1=tag name, 2=attributes, 3=heading content
// Note: Go's RE2 doesn't support backreferences, so we match any closing h1-h6 tag
var tabbedHeadingPattern = regexp.MustCompile(`<(h[1-6])([^>]*)>([^<]*\[[^\]]+\][^<]*)</h[1-6]>`)
// tabPattern matches individual tab definitions: [TabName] optional filter
var tabPattern = regexp.MustCompile(`\[([^\]]+)\](\s+[^|]*)?`)
// headingIDPattern extracts the id attribute from a heading element
var headingIDPattern = regexp.MustCompile(`id="([^"]+)"`)
// processTabbedHeadings transforms headings with tab syntax into interactive tab bars.
// Syntax: ## [All] | [Active] not done | [Done] done
// Each [Name] creates a tab button, and text after it becomes the filter expression.
func processTabbedHeadings(htmlStr string) string {
tabsCounter := 0
return tabbedHeadingPattern.ReplaceAllStringFunc(htmlStr, func(match string) string {
submatch := tabbedHeadingPattern.FindStringSubmatch(match)
if len(submatch) < 4 {
return match
}
tagName := submatch[1]
attrs := submatch[2]
content := submatch[3]
// Check if the content contains tab syntax (at least one [Name])
if !strings.Contains(content, "[") || !strings.Contains(content, "]") {
return match
}
// Parse tabs from the content
tabs := parseTabsFromContent(content)
if len(tabs) == 0 {
return match // No valid tabs found
}
// Generate a unique ID for this tab group
tabsID := fmt.Sprintf("tabs-%d", tabsCounter)
tabsCounter++
// Extract existing id from heading if present
if idMatch := headingIDPattern.FindStringSubmatch(attrs); len(idMatch) > 1 {
tabsID = idMatch[1] + "-tabs"
}
// Build the tab bar HTML
var builder strings.Builder
builder.WriteString(fmt.Sprintf(`<div class="tinkerdown-tabs" data-tabs-id="%s">`, escapeHTML(tabsID)))
builder.WriteString("\n")
// Generate the tab bar (styled like the original heading)
builder.WriteString(fmt.Sprintf(`<%s%s class="tinkerdown-tabs-heading">`, tagName, attrs))
builder.WriteString(`<span class="tinkerdown-tabs-bar" role="tablist">`)
for i, tab := range tabs {
activeClass := ""
ariaSelected := "false"
tabIndex := "-1"
if i == 0 {
activeClass = " active"
ariaSelected = "true"
tabIndex = "0"
}
filter := escapeHTML(tab.Filter)
tabID := fmt.Sprintf("%s-tab-%d", tabsID, i)
panelID := fmt.Sprintf("%s-panel", tabsID)
builder.WriteString(fmt.Sprintf(
`<button type="button" role="tab" class="tinkerdown-tab%s" data-tab-index="%d" data-filter="%s" id="%s" aria-selected="%s" aria-controls="%s" tabindex="%s">%s</button>`,
activeClass,
i,
filter,
tabID,
ariaSelected,
panelID,
tabIndex,
escapeHTML(tab.Name),
))
}
builder.WriteString(`</span>`)
builder.WriteString(fmt.Sprintf(`</%s>`, tagName))
builder.WriteString("\n")
// Add a marker for the content wrapper (client-side will handle the wrapping)
builder.WriteString(fmt.Sprintf(`<div class="tinkerdown-tabs-content" id="%s-panel" role="tabpanel" aria-labelledby="%s-tab-0" data-tabs-content>`,
tabsID, tabsID))
builder.WriteString("</div>")
builder.WriteString("</div>")
return builder.String()
})
}
// parseTabsFromContent extracts tab definitions from heading content.
// Input: "[All] | [Active] not done | [Done] done"
// Returns: [{Name: "All", Filter: ""}, {Name: "Active", Filter: "not done"}, ...]
func parseTabsFromContent(content string) []Tab {
var tabs []Tab
// Split by pipe to get individual tab definitions
parts := strings.Split(content, "|")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Match [TabName] optional filter
matches := tabPattern.FindStringSubmatch(part)
if len(matches) < 2 {
continue
}
name := strings.TrimSpace(matches[1])
filter := ""
if len(matches) > 2 {
filter = strings.TrimSpace(matches[2])
}
tabs = append(tabs, Tab{
Name: name,
Filter: filter,
})
}
return tabs
}
// htmlTagPattern strips HTML tags from text (used to clean heading text and table headers).
var htmlTagPattern = regexp.MustCompile(`<[^>]*>`)
// chartAnnotationPattern matches headings with {chart:type} or {chart} at the end.
// Goldmark renders: <h2 id="sales-by-region-chartbar">Sales by Region {chart:bar}</h2>
var chartAnnotationPattern = regexp.MustCompile(
`(<h([1-6])\s+id="[^"]*"[^>]*>)(.*?)\s*\{chart(?::(\w+))?\}\s*(</h[1-6]>)`,
)
// mermaidBlockPattern matches goldmark-rendered ```mermaid fenced blocks:
//
// <pre><code class="language-mermaid">…source…</code></pre>
//
// The `(?s)` flag lets `.` cross newlines so multi-line diagram source matches.
// Capture group 1 is the diagram source (HTML-escaped — caller must unescape
// before sending to a renderer).
var mermaidBlockPattern = regexp.MustCompile(
`(?s)<pre><code class="language-mermaid">(.*?)</code></pre>`,
)
// mermaidRenderer is the package-level diagram renderer used by parsing
// when set. Servers initialize this once at startup based on config; nil
// means "no pre-rendering, leave mermaid blocks for the client runtime".
//
// Made package-level (rather than threaded through every parse function)
// to avoid breaking the existing ParseMarkdown / ParseFile API surface.
var mermaidRenderer DiagramRenderer
// DiagramRenderer renders diagram source to bytes (typically inline SVG).
// The interface deliberately mirrors internal/diagrams.Renderer so the
// concrete implementation can live in that subpackage without forcing
// callers of tinkerdown to import it.
type DiagramRenderer interface {
Render(source []byte) ([]byte, error)
}
// SetMermaidRenderer registers a renderer that will be used to pre-render
// ```mermaid fenced blocks during ParseMarkdown. Pass nil to disable.
// Safe to call once during server initialization.
func SetMermaidRenderer(r DiagramRenderer) {
mermaidRenderer = r
}
// processMermaid replaces ```mermaid fenced blocks with inline SVG when a
// renderer is available. Blocks that fail to render are left intact so the
// client-side runtime can handle them — this is the graceful-degradation
// path that keeps pages working when Kroki is unreachable.
//
// Returns the modified HTML and the number of blocks that were NOT
// pre-rendered (i.e. still need the client-side runtime). Callers use the
// remaining count to decide whether to inject the mermaid runtime script.
func processMermaid(htmlStr string, renderer DiagramRenderer) (string, int) {
matches := mermaidBlockPattern.FindAllStringSubmatchIndex(htmlStr, -1)
if len(matches) == 0 {
return htmlStr, 0
}
if renderer == nil {
return htmlStr, len(matches)
}
remaining := 0
// Walk in reverse so indices stay valid as we splice replacements in.
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
blockStart, blockEnd := m[0], m[1]
sourceStart, sourceEnd := m[2], m[3]
// Goldmark HTML-escapes the code-block contents (e.g. `>` → `>`).
// Kroki expects the original mermaid syntax, so unescape first.
source := html.UnescapeString(htmlStr[sourceStart:sourceEnd])
svg, err := renderer.Render([]byte(source))
if err != nil {
// Keep the block — client runtime will render it.
remaining++
continue
}
// Wrap in a div with BOTH classes: `mermaid` (the canonical
// container class used by the client-side runtime, so existing
// CSS / e2e selectors keep matching) and `mermaid-prerendered`
// (so themes that need to distinguish pre-rendered from
// runtime-rendered diagrams still can).
replacement := `<div class="mermaid mermaid-prerendered">` + string(svg) + `</div>`
htmlStr = htmlStr[:blockStart] + replacement + htmlStr[blockEnd:]
}
return htmlStr, remaining
}
// chartTablePattern matches a GFM-rendered table immediately after a heading.
// Uses <table[^>]*> to tolerate attributes Goldmark may add via extensions.
var chartTablePattern = regexp.MustCompile(
`(?s)<table[^>]*>\n<thead>\n<tr>\n((?:<th>.*?</th>\n)+)</tr>\n</thead>\n<tbody>\n((?:<tr>\n(?:<td>.*?</td>\n)+</tr>\n)+)</tbody>\n</table>\n`,
)
// Pre-compiled regexes for table parsing (avoid recompilation per call).
var (
chartThPattern = regexp.MustCompile(`<th>(.*?)</th>`)
chartTrPattern = regexp.MustCompile(`(?s)<tr>\n((?:<td>.*?</td>\n)+)</tr>`)
chartTdPattern = regexp.MustCompile(`<td>(.*?)</td>`)
)
var validChartTypes = map[string]bool{
"bar": true, "line": true, "pie": true, "doughnut": true, "auto": true, "": true,
}
// ChartOptions holds per-chart customization from frontmatter.
type ChartOptions struct {
Colors []string `yaml:"colors,omitempty" json:"colors,omitempty"`
Stacked bool `yaml:"stacked,omitempty" json:"stacked,omitempty"`
Horizontal bool `yaml:"horizontal,omitempty" json:"horizontal,omitempty"`
Legend *bool `yaml:"legend,omitempty" json:"legend,omitempty"`
}
// chartData is the JSON structure passed to Chart.js via data attributes.
type chartData struct {
Labels []string `json:"labels"`
Datasets []chartDataset `json:"datasets"`
}
type chartDataset struct {
Label string `json:"label"`
Data []float64 `json:"data"`
}
// processCharts detects {chart:type} headings followed by tables and transforms
// them into chart container elements with JSON data attributes for Chart.js.
// chartOpts provides per-chart customization from frontmatter (keyed by heading slug).
// Returns the modified HTML and whether any charts were found.
func processCharts(htmlStr string, chartOpts map[string]ChartOptions) (string, bool) {
found := false
// Find all chart-annotated headings
matches := chartAnnotationPattern.FindAllStringSubmatchIndex(htmlStr, -1)
if len(matches) == 0 {
return htmlStr, false
}
// Process matches in reverse order to preserve indices
for i := len(matches) - 1; i >= 0; i-- {
m := matches[i]
fullMatchStart := m[0]
fullMatchEnd := m[1]
// Extract heading parts
headingText := htmlStr[m[6]:m[7]] // "Sales by Region"
chartType := ""
if m[8] >= 0 {
chartType = htmlStr[m[8]:m[9]] // "bar"
}
headingClose := htmlStr[m[10]:m[11]] // </h2>
// Validate chart type
if !validChartTypes[chartType] {
continue
}