Point 7 of the page-wording review. The pages no longer show their data structure as headings, and they no longer explain their own conventions. - Level 1 is one screen: claim, status badge, three figures, the technology row, and one sentence carrying one link. Measured in headless Chrome at 1440x900: 1404-1845 px tall before, exactly 900 px (no scroll) after, on all six area pages. - DocPage: no kicker line, no visible level label, no "Evidence for this page" heading or instruction note, no "Drill-downs" list, no "Status <word> - sentence" footer. The artefact table starts at level 2, under the heading "Artefacts"; the history timeline is level 2 only. - Landing page: "Start here" link text gone, "How to read this site" panel gone, the footer's status-label explainer gone. - Content, presentation only, no claim softened: the leading H1 that duplicated the page title removed from all 41 documents; level 1 bodies reduced to the constraint as ordinary prose plus the way in; "**Status: ...**" prefixes removed from the artefact stubs; "## Drill-downs" and "## Period and confidence" removed from the level 2 pages, with the cross-area links moved into the prose. - The six level 1 claims are now sentences with a subject, and use commas instead of middots. Same facts, no new ones. Not in this commit: the 35 level 2 and 3 claims still use the compact CV style. That is a voice decision for Sam, not a wording tidy-up. make validate: 41 documents, 0 violations. go test ./...: pass. (cherry picked from commit 03009f80fc7815ad7719412e125df9297a6e653f)
201 lines
5.4 KiB
Go
201 lines
5.4 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
|
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
|
)
|
|
|
|
// buildFragments renders every stage fragment once, in manifest order:
|
|
//
|
|
// 1. every document's own fragment, L1 first in area order
|
|
// 2. every artefact fragment, in the same document order
|
|
//
|
|
// The order matters: the first len(content.Areas) entries are the six area
|
|
// landing fragments, which is the default stream, and the server reads that
|
|
// order out of the manifest without loading the content tree. The panel is a
|
|
// content sink, so this is the only place that decides what a fragment is.
|
|
// Neither driver renders anything itself.
|
|
func (s *Site) buildFragments() error {
|
|
order := make([]*content.Document, 0, len(s.Content.Docs))
|
|
for _, a := range content.Areas {
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
if d.Level == 1 {
|
|
order = append(order, d)
|
|
}
|
|
}
|
|
}
|
|
rest := make([]*content.Document, 0, len(s.Content.Docs))
|
|
for _, a := range content.Areas {
|
|
for _, d := range s.PublicInArea(a.Key) {
|
|
if d.Level != 1 {
|
|
rest = append(rest, d)
|
|
}
|
|
}
|
|
}
|
|
order = append(order, rest...)
|
|
|
|
for _, d := range order {
|
|
if err := s.addFragment(s.docFragment(d)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, d := range order {
|
|
for i, a := range d.Artefacts {
|
|
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.addFragment(f); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Site) addFragment(f *Fragment) error {
|
|
if _, dup := s.fragByID[f.ID]; dup {
|
|
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
|
|
}
|
|
html, err := renderFragment(f.component())
|
|
if err != nil {
|
|
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
|
|
}
|
|
f.HTML = html
|
|
s.Fragments = append(s.Fragments, f)
|
|
s.fragByID[f.ID] = f
|
|
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
|
|
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
|
|
ID: f.ID,
|
|
DocID: f.DocID,
|
|
Title: f.Title,
|
|
Kind: f.Kind,
|
|
File: f.File,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// fragment builds the stage value this fragment renders from.
|
|
func (f *Fragment) component() stage.Fragment {
|
|
out := stage.Fragment{
|
|
ID: f.ID,
|
|
Kind: f.Kind,
|
|
Title: f.Title,
|
|
}
|
|
out.Subtitle = f.subtitle
|
|
out.Body = f.body
|
|
out.Code = f.code
|
|
out.Src = f.src
|
|
out.Alt = f.alt
|
|
out.Links = f.links
|
|
out.Confidence = f.confidence
|
|
out.DocURL = f.docURL
|
|
out.DocTitle = f.docTitle
|
|
return out
|
|
}
|
|
|
|
func renderFragment(f stage.Fragment) (string, error) {
|
|
var buf bytes.Buffer
|
|
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// docFragment is the document's own body, shown in the panel when a reader
|
|
// asks for the page itself rather than one of its artefacts.
|
|
func (s *Site) docFragment(d *content.Document) *Fragment {
|
|
area, _ := content.AreaByKey(d.Area)
|
|
return &Fragment{
|
|
ID: stage.DocFragmentID(d.ID),
|
|
DocID: d.ID,
|
|
Kind: stage.KindText,
|
|
Title: d.Title,
|
|
subtitle: area.Title,
|
|
body: templ.Raw(d.HTML),
|
|
confidence: d.Confidence,
|
|
docURL: d.URL,
|
|
docTitle: "Read the page",
|
|
File: "stage/" + stage.DocFragmentID(d.ID) + ".html",
|
|
}
|
|
}
|
|
|
|
// artefactFragment renders one artefact into the kind the panel understands.
|
|
func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) {
|
|
ra, err := s.Content.Resolve(d, a)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := &Fragment{
|
|
ID: id,
|
|
DocID: d.ID,
|
|
Kind: ra.Kind,
|
|
Title: a.Label,
|
|
ArtefactURL: a.URL,
|
|
confidence: d.Confidence,
|
|
File: "stage/" + id + ".html",
|
|
}
|
|
if ra.Target != nil {
|
|
f.subtitle = ra.Target.Title
|
|
f.body = templ.Raw(ra.Target.HTML)
|
|
f.confidence = ra.Target.Confidence
|
|
f.docURL = ra.Target.URL
|
|
f.docTitle = "Open " + ra.Target.Title
|
|
return f, nil
|
|
}
|
|
f.subtitle = ra.Rel
|
|
switch ra.Kind {
|
|
case stage.KindCode:
|
|
src, err := os.ReadFile(ra.File)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
lang := content.LanguageFor(ra.File)
|
|
if err := s.Renderer.Highlight(&buf, lang, src); err != nil {
|
|
return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err)
|
|
}
|
|
f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())}
|
|
case stage.KindImage, stage.KindDiagram, stage.KindVideo:
|
|
f.src = ra.Href
|
|
f.alt = a.Label
|
|
default:
|
|
f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}}
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// fragForEntry looks up a build-time fragment by manifest entry.
|
|
func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) {
|
|
f, ok := s.fragByID[e.ID]
|
|
return f, ok
|
|
}
|
|
|
|
// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers
|
|
// serve these bytes: the route driver as a file, the stream driver as an event.
|
|
func (s *Site) FragmentHTML(id string) (string, bool) {
|
|
f, ok := s.fragByID[id]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return f.HTML, true
|
|
}
|
|
|
|
// FragmentsByID is a copy of the fragment index, for tests and the manifest.
|
|
func (s *Site) FragmentsByID() map[string]*Fragment {
|
|
out := make(map[string]*Fragment, len(s.fragByID))
|
|
for k, v := range s.fragByID {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|