feat(udgaver): urd — noter-udgaven siger sit eget navn
Verdande og Urd er to af nornerne i Völuspá, og digtet har dem skære
mærker i træ: *skáru á skíði*. Verdande er det der bliver; Urd er det der
blev, og blev gemt. En instans startet uden opgaveruterne er et andet
produkt for den der bruger den, så den siger et andet navn.
Navnet læses fra udgaven i ÉT sted — Config.ProductName/ProductSlug —
frem for at stå skrevet i teksterne. Frontend-bygget er byte-identisk i
begge udgaver; serveren omdøber de to filer, browseren læser før noget
JavaScript kører.
Der er to titler, og det blev først klart, da e2e-prøven sagde det:
skallens <title>, som serveren omdøber, og rutens egen i <svelte:head>,
som appen sætter bagefter og som overskriver den første. Go-prøven var
grøn over de bytes, serveren sendte, mens fanen sagde "Noter · verdande".
Begge prøver spørger nu om begge.
Omdøbningen er ankret på hele strenge frem for på ordet "verdande", fordi
app.html også indeholder localStorage.getItem('verdande:theme'). Omdøbes
præfikset der og ikke i resten af appen, bliver temaet sat fra én nøgle og
læst fra en anden, og det eneste symptom er det hvide blink, det
indlejrede script findes for at undgå. Tre plantede fejl bekræfter, at det
er de rigtige prøver, der fanger det: et flyttet anker i app.html, en bred
søg-erstat, og en applyRebrands uden fejlretur.
Mailen var det vigtigste sted. appName var et felt med en værdi og ingen
læser, og kommentaren ved siden af sagde, at det stod i hver besked — de
tre steder havde ordet skrevet ud i stedet. En invitation fra en
urd-instans går til et andet menneskes indbakke.
Også omdøbt: web-manifestet (og sendt som application/manifest+json — Go
kender ikke .webmanifest og sniffer den til text/plain, hvorefter
browseren ignorerer manifestet uden at sige noget, og installationsknappen
bare ikke er der), mærket i menuen (ᚢ uruz frem for ᚹ wunjo),
opstartsloggen, passkey-promptens visningsnavn, ICS-kalendernavnet og
udseendet, der er opkaldt efter programmet.
Ikonet er IKKE med: icon.svg er stadig ᚹ, og de tre PNG'er genereres fra
den. Det rører binære, genererede filer og hører i sin egen ændring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7e3d300183
commit
6154e6b247
@@ -15,6 +15,22 @@
|
||||
Not what has been, and not what is fated to be: the thing you are in the middle of.
|
||||
Which is what a to-do list is.
|
||||
|
||||
## Two editions: verdande and urd
|
||||
|
||||
This repository builds one program with two faces. Start it with
|
||||
`VERDANDE_EDITION=notes` and it is **urd** — notes and nothing else: the task
|
||||
routes are not mounted, the sidebar has no way to them, and the program calls
|
||||
itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
Urðr is Verðandi's sister in Völuspá, and the poem has the Norns cutting marks into
|
||||
wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
All six phases are built. See [What works today](#what-works-today) for what is
|
||||
|
||||
@@ -26,6 +26,9 @@ var version = "dev"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
// Not the edition's name: this fires before the configuration is read, so
|
||||
// the edition is not known yet — and one of the things it reports is a
|
||||
// VERDANDE_EDITION the program refused.
|
||||
fmt.Fprintf(os.Stderr, "verdande: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -37,7 +40,9 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := httpapi.NewLogger(cfg.Dev)
|
||||
log.Info("starting verdande", "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL)
|
||||
// The edition's own name, so an urd instance's log says urd. The full edition
|
||||
// still logs "starting verdande", which is what the release check reads.
|
||||
log.Info("starting "+cfg.ProductSlug(), "version", version, "data_dir", cfg.DataDir, "base_url", cfg.BaseURL, "edition", cfg.Edition)
|
||||
|
||||
// The data volume is the only state that matters; create the whole layout up
|
||||
// front so a failure here is a startup error rather than a surprise at the
|
||||
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
# The install count
|
||||
|
||||
verdande reports that it exists. Two values, once a day:
|
||||
verdande reports that it exists — and so does urd, which is the same program in
|
||||
its [notes edition](editions.md). Two values, once a day:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -11,7 +12,8 @@ verdande reports that it exists. Two values, once a day:
|
||||
|
||||
That is the whole message. No IP is stored by the collector, no hostname, no
|
||||
domain, no account, no counts of anything you have made, nothing about what you
|
||||
use it for. The payload is small enough to be printed in full on the settings page,
|
||||
use it for — and not which of the two editions is running, so the count does not
|
||||
tell the two apart either. The payload is small enough to be printed in full on the settings page,
|
||||
and it is printed there — a promise about telemetry is worth exactly as much as
|
||||
your ability to check it.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ nowhere to put one that the operator would ever see.
|
||||
| `VERDANDE_ADDR` | `:8080` | Listen address. |
|
||||
| `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level, and a relaxed origin check so a Vite dev server can connect. Never in production. |
|
||||
| `VERDANDE_EDITION` | `full` | Which of the two editions this instance is. `full` is verdande — tasks, projects and notes. `notes` is urd: the task routes are not mounted at all, and the program calls itself urd. Any other value refuses to start, by name. See [Two editions](editions.md). |
|
||||
|
||||
## Behind a proxy
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Two editions: verdande and urd
|
||||
|
||||
One program, built once, with two faces.
|
||||
|
||||
| | **verdande** | **urd** |
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 171 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
mounted, the sidebar has no way to them, search answers with notes only, and the
|
||||
program calls itself urd in its tab, its wordmark and its web manifest.
|
||||
|
||||
## Why one codebase and not a fork
|
||||
|
||||
The two share everything that is hard: accounts, sessions, passkeys, sharing,
|
||||
invitations, the realtime socket, search, the backup format, the migrations. Notes
|
||||
are about a twentieth of the Go in this repository and reference no task type;
|
||||
tasks reference no note type. A fork would have duplicated the other nineteen
|
||||
twentieths in order to separate the one — and then every security fix would have
|
||||
needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 29 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
## An unknown value refuses to start
|
||||
|
||||
`VERDANDE_EDITION=note`, `Notes` and `none` are all rejected at startup, by name,
|
||||
rather than being treated as "not notes".
|
||||
|
||||
The silent direction is the dangerous one here. An operator who sets up an instance
|
||||
for notes and mistypes the value would otherwise get every task route served on it,
|
||||
with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the
|
||||
upcoming and delegated views, and the task half of search and export. Everything
|
||||
else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes
|
||||
|
||||
## The name
|
||||
|
||||
Verðandi and Urðr are two of the three Norns in Völuspá, and the poem has them
|
||||
cutting marks into wood — the act of writing something down. Verdande is what is
|
||||
becoming; Urd is what has become and was kept.
|
||||
|
||||
An instance started without the task routes is a different product to the person
|
||||
using it, so it says a different name. That name is read from the configuration in
|
||||
one place (`Config.ProductName`), which is why there is no build-time flag and no
|
||||
second frontend: the frontend is byte-identical in both editions, and the server
|
||||
renames the two files a browser reads before any JavaScript runs — the HTML shell's
|
||||
title and the web manifest.
|
||||
|
||||
!!! note "The beacon counts them separately"
|
||||
Both editions report the same two values once a day — instance id and version
|
||||
— and nothing else; see [The install count](beacon.md). The edition is not in
|
||||
the payload, so the count does not distinguish them.
|
||||
@@ -12,3 +12,35 @@ const (
|
||||
// comparison lives in one place: `cfg.Edition == "notes"` spelled out at twenty
|
||||
// call sites is twenty chances to typo a string the compiler does not check.
|
||||
func (c *Config) NotesOnly() bool { return c.Edition == EditionNotes }
|
||||
|
||||
// ProductName and ProductSlug are the two forms of the name the running instance
|
||||
// calls itself: "Urd" in a sentence, "urd" in a window title, a wordmark and a
|
||||
// manifest.
|
||||
//
|
||||
// Two editions of one program, two names — which is the whole reason the name is
|
||||
// read from the configuration rather than written into the templates. Verdande is
|
||||
// what is happening now; Urd, its sister in Völuspá, is what was cut into the wood
|
||||
// and kept. An instance started without the task routes is a different product to
|
||||
// whoever uses it, and a product that calls itself by its sibling's name is not a
|
||||
// product, it is a flag somebody set.
|
||||
//
|
||||
// The lowercase form is deliberate and not a styling accident: the wordmark, the
|
||||
// repository and the window title are all lowercase, so the slug is the name and
|
||||
// ProductName is the one that gets a capital because it stands in prose.
|
||||
func (c *Config) ProductName() string {
|
||||
if c.NotesOnly() {
|
||||
return "Urd"
|
||||
}
|
||||
return "Verdande"
|
||||
}
|
||||
|
||||
// ProductSlug is ProductName lowercased, kept as its own method rather than as
|
||||
// strings.ToLower at the call sites: the two are the same transformation today and
|
||||
// a name that does not lowercase cleanly would otherwise be a bug in every caller
|
||||
// at once.
|
||||
func (c *Config) ProductSlug() string {
|
||||
if c.NotesOnly() {
|
||||
return "urd"
|
||||
}
|
||||
return "verdande"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,18 @@ func scriptHashes(web fs.FS) []string {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return scriptHashesIn(raw)
|
||||
}
|
||||
|
||||
// scriptHashesIn is the same thing over bytes that are already in hand.
|
||||
//
|
||||
// It exists because the notes edition does not serve the index.html that is on
|
||||
// disk — it serves a renamed copy of it (see shell.go) — and the policy has to
|
||||
// describe the bytes that actually leave the server. Hashing the file while
|
||||
// serving something else is the shape of mistake that cannot be noticed locally:
|
||||
// the policy is not enforced for a page opened from a file, so the page is blank
|
||||
// in production and nowhere else.
|
||||
func scriptHashesIn(raw []byte) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
|
||||
|
||||
@@ -358,7 +358,7 @@ func (s *Server) handleExportAllICS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cal := ics.Calendar{Name: "Verdande", Domain: feedDomain(s.cfg.BaseURL)}
|
||||
cal := ics.Calendar{Name: s.cfg.ProductName(), Domain: feedDomain(s.cfg.BaseURL)}
|
||||
for _, t := range tasks {
|
||||
if t.DueDate == "" {
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
@@ -77,6 +78,11 @@ type Server struct {
|
||||
// Computed once from the built index.html; see csp.go.
|
||||
csp string
|
||||
|
||||
// The shell and the manifest with this edition's name in them, or nil to serve
|
||||
// the built files unchanged. Only the notes edition needs them; see shell.go.
|
||||
shell []byte
|
||||
manifest []byte
|
||||
|
||||
router chi.Router
|
||||
}
|
||||
|
||||
@@ -86,7 +92,7 @@ type Server struct {
|
||||
func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg, db: db, log: log, web: web,
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, log),
|
||||
mail: mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log),
|
||||
hub: realtime.NewHub(log),
|
||||
loginLimiter: newLimiter(10, 15*time.Minute),
|
||||
// Generous on purpose: the caller is the mail server, one address delivering
|
||||
@@ -101,8 +107,36 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// generous for a person and hopeless for a search through a million.
|
||||
totpLimiter: newLimiter(10, time.Hour),
|
||||
}
|
||||
// Before the CSP, deliberately: the policy has to describe the bytes that go
|
||||
// out, and in the notes edition those are the renamed ones.
|
||||
//
|
||||
// A failure here is logged rather than fatal, and that is a judgement about
|
||||
// this particular failure rather than a habit: an unknown VERDANDE_EDITION
|
||||
// refuses to start, because serving task routes on an instance set up without
|
||||
// them is a different kind of wrong. A shell whose title says the sibling's
|
||||
// name is cosmetic — it must be loud, and it must not take the instance down.
|
||||
// What makes it loud is not this line: the end-to-end suite reads the served
|
||||
// title, so an anchor that falls out of app.html goes red there.
|
||||
if web != nil && cfg.NotesOnly() {
|
||||
shell, manifest, err := rebrandedAssets(web, cfg.ProductSlug(), notesShellDescription, notesManifestDescription)
|
||||
if err != nil {
|
||||
log.Error("the frontend could not be renamed for this edition; it will call itself verdande",
|
||||
"edition", cfg.Edition, "err", err)
|
||||
} else {
|
||||
s.shell, s.manifest = shell, manifest
|
||||
}
|
||||
}
|
||||
|
||||
if s.shell != nil {
|
||||
s.csp = contentSecurityPolicy(scriptHashesIn(s.shell))
|
||||
} else {
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
}
|
||||
// The second argument is WebAuthn's display name, not its relying party id —
|
||||
// the id comes from BaseURL. So this is what the operating system's passkey
|
||||
// prompt calls the site, and the slug keeps it identical to what every existing
|
||||
// verdande instance already registered.
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, cfg.ProductSlug()); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
@@ -685,7 +719,7 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
if s.web == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("verdande — API is running. The web interface was not built into this binary.\n"))
|
||||
w.Write([]byte(s.cfg.ProductSlug() + " — API is running. The web interface was not built into this binary.\n"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,6 +740,24 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Only here is `name` settled, and that matters: every client-side route —
|
||||
// /noter and all the rest — arrives as a path with no file behind it and is
|
||||
// resolved to index.html by the fallback above. Asking before the fallback
|
||||
// would have renamed the shell for a request to "/" and handed back the built
|
||||
// one for every other route in the app.
|
||||
//
|
||||
// These two assets never reach the filesystem: they are the built files with
|
||||
// this edition's name in them, held in memory since startup. See shell.go.
|
||||
if b := s.renamed(name); b != nil {
|
||||
w.Header().Set("Content-Type", contentTypeFor(name))
|
||||
// A zero modification time tells ServeContent to send no Last-Modified and
|
||||
// to answer no conditional request — right for a shell that must never be
|
||||
// cached, and the same thing the built shell gets below.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// The product name is in the built frontend as a literal in four places, because
|
||||
// all four are read before any JavaScript runs: the window title and the meta
|
||||
// description in the HTML shell, and the two names in the web manifest that decide
|
||||
// what an installed PWA is called on a home screen.
|
||||
//
|
||||
// They cannot be filled in by the app at runtime. The shell is parsed before the
|
||||
// first script executes, the manifest is fetched by the browser rather than by us,
|
||||
// and the edition is not known until /auth/setup-state has answered — so a frontend
|
||||
// that renamed itself after loading would show the sibling's name first and correct
|
||||
// itself a moment later, on every load, in the tab and on the install prompt.
|
||||
//
|
||||
// So the server does it, once at startup, on the way out. One frontend build, two
|
||||
// names: the build is identical in both editions and the bytes served differ.
|
||||
//
|
||||
// Each replacement is REQUIRED and the anchors are exact. A rewrite that quietly
|
||||
// matched nothing is the whole failure mode here — it leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody files it and
|
||||
// nothing else notices.
|
||||
type rebrand struct {
|
||||
old, new string
|
||||
}
|
||||
|
||||
// shellRebrands returns the replacements for index.html.
|
||||
//
|
||||
// Note what is NOT in here: `localStorage.getItem('verdande:theme')` and its two
|
||||
// neighbours in the inline theme script. Those keys are storage, not a name — the
|
||||
// rest of the app reads the same three, so renaming them here and nowhere else
|
||||
// would set the theme from one key and read it from another, and the only symptom
|
||||
// is the white flash that script exists to prevent. This is why the replacements
|
||||
// are written out as whole anchored strings rather than as a replace of the word
|
||||
// "verdande".
|
||||
func shellRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{"<title>verdande</title>", "<title>" + slug + "</title>"},
|
||||
{
|
||||
`<meta name="description" content="verdande — opgaver og projekter, delt." />`,
|
||||
`<meta name="description" content="` + description + `" />`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// manifestRebrands returns the replacements for manifest.webmanifest. The
|
||||
// manifest's own description is a sentence rather than the shell's "name — sentence"
|
||||
// form, so it is passed separately rather than derived.
|
||||
func manifestRebrands(slug, description string) []rebrand {
|
||||
return []rebrand{
|
||||
{`"name": "verdande"`, `"name": "` + slug + `"`},
|
||||
{`"short_name": "verdande"`, `"short_name": "` + slug + `"`},
|
||||
{`"description": "Opgaver og projekter, delt."`, `"description": "` + description + `"`},
|
||||
}
|
||||
}
|
||||
|
||||
// applyRebrands replaces every pair exactly once and fails on the first anchor it
|
||||
// cannot find, naming it. Failing loudly rather than returning what it managed is
|
||||
// the point: a partial rename is a page whose tab and whose install prompt disagree
|
||||
// about which program this is.
|
||||
func applyRebrands(raw []byte, pairs []rebrand) ([]byte, error) {
|
||||
out := raw
|
||||
for _, p := range pairs {
|
||||
old := []byte(p.old)
|
||||
if bytes.Count(out, old) != 1 {
|
||||
return nil, fmt.Errorf("found %d occurrences of %q, want exactly 1", bytes.Count(out, old), p.old)
|
||||
}
|
||||
out = bytes.Replace(out, old, []byte(p.new), 1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rebrandedAssets reads index.html and manifest.webmanifest out of the built
|
||||
// frontend and returns them renamed for this edition.
|
||||
//
|
||||
// Both or neither: an instance serving a renamed shell beside the sibling's
|
||||
// manifest installs under the wrong name on a phone, which is the half nobody looks
|
||||
// at until it is on their home screen.
|
||||
func rebrandedAssets(web fs.FS, slug, shellDesc, manifestDesc string) (shell, manifest []byte, err error) {
|
||||
raw, err := fs.ReadFile(web, "index.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
shell, err = applyRebrands(raw, shellRebrands(slug, shellDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("index.html: %w", err)
|
||||
}
|
||||
|
||||
raw, err = fs.ReadFile(web, "manifest.webmanifest")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
manifest, err = applyRebrands(raw, manifestRebrands(slug, manifestDesc))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("manifest.webmanifest: %w", err)
|
||||
}
|
||||
return shell, manifest, nil
|
||||
}
|
||||
|
||||
// The descriptions for the notes edition. Kept beside the replacements they feed
|
||||
// rather than in the locale files, because these two strings are read by a browser
|
||||
// and a home screen before any locale has been chosen — the shell is in Danish for
|
||||
// the same reason the built app.html is.
|
||||
const (
|
||||
notesShellDescription = "urd — noter, delt."
|
||||
notesManifestDescription = "Noter, delt."
|
||||
)
|
||||
|
||||
// renamed returns the renamed bytes for `name`, or nil to serve the built file.
|
||||
//
|
||||
// Nil for everything in the full edition, which is what keeps that path exactly as
|
||||
// it was: one comparison per request against a nil slice.
|
||||
func (s *Server) renamed(name string) []byte {
|
||||
switch name {
|
||||
case "index.html":
|
||||
return s.shell
|
||||
case "manifest.webmanifest":
|
||||
return s.manifest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFor names the type for the two assets served from memory.
|
||||
//
|
||||
// http.ServeContent sniffs from the extension, and it gets .html right and
|
||||
// .webmanifest wrong — Go's table does not have it, so the sniffer reads the first
|
||||
// bytes, sees JSON-ish text and answers text/plain. A manifest served as text/plain
|
||||
// is ignored by the browser without an error anywhere: the install prompt simply
|
||||
// does not appear.
|
||||
func contentTypeFor(name string) string {
|
||||
if name == "manifest.webmanifest" {
|
||||
return "application/manifest+json"
|
||||
}
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// serverWithWeb builds the real router around a frontend, which newTestServerWith
|
||||
// cannot do — it passes nil for the web filesystem, because every other test here
|
||||
// is about the API. The renaming only happens when there is something to rename.
|
||||
func serverWithWeb(t *testing.T, edition string, web fs.FS) *Server {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
Edition: edition,
|
||||
}
|
||||
return New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), web)
|
||||
}
|
||||
|
||||
// The shell as the frontend build produces it, cut down to the parts that matter
|
||||
// here: the two anchors that get renamed, and the inline theme script that must
|
||||
// not be touched. TestAppHTMLStillCarriesTheAnchors below is what keeps this
|
||||
// honest against the real file.
|
||||
const builtShell = `<!doctype html>
|
||||
<html lang="da" data-theme="dark">
|
||||
<head>
|
||||
<meta name="description" content="verdande — opgaver og projekter, delt." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>verdande</title>
|
||||
<script>
|
||||
try {
|
||||
const stored = localStorage.getItem('verdande:theme');
|
||||
const look = localStorage.getItem('verdande:look');
|
||||
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const builtManifest = `{
|
||||
"name": "verdande",
|
||||
"short_name": "verdande",
|
||||
"description": "Opgaver og projekter, delt.",
|
||||
"start_url": "/"
|
||||
}
|
||||
`
|
||||
|
||||
func builtWeb() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
"_app/immutable/app.js": {Data: []byte("// app")},
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellLeavesTheStorageKeysAlone is the one this file exists for.
|
||||
//
|
||||
// The product name and the localStorage prefix are the same word in app.html, and
|
||||
// they are not the same thing: the prefix is read back by the app itself, so
|
||||
// renaming it here and nowhere else sets the theme from one key and reads it from
|
||||
// another. The symptom is the white flash on load that the inline script exists to
|
||||
// prevent — which is to say, no error, nothing in a log, and nothing a test that
|
||||
// merely checked the title would see.
|
||||
func TestRenamingTheShellLeavesTheStorageKeysAlone(t *testing.T) {
|
||||
shell, manifest, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
got := string(shell)
|
||||
for _, want := range []string{
|
||||
"<title>urd</title>",
|
||||
`content="urd — noter, delt."`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the shell does not contain %q", want)
|
||||
}
|
||||
}
|
||||
// Named values rather than "no occurrences of verdande": the three keys are
|
||||
// what has to survive, so the assertion says the three keys.
|
||||
for _, want := range []string{
|
||||
`localStorage.getItem('verdande:theme')`,
|
||||
`localStorage.getItem('verdande:look')`,
|
||||
`look !== 'verdande'`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("renaming the shell changed a storage key: %q is gone", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "urd:theme") || strings.Contains(got, "urd:look") {
|
||||
t.Error("renaming the shell rewrote a localStorage prefix; the app reads the verdande: ones")
|
||||
}
|
||||
|
||||
gotManifest := string(manifest)
|
||||
for _, want := range []string{`"name": "urd"`, `"short_name": "urd"`, `"description": "Noter, delt."`} {
|
||||
if !strings.Contains(gotManifest, want) {
|
||||
t.Errorf("the manifest does not contain %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingTheShellKeepsTheCSPHashValid pins the relationship between the two
|
||||
// files that is easy to break and impossible to notice: the policy names a hash of
|
||||
// the inline script, and the renamed shell is what gets served. If a replacement
|
||||
// ever reached inside the <script>, the hash would change, the browser would refuse
|
||||
// to run it, and the page would be blank — in production only, because the policy
|
||||
// is not enforced for a page opened from a file.
|
||||
func TestRenamingTheShellKeepsTheCSPHashValid(t *testing.T) {
|
||||
shell, _, err := rebrandedAssets(builtWeb(), "urd", "urd — noter, delt.", "Noter, delt.")
|
||||
if err != nil {
|
||||
t.Fatalf("rebrandedAssets: %v", err)
|
||||
}
|
||||
|
||||
before := scriptHashesIn([]byte(builtShell))
|
||||
after := scriptHashesIn(shell)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("the fixture should have exactly one inline script, found %d", len(before))
|
||||
}
|
||||
if len(after) != len(before) || after[0] != before[0] {
|
||||
t.Errorf("renaming changed the inline script: policy had %v, served shell hashes to %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenamingRefusesAMissingAnchor is the direction that matters more than the
|
||||
// happy path. A replacement that silently matched nothing leaves an Urd instance
|
||||
// calling itself verdande, which is cosmetic enough that nobody reports it — so the
|
||||
// rewrite has to say so rather than return what it managed.
|
||||
func TestRenamingRefusesAMissingAnchor(t *testing.T) {
|
||||
cases := map[string]fstest.MapFS{
|
||||
"no title": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, "<title>verdande</title>", "<title>something else</title>", 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no description": {
|
||||
"index.html": {Data: []byte(strings.Replace(builtShell, `content="verdande — opgaver og projekter, delt."`, `content="other"`, 1))},
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest name": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
"manifest.webmanifest": {Data: []byte(strings.Replace(builtManifest, `"name": "verdande"`, `"name": "other"`, 1))},
|
||||
},
|
||||
"no index.html at all": {
|
||||
"manifest.webmanifest": {Data: []byte(builtManifest)},
|
||||
},
|
||||
"no manifest at all": {
|
||||
"index.html": {Data: []byte(builtShell)},
|
||||
},
|
||||
}
|
||||
for name, web := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := rebrandedAssets(web, "urd", "d", "m"); err == nil {
|
||||
t.Error("renaming reported success over a shell it could not rename")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppHTMLStillCarriesTheAnchors is what stops the fixture above from becoming a
|
||||
// frozen copy of a file that has moved on.
|
||||
//
|
||||
// The anchors are exact strings in web/src/app.html, and that file is edited for
|
||||
// unrelated reasons — a meta tag, a new theme attribute. An edit that reformats the
|
||||
// title line or rewrites the description breaks the rename and nothing in Go would
|
||||
// know, because the Go build graph has never heard of app.html.
|
||||
//
|
||||
// Note the caching trap that comes with reading a non-Go file from a test: `go
|
||||
// test` will serve a cached green over a changed app.html. CI passes -count=1 in
|
||||
// both workflows; a local run needs it typed.
|
||||
func TestAppHTMLStillCarriesTheAnchors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../web/src/app.html")
|
||||
if err != nil {
|
||||
t.Skipf("app.html is not reachable from here: %v", err)
|
||||
}
|
||||
src := string(raw)
|
||||
|
||||
for _, pair := range shellRebrands("urd", "d") {
|
||||
if n := strings.Count(src, pair.old); n != 1 {
|
||||
t.Errorf("web/src/app.html contains %d occurrences of the anchor %q, want exactly 1 — "+
|
||||
"the notes edition renames the shell by replacing it", n, pair.old)
|
||||
}
|
||||
}
|
||||
// And the keys the rename must not touch, asserted here too: app.html is where
|
||||
// somebody would rename them, and if they are renamed there they must be
|
||||
// renamed in stores.svelte.js in the same commit.
|
||||
if !strings.Contains(src, `localStorage.getItem('verdande:theme')`) {
|
||||
t.Error("web/src/app.html no longer reads verdande:theme; check that the app reads the same key")
|
||||
}
|
||||
t.Logf("app.html: %d bytes, %d anchors found", len(raw), len(shellRebrands("urd", "d")))
|
||||
}
|
||||
|
||||
// TestTheFullEditionServesTheBuiltFilesUnchanged is the control. Everything above
|
||||
// is about the renamed path; this says the other edition does not go through it.
|
||||
func TestTheFullEditionServesTheBuiltFilesUnchanged(t *testing.T) {
|
||||
for _, edition := range []string{"full", "notes"} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
srv := serverWithWeb(t, edition, builtWeb())
|
||||
if edition == "full" {
|
||||
if srv.renamed("index.html") != nil || srv.renamed("manifest.webmanifest") != nil {
|
||||
t.Error("the full edition renamed the shell")
|
||||
}
|
||||
return
|
||||
}
|
||||
if srv.renamed("index.html") == nil || srv.renamed("manifest.webmanifest") == nil {
|
||||
t.Fatal("the notes edition did not rename the shell")
|
||||
}
|
||||
if !strings.Contains(string(srv.renamed("index.html")), "<title>urd</title>") {
|
||||
t.Error("the notes edition's shell does not say urd")
|
||||
}
|
||||
if srv.renamed("_app/immutable/app.js") != nil {
|
||||
t.Error("a build asset was served from memory; only the shell and the manifest are renamed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
|
||||
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, cfg.ProductSlug(), log), realtime.NewHub(log)), db, cfg
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *store.DB) *store.User {
|
||||
|
||||
+19
-13
@@ -24,13 +24,19 @@ import (
|
||||
type Sender struct {
|
||||
cfg config.SMTP
|
||||
log *slog.Logger
|
||||
// appName and baseURL appear in the body of every message.
|
||||
// appName and baseURL appear in every message. appName is the edition's own
|
||||
// name — an invite from an urd instance that said "verdande" would be a letter
|
||||
// to somebody else's inbox about a program they are not being invited to.
|
||||
//
|
||||
// It was a field with a value and no reader until 2026-10-07: the three places
|
||||
// that needed it had the word written out instead, and the comment above said
|
||||
// it appeared in every message. It does now.
|
||||
appName string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL}
|
||||
func New(cfg config.SMTP, baseURL, appName string, log *slog.Logger) *Sender {
|
||||
return &Sender{cfg: cfg, log: log, appName: appName, baseURL: baseURL}
|
||||
}
|
||||
|
||||
// Configured reports whether mail can actually be sent. Handlers use it to decide
|
||||
@@ -38,7 +44,7 @@ func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender {
|
||||
func (s *Sender) Configured() bool { return s.cfg.Configured() }
|
||||
|
||||
func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error {
|
||||
what := "verdande"
|
||||
what := s.appName
|
||||
if projectName != "" {
|
||||
what = "projektet “" + projectName + "”"
|
||||
}
|
||||
@@ -61,7 +67,7 @@ func (s *Sender) SendNoteInvite(ctx context.Context, to, inviterName, noteTitle,
|
||||
}
|
||||
|
||||
func (s *Sender) sendInvite(ctx context.Context, to, inviterName, what, link string, ttl time.Duration) error {
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName)
|
||||
subject := fmt.Sprintf("%s har delt noget med dig i %s", inviterName, s.appName)
|
||||
body := fmt.Sprintf(`Hej
|
||||
|
||||
%s har inviteret dig til %s.
|
||||
@@ -71,9 +77,9 @@ Opret din konto her:
|
||||
|
||||
Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, inviterName, what, link, humanDuration(ttl), s.baseURL)
|
||||
`, inviterName, what, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
@@ -88,17 +94,17 @@ Du bad om en påmindelse om:
|
||||
|
||||
%s
|
||||
|
||||
— verdande
|
||||
`, name, task, link)
|
||||
— %s
|
||||
`, name, task, link, s.appName)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error {
|
||||
subject := "Nulstil din adgangskode i verdande"
|
||||
subject := "Nulstil din adgangskode i " + s.appName
|
||||
body := fmt.Sprintf(`Hej %s
|
||||
|
||||
Der er bedt om at nulstille adgangskoden til din verdande-konto.
|
||||
Der er bedt om at nulstille adgangskoden til din %s-konto.
|
||||
|
||||
Vælg en ny adgangskode her:
|
||||
%s
|
||||
@@ -107,9 +113,9 @@ Linket virker i %s og kan kun bruges én gang.
|
||||
|
||||
Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv.
|
||||
|
||||
— verdande
|
||||
— %s
|
||||
%s
|
||||
`, name, link, humanDuration(ttl), s.baseURL)
|
||||
`, name, s.appName, link, humanDuration(ttl), s.appName, s.baseURL)
|
||||
|
||||
return s.send(ctx, to, subject, body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// TestEveryMessageCarriesTheEditionsOwnName.
|
||||
//
|
||||
// These three letters are the only thing this program sends to an address its
|
||||
// operator does not control, and an invitation from an urd instance that says
|
||||
// "verdande" is a letter to a stranger about a program they are not being invited
|
||||
// to. The name was a field with a value and no reader until 2026-10-07 — the three
|
||||
// places that needed it had the word written out instead — so the assertion that
|
||||
// matters is not "it says urd" but "it does not say the other one".
|
||||
//
|
||||
// Measured through the unconfigured path on purpose: with no SMTP host, send()
|
||||
// logs the whole message at warn level and returns, so the subject and the body
|
||||
// can be read as the recipient would get them without a mail server in the test.
|
||||
func TestEveryMessageCarriesTheEditionsOwnName(t *testing.T) {
|
||||
for _, tc := range []struct{ name, other string }{
|
||||
{"verdande", "urd"},
|
||||
{"urd", "verdande"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", tc.name,
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s.SendInvite(ctx, "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if err := s.SendPasswordReset(ctx, "ny@example.dk", "Kristian", "https://example.dk/r/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendPasswordReset: %v", err)
|
||||
}
|
||||
if err := s.SendReminder(ctx, "ny@example.dk", "Kristian", "Ring til tandlægen", "https://example.dk/opgave/1"); err != nil {
|
||||
t.Fatalf("SendReminder: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
if n := strings.Count(got, `msg="no SMTP host configured`); n != 3 {
|
||||
t.Fatalf("expected three logged messages, got %d — the test is reading the wrong thing", n)
|
||||
}
|
||||
if !strings.Contains(got, tc.name) {
|
||||
t.Errorf("no message mentions %q", tc.name)
|
||||
}
|
||||
if strings.Contains(got, tc.other) {
|
||||
t.Errorf("a message mentions %q on a %s instance; the name is hard-coded somewhere", tc.other, tc.name)
|
||||
}
|
||||
t.Logf("%s: %d bytes of mail across three messages", tc.name, len(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInviteWithoutAProjectNamesTheProgram pins the one place the name is the
|
||||
// *subject* of the sentence rather than a signature: an invitation to the instance
|
||||
// itself, with no project, reads "has invited you to <program>".
|
||||
func TestAnInviteWithoutAProjectNamesTheProgram(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s := New(config.SMTP{}, "https://example.dk", "urd",
|
||||
slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
|
||||
|
||||
if err := s.SendInvite(context.Background(), "ny@example.dk", "Kristian", "", "https://example.dk/i/abc", time.Hour); err != nil {
|
||||
t.Fatalf("SendInvite: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "inviteret dig til urd") {
|
||||
t.Errorf("the invite does not read \"inviteret dig til urd\":\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ extra_css:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Setting it up:
|
||||
- Two editions: editions.md
|
||||
- As a Rune: rune.md
|
||||
- With Docker: self-hosting.md
|
||||
- Configuration: configuration.md
|
||||
|
||||
@@ -118,3 +118,64 @@ test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
|
||||
// ovenstående bestå på en server, der var helt væk.
|
||||
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
|
||||
});
|
||||
|
||||
/**
|
||||
* Navnet.
|
||||
*
|
||||
* To udgaver af ét program er to produkter, og det eneste sted en læser afgør
|
||||
* hvilket af dem de ser på, er navnet: fanen, mærket i menuen, og det navn appen
|
||||
* lægger sig under på en hjemmeskærm. De tre står i filer, browseren læser FØR
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud — og derfor skal
|
||||
* de måles her, på det der faktisk blev sendt, frem for i en funktion.
|
||||
*/
|
||||
test('fanen, mærket og manifestet siger alle urd', async ({ page }) => {
|
||||
// Der er TO titler, og det blev opdaget her frem for i en Go-prøve: skallens,
|
||||
// som browseren læser før noget JavaScript kører og som serveren skriver om,
|
||||
// og rutens egen, som appen sætter i <svelte:head> når siden tegner. Den sidste
|
||||
// overskriver den første — så en prøve, der kun læste den ene, ville være grøn
|
||||
// over en fane, der sagde "Noter · verdande".
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal, 'skallens titel, før appen kører').toContain('<title>urd</title>');
|
||||
await expect(page, 'rutens egen titel, efter appen har tegnet').toHaveTitle(/· urd$/);
|
||||
|
||||
const mærke = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.brand');
|
||||
await expect(mærke.locator('.name')).toHaveText('urd');
|
||||
// ᚢ uruz, det bogstav navnet begynder med i den ældre futhark — og ikke ᚹ
|
||||
// wunjo, som er Verdandes. Begge er navngivne værdier frem for "der er en
|
||||
// rune": en tom span og den forkerte rune ser ens ud for en prøve, der kun
|
||||
// spørger om der står noget.
|
||||
await expect(mærke.locator('.rune')).toHaveText('ᚢ');
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('urd');
|
||||
expect(manifest.short_name).toBe('urd');
|
||||
});
|
||||
|
||||
test('manifestet bliver sendt som et manifest, ikke som tekst', async ({ page }) => {
|
||||
// Go kender ikke .webmanifest, så uden en eksplicit type sniffes den til
|
||||
// text/plain — og en browser ignorerer så manifestet uden at sige noget.
|
||||
// Symptomet er at installationsknappen bare ikke er der.
|
||||
const svar = await page.request.get('/manifest.webmanifest');
|
||||
expect(svar.headers()['content-type']).toContain('application/manifest+json');
|
||||
});
|
||||
|
||||
test('omdøbningen rørte ikke lagernøglerne', async ({ page }) => {
|
||||
// Fælden i hele øvelsen: produktnavnet og localStorage-præfikset er samme ord
|
||||
// i app.html, og de er ikke samme ting. Omdøbes præfikset her og ikke i
|
||||
// resten af appen, bliver temaet sat fra én nøgle og læst fra en anden, og det
|
||||
// eneste symptom er det hvide blink, det indlejrede script findes for at
|
||||
// undgå. Målt på de bytes der blev sendt, ikke på kilden.
|
||||
const skal = await (await page.request.get('/')).text();
|
||||
expect(skal).toContain("localStorage.getItem('verdande:theme')");
|
||||
expect(skal).toContain("localStorage.getItem('verdande:look')");
|
||||
expect(skal).not.toContain('urd:theme');
|
||||
});
|
||||
|
||||
test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
|
||||
// `{product}`-pladsholderen, hele vejen gennem i18n og ud på skærmen. Udseendet
|
||||
// "som du kender det" er opkaldt efter programmet, så det er det kort, der
|
||||
// skifter navn med udgaven.
|
||||
await page.goto('/indstillinger');
|
||||
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -5509,3 +5509,35 @@ test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ pag
|
||||
await page.unroute('**/api/v1/notes?*');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The control for the notes edition's renaming, and it belongs here rather than
|
||||
* beside it: the whole point is that the full edition does NOT go through that
|
||||
* path, and that can only be measured on an instance which is not the notes one.
|
||||
*
|
||||
* A gate asserted in one direction is half a gate. edition.spec.js proves the
|
||||
* notes edition calls itself urd; without this, a rename that fired in both
|
||||
* editions would pass there and change the name of this program.
|
||||
*/
|
||||
test('the full edition still calls itself verdande', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Both titles, for the same reason edition.spec.js checks both: the shell's,
|
||||
// which the browser reads before any script runs, and the route's own, set in
|
||||
// <svelte:head> once the page draws. Today has no title of its own, so the
|
||||
// shell's is what stands here — and /noter has one, so it is asked separately.
|
||||
const shell = await (await page.request.get('/')).text();
|
||||
expect(shell).toContain('<title>verdande</title>');
|
||||
await expect(page).toHaveTitle('verdande');
|
||||
|
||||
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
|
||||
await expect(brand.locator('.name')).toHaveText('verdande');
|
||||
// ᚹ wunjo, not ᚢ uruz.
|
||||
await expect(brand.locator('.rune')).toHaveText('ᚹ');
|
||||
|
||||
await page.goto('/noter');
|
||||
await expect(page).toHaveTitle(/· verdande$/);
|
||||
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
|
||||
expect(manifest.name).toBe('verdande');
|
||||
expect(manifest.description).toBe('Opgaver og projekter, delt.');
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
{#if notice}
|
||||
<aside class="notice" role="region" aria-label={t('beacon.noticeTitle')}>
|
||||
<h2>{t('beacon.noticeTitle')}</h2>
|
||||
<p>{t('beacon.noticeBody')}</p>
|
||||
<p>{t('beacon.noticeBody', { product: app.productName })}</p>
|
||||
|
||||
<!-- Det, der bliver sendt, med de rigtige værdier i. Ikke et eksempel: det
|
||||
er selve meldingen, og den kan sammenholdes med indstillingssiden. -->
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
|
||||
<h2>{t('net.downTitle')}</h2>
|
||||
<p class="lede">{t('net.downBody')}</p>
|
||||
<p class="lede">{t('net.downBody', { product: app.productName })}</p>
|
||||
|
||||
<button onclick={again} disabled={trying}>
|
||||
{trying ? t('net.reconnecting') : t('net.downRetry')}
|
||||
|
||||
@@ -475,11 +475,13 @@
|
||||
|
||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||
<div class="brand">
|
||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
||||
the name is in the tab and everywhere else already. -->
|
||||
<span class="rune" aria-hidden="true">ᚹ</span>
|
||||
<span class="name">verdande</span>
|
||||
<!-- The mark: one rune, the letter the Norn's name starts with in the elder
|
||||
futhark — Wunjo for Verdande, Uruz for Urd. Both come from the store
|
||||
rather than from here, because the two editions of this program are two
|
||||
products with two names, and a wordmark is the one place a reader
|
||||
decides which one they are looking at. -->
|
||||
<span class="rune" aria-hidden="true">{app.productRune}</span>
|
||||
<span class="name">{app.productSlug}</span>
|
||||
</div>
|
||||
|
||||
<div class="views">
|
||||
|
||||
@@ -145,7 +145,7 @@ export const da = {
|
||||
// læserens mulighed for at efterprøve det.
|
||||
'beacon.noticeTitle': 'Denne installation melder, at den findes',
|
||||
'beacon.noticeBody':
|
||||
'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'Én gang i døgnet sender {product} to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
|
||||
'beacon.noticeTo': 'Sendes til {url}',
|
||||
'beacon.noticeKeep': 'Behold den',
|
||||
'beacon.noticeStop': 'Slå den fra',
|
||||
@@ -238,7 +238,7 @@ export const da = {
|
||||
'net.reconnecting': 'Genopretter forbindelse…',
|
||||
'net.offline': 'Ingen forbindelse',
|
||||
'net.downTitle': 'Ingen forbindelse',
|
||||
'net.downBody': 'Verdande kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downBody': '{product} kan ikke nås herfra lige nu. Din tekst er ikke væk — den ligger på serveren, og den kommer igen, når forbindelsen gør.',
|
||||
'net.downRetry': 'Prøv igen',
|
||||
'nav.doItToday': 'Gør det i dag',
|
||||
|
||||
@@ -353,7 +353,7 @@ export const da = {
|
||||
'size.default': 'Standard',
|
||||
'size.large': 'Stor',
|
||||
'size.xl': 'Størst',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'Som du kender den.',
|
||||
'look.rolig': 'Rolig',
|
||||
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||
|
||||
@@ -142,7 +142,7 @@ export const en = {
|
||||
// the reader's ability to check it.
|
||||
'beacon.noticeTitle': 'This installation reports that it exists',
|
||||
'beacon.noticeBody':
|
||||
'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'Once a day {product} sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
|
||||
'beacon.noticeTo': 'Sent to {url}',
|
||||
'beacon.noticeKeep': 'Keep it on',
|
||||
'beacon.noticeStop': 'Turn it off',
|
||||
@@ -235,7 +235,7 @@ export const en = {
|
||||
'net.reconnecting': 'Reconnecting…',
|
||||
'net.offline': 'No connection',
|
||||
'net.downTitle': 'No connection',
|
||||
'net.downBody': 'Verdande cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downBody': '{product} cannot be reached from here right now. Nothing you wrote is lost — it is on the server, and it comes back when the connection does.',
|
||||
'net.downRetry': 'Try again',
|
||||
'nav.doItToday': 'Do it today',
|
||||
|
||||
@@ -350,7 +350,7 @@ export const en = {
|
||||
'size.default': 'Default',
|
||||
'size.large': 'Large',
|
||||
'size.xl': 'Largest',
|
||||
'look.verdande': 'Verdande',
|
||||
'look.verdande': '{product}',
|
||||
'look.verdandeNote': 'As you know it.',
|
||||
'look.rolig': 'Calm',
|
||||
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||
|
||||
@@ -64,6 +64,35 @@ class AppState {
|
||||
return this.edition === 'notes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navnet instansen kalder sig selv — `Urd` i en sætning, `urd` i et mærke.
|
||||
*
|
||||
* To udgaver af ét program, to navne. Grunden til at det læses fra udgaven
|
||||
* frem for at stå skrevet i teksterne: for den der bruger en instans uden
|
||||
* opgaveruter er det et andet produkt, og et produkt, der kalder sig sin
|
||||
* søsters navn, er ikke et produkt — det er et flag, nogen har sat.
|
||||
*
|
||||
* Vinduets titel og manifestet står der IKKE. De læses af browseren, før
|
||||
* noget JavaScript kører, så serveren skriver dem om på vejen ud
|
||||
* (`internal/httpapi/shell.go`). Gjorde vi det her, ville hver indlæsning
|
||||
* vise søsterens navn et øjeblik og så rette sig selv.
|
||||
*/
|
||||
get productName() {
|
||||
return this.notesOnly ? 'Urd' : 'Verdande';
|
||||
}
|
||||
|
||||
get productSlug() {
|
||||
return this.notesOnly ? 'urd' : 'verdande';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mærket: én rune, det bogstav navnet begynder med i den ældre futhark.
|
||||
* ᚹ wunjo for Verdande, ᚢ uruz for Urd.
|
||||
*/
|
||||
get productRune() {
|
||||
return this.notesOnly ? '\u16A2' : '\u16B9';
|
||||
}
|
||||
|
||||
/** Transient messages: a failed save, a rolled-back change. */
|
||||
toasts = $state([]);
|
||||
connected = $state(false);
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('done.title')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('done.title')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
|
||||
<svelte:head><title>{group?.name ?? t('group.projects')} — {app.productSlug}</title></svelte:head>
|
||||
|
||||
<div class="view">
|
||||
{#if status === 'denied'}
|
||||
|
||||
@@ -379,7 +379,10 @@
|
||||
<!-- Each card is written in the face it selects, because that is the
|
||||
only part of the choice a name cannot carry. -->
|
||||
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
||||
<span class="theme-name">{t(option.name)}</span>
|
||||
<!-- The product name is passed to every look, and only one of them has a
|
||||
place for it: the look named after the program. The other three
|
||||
carry no placeholder, so the parameter is ignored. -->
|
||||
<span class="theme-name">{t(option.name, { product: app.productName })}</span>
|
||||
<span class="theme-note">{t(option.note)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
* and it reads the token out of the URL itself.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
|
||||
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
|
||||
// pointen med den her rute.
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Invitation · verdande</title></svelte:head>
|
||||
<svelte:head><title>Invitation · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
|
||||
@@ -933,7 +933,7 @@
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||
<svelte:head><title>{t('notes.title')} · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onclick={onShareOutside}
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
* fill in. Same shape as the invite link, and found the same way.
|
||||
*/
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
// Udgavens navn i titlen. Siden her tegner først, når layoutets `app.load()`
|
||||
// har svaret, så udgaven er kendt — også uden en session, hvilket er hele
|
||||
// pointen med den her rute.
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Ny adgangskode · verdande</title></svelte:head>
|
||||
<svelte:head><title>Ny adgangskode · {app.productSlug}</title></svelte:head>
|
||||
|
||||
<SignIn />
|
||||
|
||||
Reference in New Issue
Block a user