Minumum Working Example

This commit is contained in:
Simon Martens
2026-02-20 14:53:05 +01:00
parent 8f5338c0b8
commit 5a00333266
8 changed files with 1169 additions and 6 deletions

328
server/brief.go Normal file
View File

@@ -0,0 +1,328 @@
package server
import (
"bytes"
"html"
"html/template"
"net/http"
"slices"
"strconv"
"strings"
"github.com/Theodor-Springmann-Stiftung/lenz-web/app"
"github.com/Theodor-Springmann-Stiftung/lenz-web/xmlmodels"
"github.com/labstack/echo/v5"
)
type briefPageModel struct {
Number int
Prev int
Next int
Pages []briefRenderPage
}
type briefRenderPage struct {
Number int
Lines []briefRenderLine
LeftNotes []briefRenderSidenote
RightNotes []briefRenderSidenote
TopNotes []briefRenderSidenote
BottomNotes []briefRenderSidenote
}
type briefRenderSidenote struct {
Position string
PosClass string
Annotation string
Lines []briefRenderLine
}
type briefRenderLine struct {
Type string
AlignCtx bool
TabCtx bool
ClassName string
Text string
HTML template.HTML
}
func (s *Server) Brief(c *echo.Context) error {
num, err := strconv.Atoi(c.Param("number"))
if err != nil || num <= 0 {
return c.String(http.StatusBadRequest, "invalid brief number")
}
letter := s.app.Library().Letters.Item(num)
if letter == nil {
return c.String(http.StatusNotFound, "brief not found")
}
model := renderBrief(*letter)
model.Prev, model.Next = briefNeighbors(s.app, num)
var out bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&out, "brief", model); err != nil {
return c.String(http.StatusInternalServerError, "template render failed: "+err.Error())
}
return c.HTML(http.StatusOK, out.String())
}
func briefNeighbors(a *app.App, current int) (prev int, next int) {
lib := a.Library()
nums := make([]int, 0, lib.Letters.Count())
for letter := range lib.Letters.Iterate() {
nums = append(nums, letter.Letter)
}
if len(nums) == 0 {
return 0, 0
}
slices.Sort(nums)
i := slices.Index(nums, current)
if i < 0 {
return 0, 0
}
if i > 0 {
prev = nums[i-1]
}
if i+1 < len(nums) {
next = nums[i+1]
}
return prev, next
}
func renderBrief(letter xmlmodels.Letter) briefPageModel {
model := briefPageModel{
Number: letter.Letter,
Pages: make([]briefRenderPage, 0, len(letter.Data)),
}
for _, p := range letter.Data {
rp := briefRenderPage{
Number: p.Number,
Lines: renderLines(p.Lines),
LeftNotes: make([]briefRenderSidenote, 0, len(p.Sidenotes)),
RightNotes: make([]briefRenderSidenote, 0, len(p.Sidenotes)),
TopNotes: make([]briefRenderSidenote, 0, len(p.Sidenotes)),
BottomNotes: make([]briefRenderSidenote, 0, len(p.Sidenotes)),
}
for _, sn := range p.Sidenotes {
rs := briefRenderSidenote{
Position: sn.Position,
PosClass: sidenotePosClass(sn.Position),
Annotation: sn.Annotation,
Lines: renderLines(sn.Lines),
}
switch noteRegion(sn.Position) {
case "left":
rp.LeftNotes = append(rp.LeftNotes, rs)
case "right":
rp.RightNotes = append(rp.RightNotes, rs)
case "top":
rp.TopNotes = append(rp.TopNotes, rs)
case "bottom":
rp.BottomNotes = append(rp.BottomNotes, rs)
default:
rp.RightNotes = append(rp.RightNotes, rs)
}
}
model.Pages = append(model.Pages, rp)
}
return model
}
func noteRegion(position string) string {
p := strings.ToLower(strings.TrimSpace(position))
switch {
case strings.Contains(p, "top"):
return "top"
case strings.Contains(p, "bottom"):
return "bottom"
case strings.Contains(p, "left"):
return "left"
case strings.Contains(p, "right"):
return "right"
default:
return "right"
}
}
func sidenotePosClass(position string) string {
p := strings.ToLower(strings.TrimSpace(position))
p = strings.ReplaceAll(p, " ", "-")
if p == "" {
return "pos-right"
}
return "pos-" + p
}
func renderLines(lines []xmlmodels.Line) []briefRenderLine {
out := make([]briefRenderLine, 0, len(lines))
for _, line := range lines {
r := briefRenderLine{
Type: lineTypeClass(line.Type),
AlignCtx: line.AlignCtx,
TabCtx: line.TabCtx,
ClassName: "line line-" + lineTypeClass(line.Type),
Text: line.Text,
}
r.ClassName += " " + indentClass(line.Indent)
if line.AlignCtx {
r.ClassName += " line-align-ctx"
}
if line.TabCtx {
r.ClassName += " line-tab-ctx"
}
if line.Type != xmlmodels.Empty {
r.HTML = template.HTML(renderTokens(line.Tokens))
}
out = append(out, r)
}
return out
}
func indentClass(indent int) string {
if indent <= 0 {
return "line-indent-0"
}
if indent > 12 {
indent = 12
}
return "line-indent-" + strconv.Itoa(indent)
}
func lineTypeClass(lt xmlmodels.LineType) string {
switch lt {
case xmlmodels.First:
return "first"
case xmlmodels.Continuation:
return "continuation"
case xmlmodels.Semantic:
return "semantic"
case xmlmodels.Indent:
return "indent"
case xmlmodels.Empty:
return "empty"
default:
return "semantic"
}
}
type openToken struct {
name string
attrs map[string]string
}
func renderTokens(tokens []xmlmodels.Token) string {
var b strings.Builder
var stack []openToken
for _, tok := range tokens {
switch tok.Type {
case xmlmodels.CharData:
b.WriteString(html.EscapeString(tok.Value))
case xmlmodels.StartElement:
b.WriteString(renderStartTag(tok.Name, tok.Attrs))
stack = append(stack, openToken{name: tok.Name, attrs: tok.Attrs})
case xmlmodels.EndElement:
var attrs map[string]string
stack, attrs = popAttrsFor(stack, tok.Name)
b.WriteString(renderEndTag(tok.Name, attrs))
}
}
return b.String()
}
func popAttrsFor(stack []openToken, name string) ([]openToken, map[string]string) {
for i := len(stack) - 1; i >= 0; i-- {
if stack[i].name == name {
attrs := stack[i].attrs
return append(stack[:i], stack[i+1:]...), attrs
}
}
return stack, nil
}
func renderStartTag(name string, attrs map[string]string) string {
switch name {
case "aq":
return `<span class="tag-aq">`
case "b":
return `<strong class="tag-b">`
case "del":
return `<del class="tag-del">`
case "dul":
return `<span class="tag-dul">`
case "tul":
return `<span class="tag-tul">`
case "er":
return `<span class="tag-er">`
case "gr":
return `<span class="tag-gr">`
case "hb":
return `<span class="tag-hb">`
case "ink":
return `<span class="tag-ink" data-ref="` + html.EscapeString(attrs["ref"]) + `">`
case "it":
return `<em class="tag-it">`
case "pe":
return `<span class="tag-pe">`
case "ru":
return `<span class="tag-ru">`
case "tl":
return `<span class="tag-tl">`
case "ul":
return `<span class="tag-ul">`
case "note":
return `<span class="tag-note">`
case "fn":
return `<span class="tag-fn" data-index="` + html.EscapeString(attrs["index"]) + `">`
case "nr":
return `<span class="tag-nr">&middot;`
case "subst":
return `<span class="tag-subst">`
case "insertion":
return `<span class="tag-insertion">`
case "hand":
return `<span class="tag-hand" data-ref="` + html.EscapeString(attrs["ref"]) + `">`
case "align":
return `<span class="tag-align align-` + html.EscapeString(attrs["pos"]) + `">`
case "tab":
return `<span class="tag-tab" data-tab="` + html.EscapeString(attrs["value"]) + `">`
default:
return `<span class="tag-generic" data-tag="` + html.EscapeString(name) + `">`
}
}
func renderEndTag(name string, attrs map[string]string) string {
if name == "nr" {
extent := parseExtent(attrs["extent"])
return strings.Repeat("&nbsp;", extent) + `&middot;</span>`
}
switch name {
case "b":
return `</strong>`
case "it":
return `</em>`
case "del":
return `</del>`
default:
return `</span>`
}
}
func parseExtent(raw string) int {
if raw == "" {
return 1
}
field := raw
for i := 0; i < len(raw); i++ {
if raw[i] == '-' || raw[i] == '|' {
field = raw[:i]
break
}
}
n, err := strconv.Atoi(field)
if err != nil || n < 1 {
return 1
}
return n
}

View File

@@ -1,11 +1,23 @@
package server package server
import ( import (
"io/fs"
"github.com/Theodor-Springmann-Stiftung/lenz-web/templates" "github.com/Theodor-Springmann-Stiftung/lenz-web/templates"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
// INFO: Static files here: // INFO: Static files here:
func MapStatic(e *echo.Echo) { func MapStatic(e *echo.Echo) {
e.StaticFS("/public", templates.PublicFS) publicFS, err := fs.Sub(templates.PublicFS, "public")
if err != nil {
// fallback keeps server running even if FS layout changes unexpectedly
e.StaticFS("/public", templates.PublicFS)
return
}
e.StaticFS("/public", publicFS)
}
func MapEndpoints(e *echo.Echo, s *Server) {
e.GET("/brief/:number", s.Brief)
} }

View File

@@ -12,6 +12,7 @@ type Server struct {
server *echo.Echo server *echo.Echo
cfg app.Config cfg app.Config
tmpl *template.Template tmpl *template.Template
app *app.App
} }
func NewServer(app *app.App) (*Server, error) { func NewServer(app *app.App) (*Server, error) {
@@ -19,10 +20,12 @@ func NewServer(app *app.App) (*Server, error) {
server: echo.New(), server: echo.New(),
cfg: app.Config(), cfg: app.Config(),
tmpl: app.Templates(), tmpl: app.Templates(),
app: app,
} }
// INFO: Endpoint mapping here: // INFO: Endpoint mapping here:
MapStatic(s.server) MapStatic(s.server)
MapEndpoints(s.server, s)
return s, nil return s, nil
} }

View File

@@ -5,6 +5,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}Default{{ end }}</title> <title>{{ block "title" . }}Default{{ end }}</title>
<link rel="stylesheet" href="/public/style.css">
</head> </head>
<body> <body>
{{ block "body" . }}{{ end }} {{ block "body" . }}{{ end }}

View File

@@ -0,0 +1,125 @@
{{ define "brief" }}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brief {{ .Number }}</title>
<link rel="stylesheet" href="/public/style.css">
</head>
<body>
<main class="brief-page">
<header class="brief-head">
<h1>Brief {{ .Number }}</h1>
<nav class="brief-nav">
{{ if gt .Prev 0 }}
<a class="brief-nav-btn" href="/brief/{{ .Prev }}">← Brief {{ .Prev }}</a>
{{ else }}
<span class="brief-nav-btn is-disabled">← Kein früherer</span>
{{ end }}
{{ if gt .Next 0 }}
<a class="brief-nav-btn" href="/brief/{{ .Next }}">Brief {{ .Next }} →</a>
{{ else }}
<span class="brief-nav-btn is-disabled">Kein späterer →</span>
{{ end }}
</nav>
</header>
<section class="brief-grid">
{{ range $i, $p := .Pages }}
{{ if gt $i 0 }}
<div class="grid-row row-divider">
<div class="grid-full page-divider-line"></div>
</div>
{{ end }}
{{ if $p.TopNotes }}
<div class="grid-row row-top">
<div class="grid-left"></div>
<div class="grid-mid notes-band">
{{ range $p.TopNotes }}
<section class="sidenote {{ .PosClass }}">
{{ if .Annotation }}<p class="sidenote-annotation">{{ .Annotation }}</p>{{ end }}
{{ range .Lines }}
{{ if eq .Type "empty" }}
<div class="{{ .ClassName }}"></div>
{{ else }}
<div class="{{ .ClassName }}">{{ .HTML }}</div>
{{ end }}
{{ end }}
</section>
{{ end }}
</div>
<div class="grid-right">{{ if gt $i 0 }}<div class="page-marker">S. {{ $p.Number }}</div>{{ end }}</div>
</div>
{{ end }}
<div class="grid-row row-page">
<aside class="grid-left notes-col">
{{ range $p.LeftNotes }}
<section class="sidenote {{ .PosClass }}">
{{ if .Annotation }}<p class="sidenote-annotation">{{ .Annotation }}</p>{{ end }}
{{ range .Lines }}
{{ if eq .Type "empty" }}
<div class="{{ .ClassName }}"></div>
{{ else }}
<div class="{{ .ClassName }}">{{ .HTML }}</div>
{{ end }}
{{ end }}
</section>
{{ end }}
</aside>
<div class="grid-mid page-lines">
{{ range $p.Lines }}
{{ if eq .Type "empty" }}
<div class="{{ .ClassName }}"></div>
{{ else }}
<div class="{{ .ClassName }}">{{ .HTML }}</div>
{{ end }}
{{ end }}
</div>
<aside class="grid-right notes-col">
{{ if and (gt $i 0) (not $p.TopNotes) }}<div class="page-marker">S. {{ $p.Number }}</div>{{ end }}
{{ range $p.RightNotes }}
<section class="sidenote {{ .PosClass }}">
{{ if .Annotation }}<p class="sidenote-annotation">{{ .Annotation }}</p>{{ end }}
{{ range .Lines }}
{{ if eq .Type "empty" }}
<div class="{{ .ClassName }}"></div>
{{ else }}
<div class="{{ .ClassName }}">{{ .HTML }}</div>
{{ end }}
{{ end }}
</section>
{{ end }}
</aside>
</div>
{{ if $p.BottomNotes }}
<div class="grid-row row-bottom">
<div class="grid-left"></div>
<div class="grid-mid notes-band">
{{ range $p.BottomNotes }}
<section class="sidenote {{ .PosClass }}">
{{ if .Annotation }}<p class="sidenote-annotation">{{ .Annotation }}</p>{{ end }}
{{ range .Lines }}
{{ if eq .Type "empty" }}
<div class="{{ .ClassName }}"></div>
{{ else }}
<div class="{{ .ClassName }}">{{ .HTML }}</div>
{{ end }}
{{ end }}
</section>
{{ end }}
</div>
<div class="grid-right"></div>
</div>
{{ end }}
{{ end }}
</section>
</main>
</body>
</html>
{{ end }}

View File

@@ -1,2 +1,448 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.static{position:static}} @layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.static {
position: static;
}
.block {
display: block;
}
}
:root {
--bg: #fff;
--ink: #2a2824;
--muted: #7f7a72;
--rule: #d8d4cc;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
font-size: 1.125rem;
}
html {
font-size: 112.5%;
}
.brief-page {
max-width: 1360px;
margin: 0 auto;
padding: 0;
}
.brief-head {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
margin: 0;
border-bottom: none;
background: #fff;
}
.brief-head h1 {
margin: 0;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.brief-nav {
display: flex;
gap: 0.3rem;
}
.brief-nav-btn {
text-decoration: none;
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink);
font-size: 0.86rem;
padding: 0.2rem 0.5rem;
}
.brief-nav-btn.is-disabled {
opacity: 0.35;
pointer-events: none;
}
.brief-grid {
display: block;
margin: 0;
padding: 0;
}
.grid-row {
display: grid;
grid-template-columns: 250px minmax(0, 1fr) 250px;
column-gap: 0;
margin: 0;
padding: 0;
}
.row-divider {
margin: 0;
padding: 0;
}
.page-divider-line {
border-top: 1px solid #cfc8bc;
margin: 0;
padding: 0;
width: 100%;
}
.grid-left, .grid-mid, .grid-right {
margin: 0;
padding: 0;
}
.grid-full {
grid-column: 1 / -1;
}
.page-marker {
margin: 0;
padding: 0.18rem 0 0.18rem 0;
font-size: 0.84rem;
color: #5f584f;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
text-align: right;
}
.notes-col {
padding-top: 0.18rem;
}
.notes-col > .sidenote + .sidenote {
margin-top: 0.8rem;
}
.page-lines {
margin: 0;
padding: 0;
}
.notes-band {
margin: 0;
padding: 0.1rem 0;
}
.sidenote {
margin: 0;
padding: 0;
border: none;
background: transparent;
}
.sidenote-annotation {
margin: 0;
padding: 0;
font-size: 0.78rem;
color: #8d867b;
font-family: "Times New Roman", Georgia, serif;
font-style: italic;
}
.pos-top-right, .pos-bottom-right, .pos-right {
text-align: right;
}
.pos-top-center, .pos-bottom-center {
text-align: center;
}
.line {
white-space: normal;
font-size: 1.12rem;
line-height: 1.5;
color: #2f2c27;
margin: 0;
padding: 0;
}
.notes-col .line, .notes-band .line {
font-size: 0.9rem;
}
.line-empty {
height: 1rem;
}
.line-first {
padding-left: 0;
}
.line-continuation {
color: #3b3731;
}
.line-indent-0 {
text-indent: 0;
}
.line-indent-1 {
text-indent: 0.58rem;
}
.line-indent-2 {
text-indent: 1.16rem;
}
.line-indent-3 {
text-indent: 1.74rem;
}
.line-indent-4 {
text-indent: 2.32rem;
}
.line-indent-5 {
text-indent: 2.9rem;
}
.line-indent-6 {
text-indent: 3.48rem;
}
.line-indent-7 {
text-indent: 4.06rem;
}
.line-indent-8 {
text-indent: 4.64rem;
}
.line-indent-9 {
text-indent: 5.22rem;
}
.line-indent-10 {
text-indent: 5.8rem;
}
.line-indent-11 {
text-indent: 6.38rem;
}
.line-indent-12 {
text-indent: 6.96rem;
}
.line-align-ctx {
text-align: right;
}
.line-align-ctx .tag-align.align-center {
display: inline-block;
width: 100%;
text-align: center;
}
.line-align-ctx .tag-align.align-right {
display: inline-block;
width: 100%;
text-align: right;
}
.line-tab-ctx .tag-tab {
display: inline-block;
min-width: 3.6ch;
border-left: 1px dotted #cdbfae;
padding-left: 0.34rem;
}
.tag-aq, .tag-note {
font-family: "Trebuchet MS", "Helvetica Neue", Arial, sans-serif;
}
.tag-b {
font-weight: 700;
}
.tag-del {
text-decoration: line-through;
}
.tag-dul {
text-decoration: underline;
text-decoration-style: double;
}
.tag-tul {
text-decoration: underline;
text-decoration-style: wavy;
text-decoration-thickness: 2px;
}
.tag-it {
font-style: italic;
}
.tag-ul {
text-decoration: underline;
}
.tag-note {
font-size: 0.9em;
font-weight: 700;
color: var(--muted);
}
.tag-note {
font-family: "Times New Roman", Georgia, serif;
font-style: italic;
font-weight: 400;
color: #8d867b;
}
.tag-ink {
color: #2d5e8a;
}
.tag-pe {
color: #4e667f;
}
.tag-tl {
background: #f5e4d6;
color: #8c5a41;
padding: 0 0.12rem;
border-radius: 2px;
}
.tag-nr {
color: #6f675d;
}
.tag-insertion {
position: relative;
padding-left: 0.1rem;
}
.tag-insertion::before {
content: "\2038";
font-size: 0.78em;
vertical-align: super;
color: #9a8b76;
margin-right: 0.06rem;
}
@media (max-width: 960px) {
.brief-page {
max-width: 100%;
}
.grid-row {
grid-template-columns: 1fr;
}
.notes-col, .grid-left, .grid-right, .notes-band {
text-align: left;
}
}

View File

@@ -1 +1,249 @@
@import "tailwindcss"; @import "tailwindcss";
:root {
--bg: #fff;
--ink: #2a2824;
--muted: #7f7a72;
--rule: #d8d4cc;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Georgia, "Times New Roman", serif;
font-size: 1.125rem;
}
html {
font-size: 112.5%;
}
.brief-page {
max-width: 1360px;
margin: 0 auto;
padding: 0;
}
.brief-head {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
margin: 0;
border-bottom: none;
background: #fff;
}
.brief-head h1 {
margin: 0;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.brief-nav {
display: flex;
gap: 0.3rem;
}
.brief-nav-btn {
text-decoration: none;
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink);
font-size: 0.86rem;
padding: 0.2rem 0.5rem;
}
.brief-nav-btn.is-disabled { opacity: 0.35; pointer-events: none; }
.brief-grid {
display: block;
margin: 0;
padding: 0;
}
.grid-row {
display: grid;
grid-template-columns: 250px minmax(0, 1fr) 250px;
column-gap: 0;
margin: 0;
padding: 0;
}
.row-divider {
margin: 0;
padding: 0;
}
.page-divider-line {
border-top: 1px solid #cfc8bc;
margin: 0;
padding: 0;
width: 100%;
}
.grid-left,
.grid-mid,
.grid-right {
margin: 0;
padding: 0;
}
.grid-full {
grid-column: 1 / -1;
}
.page-marker {
margin: 0;
padding: 0.18rem 0 0.18rem 0;
font-size: 0.84rem;
color: #5f584f;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
text-align: right;
}
.notes-col {
padding-top: 0.18rem;
}
.notes-col > .sidenote + .sidenote {
margin-top: 0.8rem;
}
.page-lines {
margin: 0;
padding: 0;
}
.notes-band {
margin: 0;
padding: 0.1rem 0;
}
.sidenote {
margin: 0;
padding: 0;
border: none;
background: transparent;
}
.sidenote-annotation {
margin: 0;
padding: 0;
font-size: 0.78rem;
color: #8d867b;
font-family: "Times New Roman", Georgia, serif;
font-style: italic;
}
.pos-top-right,
.pos-bottom-right,
.pos-right {
text-align: right;
}
.pos-top-center,
.pos-bottom-center {
text-align: center;
}
.line {
white-space: normal;
font-size: 1.12rem;
line-height: 1.5;
color: #2f2c27;
margin: 0;
padding: 0;
}
.notes-col .line,
.notes-band .line {
font-size: 0.9rem;
}
.line-empty { height: 1rem; }
.line-first { padding-left: 0; }
.line-continuation { color: #3b3731; }
.line-indent-0 { text-indent: 0; }
.line-indent-1 { text-indent: 0.58rem; }
.line-indent-2 { text-indent: 1.16rem; }
.line-indent-3 { text-indent: 1.74rem; }
.line-indent-4 { text-indent: 2.32rem; }
.line-indent-5 { text-indent: 2.9rem; }
.line-indent-6 { text-indent: 3.48rem; }
.line-indent-7 { text-indent: 4.06rem; }
.line-indent-8 { text-indent: 4.64rem; }
.line-indent-9 { text-indent: 5.22rem; }
.line-indent-10 { text-indent: 5.8rem; }
.line-indent-11 { text-indent: 6.38rem; }
.line-indent-12 { text-indent: 6.96rem; }
.line-align-ctx { text-align: right; }
.line-align-ctx .tag-align.align-center { display: inline-block; width: 100%; text-align: center; }
.line-align-ctx .tag-align.align-right { display: inline-block; width: 100%; text-align: right; }
.line-tab-ctx .tag-tab {
display: inline-block;
min-width: 3.6ch;
border-left: 1px dotted #cdbfae;
padding-left: 0.34rem;
}
.tag-aq,
.tag-note { font-family: "Trebuchet MS", "Helvetica Neue", Arial, sans-serif; }
.tag-b { font-weight: 700; }
.tag-del { text-decoration: line-through; }
.tag-dul { text-decoration: underline; text-decoration-style: double; }
.tag-tul { text-decoration: underline; text-decoration-style: wavy; text-decoration-thickness: 2px; }
.tag-it { font-style: italic; }
.tag-ul { text-decoration: underline; }
.tag-note { font-size: 0.9em; font-weight: 700; color: var(--muted); }
.tag-note {
font-family: "Times New Roman", Georgia, serif;
font-style: italic;
font-weight: 400;
color: #8d867b;
}
.tag-ink { color: #2d5e8a; }
.tag-pe { color: #4e667f; }
.tag-tl { background: #f5e4d6; color: #8c5a41; padding: 0 0.12rem; border-radius: 2px; }
.tag-nr { color: #6f675d; }
.tag-insertion {
position: relative;
padding-left: 0.1rem;
}
.tag-insertion::before {
content: "\2038";
font-size: 0.78em;
vertical-align: super;
color: #9a8b76;
margin-right: 0.06rem;
}
@media (max-width: 960px) {
.brief-page { max-width: 100%; }
.grid-row {
grid-template-columns: 1fr;
}
.notes-col,
.grid-left,
.grid-right,
.notes-band {
text-align: left;
}
}

View File

@@ -18,10 +18,10 @@ type LineType int
const ( const (
Continuation LineType = iota Continuation LineType = iota
First First
Fist = First // backward-compatible alias for historical typo Fist = First // backward-compatible alias for historical typo
Semantic // Indent=0 , still type="break" Semantic LineType = iota // Indent=0 , still type="break"
Indent // Indent>0, type dosent matter Indent // Indent>0, type dosent matter
Empty // no line content, after that, an empty line Empty // no line content, after that, an empty line
) )
type Token struct { type Token struct {