Skip to content
Open
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
63 changes: 54 additions & 9 deletions internal/preview/refresh/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"math"
"path"
"path/filepath"
"regexp"
"strings"

"bino.bi/bino/internal/httpserver"
Expand All @@ -23,6 +24,27 @@ type previewArtefactInfo struct {
Title string `json:"title"`
Format string `json:"format"`
IsDoc bool `json:"isDoc"` // true for DocumentArtefact
// Document-only fields feeding the toolbar's doc meta strip.
Orientation string `json:"orientation,omitempty"`
Locale string `json:"locale,omitempty"`
Chapters int `json:"chapters,omitempty"`
TOC bool `json:"toc,omitempty"`
HeaderFooter bool `json:"headerFooter,omitempty"`
}

// docArtefactInfo maps a DocumentArtefact to its dropdown/meta-strip payload.
func docArtefactInfo(docArt config.DocumentArtefact) previewArtefactInfo {
return previewArtefactInfo{
Name: docArt.Document.Name,
Title: docArt.Spec.Title,
Format: docArt.Spec.Format,
IsDoc: true,
Orientation: docArt.Spec.Orientation,
Locale: docArt.Spec.Locale,
Chapters: docSourceCount(docArt),
TOC: docArt.Spec.TableOfContents,
HeaderFooter: docArt.Spec.DisplayHeaderFooter,
}
}

// previewDocumentInfo holds metadata about a manifest document for the assets modal.
Expand Down Expand Up @@ -292,15 +314,38 @@ func withPreviewStyles(doc []byte) []byte {
return updated
}

// withDocumentPageWidth injects a CSS custom property with the page width
// derived from the document's format and orientation so the preview can
// size the page container accordingly. The property is set as an inline
// style on the <bn-context> element (not in <head>) so it survives — and
// updates through — the attribute sync performed by swapContext on SSE
// content morphs.
func withDocumentPageWidth(doc []byte, format, orientation string) []byte {
width := documentPageWidth(format, orientation)
attr := fmt.Appendf(nil, ` style="--bn-doc-page-width:%s"`, width)
// cssLengthPattern accepts the margin lengths the preview mirrors into CSS
// custom properties. Anything else falls back to the Chrome print defaults —
// an unvalidated value would poison the whole var() declaration.
var cssLengthPattern = regexp.MustCompile(`^\d+(\.\d+)?(mm|cm|in|px)$`)

// withDocumentPreviewMeta injects page-geometry CSS custom properties derived
// from the DocumentArtefact spec as an inline style on the <bn-context>
// element (not in <head>) so they survive — and update through — the
// attribute sync performed by swapContext on SSE content morphs. When the
// built PDF will render a header/footer, the element is also marked with
// data-bino-doc-hf so preview.css can show placeholder bands sized by the
// margin properties. Margin defaults mirror internal/chrome/render.go
// (20mm top / 15mm bottom).
func withDocumentPreviewMeta(doc []byte, docSpec config.DocumentArtefactSpec) []byte {
width := documentPageWidth(docSpec.Format, docSpec.Orientation)

var attr []byte
if docSpec.DisplayHeaderFooter {
marginTop := "20mm"
if cssLengthPattern.MatchString(docSpec.MarginTop) {
marginTop = docSpec.MarginTop
}
marginBottom := "15mm"
if cssLengthPattern.MatchString(docSpec.MarginBottom) {
marginBottom = docSpec.MarginBottom
}
attr = fmt.Appendf(nil, ` style="--bn-doc-page-width:%s;--bn-doc-margin-top:%s;--bn-doc-margin-bottom:%s" data-bino-doc-hf='true'`,
width, marginTop, marginBottom)
} else {
attr = fmt.Appendf(nil, ` style="--bn-doc-page-width:%s"`, width)
}

openTag := []byte("<bn-context")
idx := bytes.Index(doc, openTag)
if idx == -1 {
Expand Down
45 changes: 45 additions & 0 deletions internal/preview/refresh/html_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package refresh

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -36,6 +37,50 @@ func TestDocSourceCount(t *testing.T) {
}
}

// TestDocArtefactInfo asserts the doc meta fields reach the toolbar JSON
// payload — and that report artefacts (zero values) omit them entirely.
func TestDocArtefactInfo(t *testing.T) {
t.Parallel()

dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("# H\n"), 0o600); err != nil {
t.Fatalf("write markdown: %v", err)
}
docArt := docArtefactFixture("The Handbook", dir, "notes.md")
docArt.Spec.Orientation = "portrait"
docArt.Spec.Locale = "en"
docArt.Spec.TableOfContents = true
docArt.Spec.DisplayHeaderFooter = true

payload, err := json.Marshal(docArtefactInfo(docArt))
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(payload)
for _, want := range []string{
`"isDoc":true`,
`"orientation":"portrait"`,
`"locale":"en"`,
`"chapters":1`,
`"toc":true`,
`"headerFooter":true`,
} {
if !strings.Contains(got, want) {
t.Errorf("payload missing %s: %s", want, got)
}
}

report, err := json.Marshal(previewArtefactInfo{Name: "r", Title: "R", Format: "a4"})
if err != nil {
t.Fatalf("marshal report info: %v", err)
}
for _, ban := range []string{"orientation", "locale", "chapters", "toc", "headerFooter"} {
if strings.Contains(string(report), ban) {
t.Errorf("report artefact payload must omit %s: %s", ban, report)
}
}
}

func TestWithAllPagesDocuments(t *testing.T) {
t.Parallel()

Expand Down
9 changes: 2 additions & 7 deletions internal/preview/refresh/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,7 @@ func Run(ctx context.Context, reason string, changed []string, server *httpserve
})
}
for _, docArt := range documentArtefacts {
artefactInfos = append(artefactInfos, previewArtefactInfo{
Name: docArt.Document.Name,
Title: docArt.Spec.Title,
Format: docArt.Spec.Format,
IsDoc: true,
})
artefactInfos = append(artefactInfos, docArtefactInfo(docArt))
}

documentInfos := make([]previewDocumentInfo, 0, len(docs))
Expand Down Expand Up @@ -547,7 +542,7 @@ func Run(ctx context.Context, reason string, changed []string, server *httpserve
docGraph = buildPreviewGraphData(g, rootNode)
}
}
styledHTML := withPreviewStyles(withDocumentPageWidth(renderResult.HTML, docArt.Spec.Format, docArt.Spec.Orientation))
styledHTML := withPreviewStyles(withDocumentPreviewMeta(renderResult.HTML, docArt.Spec))
frameHTML := withPreviewHeader(styledHTML, artefactInfos, documentInfos, docPath, docGraph)
routeMap[docPath] = httpserver.StaticContent(append([]byte(nil), frameHTML...), "text/html; charset=utf-8")
// Broadcast like the report loop does: swapContext extracts the
Expand Down
80 changes: 66 additions & 14 deletions internal/preview/refresh/refresh_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"bino.bi/bino/internal/httpserver"
"bino.bi/bino/internal/logx"
"bino.bi/bino/internal/report/config"
)

const documentArtefactManifest = `apiVersion: bino.bi/v1alpha1
Expand Down Expand Up @@ -158,38 +159,89 @@ func TestRunSelectiveMarkdownEdit(t *testing.T) {
}
}

// TestWithDocumentPageWidth covers the format/orientation width mapping and
// the bn-context style-attribute injection.
func TestWithDocumentPageWidth(t *testing.T) {
// TestWithDocumentPreviewMeta covers the format/orientation width mapping,
// the header/footer marker with validated margins, and the bn-context
// style-attribute injection (attributes morph-sync, head styles would not).
func TestWithDocumentPreviewMeta(t *testing.T) {
t.Parallel()

const page = `<html><head></head><body><bn-context locale='de'>x</bn-context></body></html>`
tests := []struct {
name string
doc string
format string
orientation string
want string
spec config.DocumentArtefactSpec
want []string
notContains []string
}{
{"a4 portrait", page, "a4", "portrait", `<bn-context style="--bn-doc-page-width:210mm" locale='de'>`},
{"a4 landscape", page, "a4", "landscape", `<bn-context style="--bn-doc-page-width:297mm" locale='de'>`},
{"letter portrait", page, "letter", "portrait", `<bn-context style="--bn-doc-page-width:215.9mm" locale='de'>`},
{"unknown format falls back to a4", page, "tabloid", "", `<bn-context style="--bn-doc-page-width:210mm" locale='de'>`},
{
name: "a4 portrait",
doc: page,
spec: config.DocumentArtefactSpec{Format: "a4", Orientation: "portrait"},
want: []string{`<bn-context style="--bn-doc-page-width:210mm" locale='de'>`},
notContains: []string{
"data-bino-doc-hf",
"--bn-doc-margin-top",
},
},
{
name: "a4 landscape",
doc: page,
spec: config.DocumentArtefactSpec{Format: "a4", Orientation: "landscape"},
want: []string{`<bn-context style="--bn-doc-page-width:297mm" locale='de'>`},
},
{
name: "letter portrait",
doc: page,
spec: config.DocumentArtefactSpec{Format: "letter"},
want: []string{`--bn-doc-page-width:215.9mm`},
},
{
name: "unknown format falls back to a4",
doc: page,
spec: config.DocumentArtefactSpec{Format: "tabloid"},
want: []string{`--bn-doc-page-width:210mm`},
},
{
name: "header footer marks the context and mirrors margins",
doc: page,
spec: config.DocumentArtefactSpec{Format: "a4", DisplayHeaderFooter: true, MarginTop: "30mm"},
want: []string{
`data-bino-doc-hf='true'`,
`--bn-doc-margin-top:30mm`,
`--bn-doc-margin-bottom:15mm`, // Chrome default when unset
},
},
{
name: "invalid margin falls back to the Chrome default",
doc: page,
spec: config.DocumentArtefactSpec{Format: "a4", DisplayHeaderFooter: true, MarginTop: "30mm; }injection"},
want: []string{`--bn-doc-margin-top:20mm`},
notContains: []string{
"injection",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := string(withDocumentPageWidth([]byte(tt.doc), tt.format, tt.orientation))
if !strings.Contains(got, tt.want) {
t.Errorf("withDocumentPageWidth(%q, %q) = %q, want substring %q", tt.format, tt.orientation, got, tt.want)
got := string(withDocumentPreviewMeta([]byte(tt.doc), tt.spec))
for _, want := range tt.want {
if !strings.Contains(got, want) {
t.Errorf("output = %q, want substring %q", got, want)
}
}
for _, ban := range tt.notContains {
if strings.Contains(got, ban) {
t.Errorf("output must not contain %q:\n%s", ban, got)
}
}
})
}

t.Run("no bn-context returns input unchanged", func(t *testing.T) {
t.Parallel()
in := `<html><head></head><body>plain</body></html>`
if got := string(withDocumentPageWidth([]byte(in), "a4", "portrait")); got != in {
if got := string(withDocumentPreviewMeta([]byte(in), config.DocumentArtefactSpec{Format: "a4"})); got != in {
t.Errorf("expected unchanged output, got %q", got)
}
})
Expand Down
25 changes: 24 additions & 1 deletion internal/web/preview/components/bino-search.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LitElement, html, css } from 'lit';
import { viewPath } from '../../shared/dom-utils.js';

class BinoSearch extends LitElement {
static properties = {
Expand Down Expand Up @@ -150,10 +151,11 @@ class BinoSearch extends LitElement {

render() {
var self = this;
var placeholder = viewPath().startsWith('/doc/') ? 'Search headings...' : 'Search elements...';
return html`
<div class="search-wrap">
<span class="search-icon">\u2315</span>
<input type="text" placeholder="Search elements..." autocomplete="off" spellcheck="false"
<input type="text" placeholder=${placeholder} autocomplete="off" spellcheck="false"
@input=${this._onInput}
@keydown=${this._onKeydown}
@focus=${this._onFocus}>
Expand Down Expand Up @@ -283,6 +285,27 @@ class BinoSearch extends LitElement {
}
});

// Search document headings (doc routes; the selector matches nothing on
// report routes). Headings carry auto-generated anchor ids.
var headings = document.querySelectorAll('.bn-document-content h1, .bn-document-content h2, .bn-document-content h3, .bn-document-content h4, .bn-document-content h5, .bn-document-content h6');
headings.forEach(function(el) {
var text = (el.textContent || '').trim();
if (!text) return;
var key = 'heading:' + (el.id || text);

if (seen.has(key)) return;

if (text.toLowerCase().indexOf(lowerQuery) !== -1) {
seen.add(key);
results.push({
type: 'heading',
kind: el.tagName.toLowerCase() + ' heading',
name: text,
el: el
});
}
});

// Search text content within layout pages
if (results.length < 50) {
pages.forEach(function(pageEl) {
Expand Down
Loading