oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "amadan.net/oficina/estilo.git/sky"
5 "bytes"
6 "embed"
7 "fmt"
8 "html/template"
9 "io/fs"
10 "net/http"
11 "testing/fstest"
12
13 "amadan.net/oficina/estilo.git/base"
14 "amadan.net/oficina/estilo.git/busy"
15 "amadan.net/oficina/estilo.git/chrome"
16 estilomenu "amadan.net/oficina/estilo.git/menu"
17 "amadan.net/oficina/estilo.git/savestatus"
18 "amadan.net/oficina/estilo.git/theme"
19 "amadan.net/rastrillo/rastrillo"
20 "amadan.net/rastrillo/rastrillo/ui"
21)
22
23// There is no rastrillo shell any more. The topbar shell's bar hard-coded
24// an "Account" summary and a tail that wrapped (45px on the public page,
25// 51px elsewhere, 96px at 390px), and estilo may not restyle rastrillo's
26// markup, so every page now renders views/layout.html with estilo's own
27// bar (chrome.go builds its data). That is the change the whole suite
28// makes in round 2; Docs is the first to make it.
29
30// Catalog is CARLOS Docs' own English strings. Every user-visible
31// string this app writes is a key here (never a literal in a
32// template or a handler), resolved through rastrillo.T at render
33// time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog.
34var Catalog = rastrillo.Catalog{
35 // docs.home.* — connecting a Docs account to the suite account. The
36 // hub is never named ("Home" leaves user copy, spec §10 and §13 Q3):
37 // the account is "your Oficina account", and the verb stays Connect.
38 "docs.home.start_title": "Connect your Oficina account",
39 "docs.home.start_body": "Use your Oficina account to sign in to this Docs account. Your documents and sharing settings stay as they are.",
40 "docs.home.start_action": "Connect",
41 "docs.home.cancel_action": "Back to Docs",
42 "docs.home.confirm_title": "Confirm account connection",
43 "docs.home.confirm_body": "Connect the Docs account below to the Oficina account you just signed in to.",
44 "docs.home.confirm_action": "Connect accounts",
45 "docs.home.unknown_title": "Oficina account not connected",
46 "docs.home.unknown_body": "Sign in to your existing Docs account, then connect your Oficina account from the account menu.",
47 "docs.home.local_action": "Sign in to Docs",
48 "docs.home.failed_title": "Could not connect accounts",
49 "docs.home.failed_body": "Return to Docs and start again.",
50 "docs.home.success_title": "Accounts connected",
51 "docs.home.success_body": "You can now use your Oficina account to sign in to this Docs account.",
52 "docs.home.documents_action": "Back to documents",
53 "docs.home.menu_action": "Connect your Oficina account",
54
55 // docs.list.* — GET /, spec §6.4. Title is the screen's noun, per
56 // page-header's own contract ("Posts", not "Home"). The four group
57 // labels are recency buckets (handlers.go's recencyBucket); a
58 // document's own title and its row's timestamp are data, not
59 // authored copy, so neither is a catalog key.
60 "docs.share.title": "Share",
61 // docs.share.action is the bar's action on a document (spec §7.3: the
62 // ellipsis, because it opens a form before anything happens).
63 "docs.share.action": "Share…",
64 "docs.share.heading": "Who can open this",
65 "docs.share.address": "Email address",
66 "docs.share.role": "They can",
67 "docs.share.role.reader": "Read it",
68 "docs.share.role.writer": "Read and edit it",
69 "docs.share.submit": "Share",
70 "docs.share.revoke": "Stop sharing",
71 "docs.share.back": "Back to the document",
72 "docs.share.empty": "Only you can open this document.",
73 "docs.share.error.address": "Type the email address of the person you want to share with.",
74
75 // docs.share.public.* is the third rung — a read-only link anyone
76 // can follow, signed in or not (suite doctrine ruling 15).
77 //
78 // The body says "anyone with the link", not "the public": the
79 // distinction is the whole security model of a capability, and an
80 // owner who reads it as "listed somewhere" or as "safe because it is
81 // unguessable" is being told the wrong thing either way. It also
82 // says read, twice over with the heading, because the one question
83 // an owner has about this switch is whether a stranger can change
84 // their document.
85 //
86 // The off label is "Stop publishing" rather than "Turn off": what it
87 // does is kill the URL, and a person who has handed that URL to
88 // somebody deserves a verb that says so. It pairs with
89 // docs.share.revoke's "Stop sharing" by design — one vocabulary for
90 // taking access away, whichever rung it was granted on.
91 "docs.share.public.heading": "Anyone with a link",
92 "docs.share.public.body": "Publish a link that anyone can open to read this document. They will not be able to change it, and they will not need to sign in.",
93 "docs.share.public.on": "Publish a link",
94 "docs.share.public.off": "Stop publishing",
95 "docs.share.public.hint": "Publishing again makes a new link, and the old one stops working.",
96
97 // docs.share.public.once is the one-time notice, and the copy has to
98 // carry a real instruction because the app genuinely cannot repeat
99 // itself: it stores a hash of the token, not the token (suite ruling
100 // 16). "You will not see it again" is a fact here, not a scare.
101 //
102 // docs.share.public.published is what the section says on every later
103 // visit — published, without the URL, which is the shape of the whole
104 // ruling in one line of copy.
105 "docs.share.public.once": "Copy this link now. This is the only time it will be shown.",
106 "docs.share.public.published": "This document is published. The link was shown once when it was created and cannot be shown again — replace it if you no longer have it.",
107 "docs.share.public.replace": "Replace the link",
108 // The one-time link's field and its Copy button (share.html). "Link
109 // copied." is the suite's wording for the same confirmation (Calls).
110 "docs.share.public.url": "Published link",
111 "docs.share.public.copy": "Copy link",
112 "docs.share.public.copied": "Link copied.",
113
114 // docs.public.readonly is the standing notice on the published page
115 // itself (views/public.html). It is there because a person arriving
116 // from a pasted link has no other way to know they are looking at a
117 // copy they cannot edit — the page has no editor to try, so without
118 // this the absence reads as a page that failed to load its own
119 // controls.
120 "docs.public.readonly": "You are reading a shared copy of this document.",
121
122 "docs.list.title": "Documents",
123 "docs.list.group.today": "Today",
124 "docs.list.group.yesterday": "Yesterday",
125 "docs.list.group.week": "This week",
126 "docs.list.group.earlier": "Earlier",
127
128 // docs.list.empty.* — the empty state (Paul's ruling: "An empty
129 // list is a designed screen, not a blank page").
130 "docs.list.empty.title": "No documents yet",
131 "docs.list.empty.body": "Documents you create will show up here, newest first.",
132
133 // docs.list.create.* — the create control in list.html's page
134 // header, present whether or not Groups is empty (fix round 1:
135 // the plan had scoped a create *control* out of every task, so the
136 // screen this plan exists for could not be driven from a browser
137 // at all). label is both the submit button's text and the title
138 // field's accessible name; placeholder is that field's hint text.
139 "docs.list.create.label": "New document",
140 "docs.list.create.placeholder": "Title",
141
142 // docs.list.search.* — finding a document by its title (spec
143 // §15.2). The field is a GET form, so a search is a link and works
144 // with no JavaScript; fixtures/jump-to.mjs adds the keyboard
145 // shortcut on top of it and takes its own copy from here rather
146 // than carrying any.
147 //
148 // "Search documents" rather than "Search": the field sits beside a
149 // create field on the same header, and a bare "Search" next to a
150 // bare "Title" says nothing about which list is being searched.
151 // The empty state names the query back, because "nothing found" and
152 // "nothing found FOR THIS" are different amounts of help when you
153 // have mistyped.
154 "docs.list.search.label": "Search documents",
155 "docs.list.search.placeholder": "Search",
156 "docs.list.search.none.title": "No documents match",
157 "docs.list.search.none.body": "Nothing here has that in its title. Check the spelling, or try a shorter word.",
158 "docs.list.search.clear": "Clear search",
159
160 // docs.jumpto.* — the keyboard jump-to palette
161 // (fixtures/jump-to.mjs), opened with Control-K or Command-K from
162 // the list or from a document. Every string is rendered into the
163 // page server-side, the same rule the editor's save-status copy
164 // and the formatting bar follow: the bundle carries no copy of its
165 // own to keep in sync with this file by hand.
166 "docs.jumpto.label": "Jump to a document",
167 "docs.jumpto.placeholder": "Search documents",
168 "docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.",
169 "docs.jumpto.empty": "No documents match",
170 "docs.team.partial": "We couldn’t check your team access. Only documents shared with you are shown.",
171 "docs.jumpto.failed": "Search is unavailable right now.",
172 "docs.jumpto.close": "Close",
173
174 // docs.app.name is the brand in the bar and the last part of every
175 // <title> (spec §10): the app's name, never "Documents", which is the
176 // list's heading.
177 "docs.app.name": "Docs",
178
179 // docs.chrome.* is the estilo bar and account menu (chrome.go).
180 "docs.chrome.skip": "Skip to content",
181 "docs.chrome.menu": "Menu",
182 "docs.chrome.signin": "Sign in",
183 "docs.chrome.account": "Account: {address}",
184 "docs.chrome.account.anonymous": "Account",
185 "docs.chrome.signedinas": "Signed in as",
186 "docs.chrome.appearance": "Appearance",
187 "docs.chrome.appearance.light": "Light",
188 "docs.chrome.appearance.dark": "Dark",
189 "docs.chrome.appearance.system": "System",
190 "docs.chrome.help": "Help",
191 // The switcher. "All apps" rather than "Oficina": as a menu label,
192 // the suite's name collides with the brand on the hub's own bar
193 // (spec §13 Q2, Paul's answer).
194 "docs.chrome.apps": "Oficina apps",
195 "docs.chrome.allapps": "All apps",
196 "docs.chrome.thisapp": "(this app)",
197 // The editable title in the bar (spec §5.4, §13 Q7).
198 "docs.chrome.title.label": "Document title",
199 "docs.chrome.title.rename": "Rename",
200
201 // docs.error.page.* is the error page (errors.go, spec §3.2). Each
202 // status has a tab title, a heading and one sentence; {app} is the
203 // app's name. A document that is missing and one not shared with you
204 // are one page, so neither can be told from the other.
205 "docs.error.page.404.title": "Page not found",
206 "docs.error.page.404.heading": "We can’t find that page",
207 "docs.error.page.404.body": "The link may be out of date, or the page may have moved.",
208 "docs.error.page.document.title": "Document not available",
209 "docs.error.page.document.heading": "This document isn’t available",
210 "docs.error.page.document.body": "It may have been deleted, or it hasn’t been shared with you.",
211 "docs.error.page.403.title": "No access",
212 "docs.error.page.403.heading": "You don’t have access to this page",
213 "docs.error.page.403.body": "Ask whoever runs this team if you think you should.",
214 "docs.error.page.400.title": "Something went wrong",
215 "docs.error.page.400.heading": "That didn’t work",
216 "docs.error.page.400.body": "Go back and try again.",
217 "docs.error.page.500.title": "Something went wrong",
218 "docs.error.page.500.heading": "Something went wrong",
219 "docs.error.page.500.body": "Try again in a moment. If it keeps happening, quote the reference below.",
220 "docs.error.page.503.title": "{app} is unavailable",
221 "docs.error.page.503.heading": "{app} is unavailable right now",
222 "docs.error.page.503.body": "Try again in a few minutes.",
223 "docs.error.home": "Go to {app}",
224 "docs.error.retry": "Try again",
225 "docs.error.back": "Back",
226 "docs.error.ref": "Reference:",
227
228 // docs.error.generic is the one fallback error string this task
229 // has: a render failure with nowhere styled to land yet (SKILL.md
230 // §7's view.Fail/ErrorPage wiring is later work). Still a catalog
231 // key, never a literal, even though it is plain text today.
232 "docs.error.generic": "Something went wrong.",
233
234 // docs.error.notfound answers GET /d/{sqid} for an unknown sqid —
235 // spec §4.2's 404-not-403 contract. Same "nowhere styled to land
236 // yet" caveat as docs.error.generic above.
237 "docs.error.notfound": "Not found.",
238
239 // docs.error.badupdate answers POST /d/{sqid}/updates when the
240 // posted bytes are not a Yjs update the core will accept — a
241 // client error (400), not a render failure, but still a catalog
242 // key rather than a literal in the handler (Task 5).
243 "docs.error.badupdate": "That update could not be applied.",
244
245 // docs.error.updatetoolarge answers POST /d/{sqid}/updates when
246 // the body exceeds maxUpdateBytes (handlers.go) — refused before
247 // it is read into memory (http.MaxBytesReader), so this is what a
248 // browser sees for it (Task 5).
249 "docs.error.updatetoolarge": "That update is too large.",
250
251 // docs.error.badsince answers GET /d/{sqid}/updates when the
252 // ?since= cursor is not a non-negative integer (handlers.go's
253 // parseSince). Only a broken or hand-typed client can produce it —
254 // the editor's own cursor comes from a Docs-Seq header this app
255 // wrote — so this is a key nobody should ever see, kept a catalog
256 // key anyway because "nobody should see it" is exactly the
257 // reasoning that leaves an untranslated literal in the codebase.
258 "docs.error.badsince": "That update cursor is not valid.",
259
260 // docs.document.untitled is POST /documents' fallback title when
261 // the submitted one is empty or all whitespace — boilerplate copy
262 // this app writes, not the caller's own input, so it is a catalog
263 // key rather than a hardcoded default.
264 "docs.document.untitled": "Untitled document",
265
266 // docs.signin.* — GET /signin (auth.go's signin handler): the
267 // form itself, and every outcome rastrillo/auth's own handlers
268 // redirect back here with (?sent=1,
269 // ?err=rate|address|expired|keymail|1). docs.signin.error.unknown
270 // is that switch's own default: branch (fix round 1 on Task 3):
271 // every one of these outcomes means the same operational thing on
272 // this one page — sign-in did not complete, ask for a new link —
273 // so its fallback must not borrow docs.error.generic ("Something
274 // went wrong."), an app-wide string written for a render failure
275 // with nowhere else to land, not for this page's own normal,
276 // recoverable outcomes. See auth.go's signin handler for why no
277 // finer distinction between ?err=keymail and ?err=1 is drawn
278 // either (rastrillo/auth's own handlers already refuse to be an
279 // oracle between "wrong" and "unknown").
280 //
281 // The door's own words (heading, label, help, submit, the sent state
282 // and the signed-out page) are estilo's chrome.English, not keys here:
283 // one copy set for the suite. What stays is this app's: the outcome
284 // errors, and the Keymail continuation's button.
285 "docs.signin.keymail": "Continue to Keymail",
286 "docs.signin.error.rate": "Too many attempts. Wait a few minutes, then try again.",
287 "docs.signin.error.address": "That doesn’t look like an email address.",
288 "docs.signin.error.expired": "That link has expired or was already used. Enter your email to get a new one.",
289 "docs.signin.error.unknown": "We couldn’t sign you in. Enter your email to get a new link.",
290 "docs.signin.error.refused": "That address can’t sign in to Docs. Use a different address, or ask whoever runs this team to add it.",
291
292 // docs.signout.label is the sign-out control's text — both its
293 // button label and its accessible name. It lives in the shell
294 // bar's account menu, which list.html and show.html each fill with
295 // an "account" block; it was in their two page headers until the
296 // chrome moved to the topbar shell (see shellLayout above).
297 // docs.list.shared marks a row on the list that belongs to somebody
298 // else. It exists because the list stopped being "your documents" the
299 // day sharing landed, and a row that looks like yours and is not is
300 // how a person opens a document expecting to be able to edit it.
301 "docs.list.shared": "Shared with you",
302
303 "docs.signout.label": "Sign out",
304
305 // docs.editor.status.* is the save-status indicator fix round 1's
306 // F2 added next to [docs-editor] (show.html), driven by
307 // fixtures/editor-entry.js's save queue and resync controller.
308 // retrying is the quiet state: a network hiccup or a 500 is being
309 // retried automatically, nothing is lost yet, so this stays
310 // understated. failed is the loud one: the core has permanently
311 // rejected a batch (a 4xx — e.g. a paste over maxUpdateBytes, or
312 // core.ErrUpdateLogFull once a document's log is full) and that
313 // batch has been dropped rather than retried forever or left to
314 // poison later edits (save-queue.mjs's own doc comment).
315 //
316 // failed's copy was wrong in fix round 1 ("Copy anything important
317 // before leaving this page"): by the time a person can read it,
318 // fixtures/editor-entry.js's resync has already discarded the
319 // diverged document and rebuilt the view from the server's own
320 // log (resync-controller.mjs) — the content that message told them
321 // to copy is no longer on screen to copy, and the advice pointed
322 // at staying put when a reload is the one thing that actually
323 // forces recovery sooner than the automatic retry would. Fix
324 // round 2 rewrote it to say what is true (something was lost, in
325 // the past tense — not a warning to act before it happens) and
326 // what helps (reload, the same fallback resync itself uses,
327 // available to a person immediately rather than waiting out
328 // resync-controller.mjs's own backoff).
329 "docs.editor.status.retrying": "Trouble saving. Retrying…",
330 "docs.editor.status.failed": "Some changes couldn't be saved and were lost. Reload the page if this keeps happening.",
331
332 // docs.editor.toolbar.* — the formatting bar (show.html's
333 // [docs-toolbar], driven by fixtures/toolbar.mjs). Every one of
334 // these is both a control's visible tooltip and its accessible
335 // name: the buttons' content is a glyph or an icon, which
336 // announces as nothing useful on its own.
337 //
338 // The names describe what a person gets, not what the schema calls
339 // it — "Body text" rather than "Paragraph", because the control
340 // sits next to three headings and the question it answers is
341 // "which of these is this line", and "Numbered list" rather than
342 // "Ordered list" for the same reason.
343 "docs.editor.toolbar.label": "Formatting",
344 "docs.editor.toolbar.undo": "Undo",
345 "docs.editor.toolbar.redo": "Redo",
346 "docs.editor.toolbar.bold": "Bold",
347 "docs.editor.toolbar.italic": "Italic",
348 "docs.editor.toolbar.code": "Code",
349 "docs.editor.toolbar.bulletlist": "Bulleted list",
350 "docs.editor.toolbar.orderedlist": "Numbered list",
351 "docs.editor.toolbar.quote": "Quote",
352 "docs.editor.toolbar.block": "Style",
353 "docs.editor.toolbar.block.paragraph": "Body text",
354 "docs.editor.toolbar.block.heading1": "Heading 1",
355 "docs.editor.toolbar.block.heading2": "Heading 2",
356 "docs.editor.toolbar.block.heading3": "Heading 3",
357 "docs.editor.toolbar.font": "Font",
358 "docs.editor.toolbar.font.serif": "Serif",
359 "docs.editor.toolbar.font.sans": "Sans serif",
360 "docs.editor.toolbar.font.mono": "Monospace",
361 "docs.editor.toolbar.size": "Size",
362 "docs.editor.toolbar.size.small": "Small",
363 "docs.editor.toolbar.size.normal": "Normal",
364 "docs.editor.toolbar.size.large": "Large",
365 "docs.editor.toolbar.size.extralarge": "Extra large",
366 // Alignment. Three buttons rather than a select, because unlike
367 // Style/Font/Size these are a small closed set a person switches
368 // between constantly, and because the current one is worth showing
369 // at a glance (aria-pressed) rather than only on opening a list.
370 //
371 // "Align left" and not "Left": the name is read on its own by a
372 // screen reader, out of any row, and "Left" answers no question.
373 "docs.editor.toolbar.align.left": "Align left",
374 "docs.editor.toolbar.align.center": "Align center",
375 "docs.editor.toolbar.align.right": "Align right",
376
377 // docs.editor.menu.* — the menu bar (show.html's [estilo-menubar],
378 // built by menu.go, given its keyboard behaviour by
379 // fixtures/menu.mjs).
380 //
381 // The six titles are the ones every word processor uses, and that
382 // is the whole argument for them: a menu bar is the one part of an
383 // editor where being unsurprising beats being expressive, because
384 // people arrive already knowing where Export lives. Nothing here
385 // tries to be a better word for File.
386 //
387 // Item labels name what a person gets, matching the rule the
388 // toolbar keys above already follow. Where a label exists in the
389 // toolbar catalog it is reused rather than restated — Bold, Undo,
390 // the alignments, the two exports — so the two surfaces cannot
391 // drift into calling the same command different things.
392 "docs.editor.menu.label": "Menus",
393 "docs.editor.menu.file": "File",
394 "docs.editor.menu.edit": "Edit",
395 "docs.editor.menu.view": "View",
396 "docs.editor.menu.format": "Format",
397 "docs.editor.menu.tools": "Tools",
398
399 "docs.editor.menu.file.new": "New document",
400 // The ellipsis (U+2026) marks an item that needs more before anything
401 // happens: a palette, a dialog (spec §7.3). An item that simply does
402 // its thing, or navigates, has none.
403 "docs.editor.menu.file.open": "Open a document…",
404 "docs.editor.menu.file.print": "Print…",
405
406 "docs.editor.menu.edit.selectall": "Select all",
407
408 "docs.editor.menu.view.paged": "Paged",
409 "docs.editor.menu.view.infinite": "Infinite",
410
411 "docs.editor.menu.format.indent": "Increase indent",
412 "docs.editor.menu.format.outdent": "Decrease indent",
413 // "Clear formatting" and not "Remove formatting": it is what every
414 // editor calls it, and the verb people search for.
415 "docs.editor.menu.format.clear": "Clear formatting",
416
417 "docs.editor.menu.tools.wordcount": "Word count…",
418 "docs.editor.menu.tools.shortcuts": "Keyboard shortcuts…",
419
420 // The number on a page break in paged mode. {n} is substituted by
421 // fixtures/pages.mjs.
422 //
423 // "Page 2" rather than a bare "2", because it sits alone on the
424 // desk between two sheets with nothing around it to say what the
425 // number counts.
426 "docs.editor.page.number": "Page {n}",
427
428 // docs.editor.wordcount.* — Tools → Word count, a native <dialog>.
429 //
430 // Four numbers, each labelled with what it counts rather than with a
431 // bare noun: "Characters" alone leaves a reader working out whether
432 // spaces are in it, which is the one thing anybody actually wants to
433 // know about a character count.
434 "docs.editor.wordcount.title": "Word count",
435 "docs.editor.wordcount.words": "Words",
436 "docs.editor.wordcount.characters": "Characters",
437 "docs.editor.wordcount.charactersnospaces": "Characters excluding spaces",
438 "docs.editor.wordcount.paragraphs": "Paragraphs",
439 "docs.editor.wordcount.selection": "Words in the selection",
440 "docs.editor.wordcount.close": "Close",
441
442 // docs.editor.shortcuts.* — Tools → Keyboard shortcuts.
443 //
444 // The rows are read out of the menu bar's own DOM
445 // (fixtures/shortcuts.mjs), so there are no per-shortcut strings
446 // here to keep in step. These two are the bindings NO menu item
447 // carries, and they are the two a person most needs told: Tab is
448 // repurposed inside the document, and Escape is how you leave it.
449 "docs.editor.shortcuts.title": "Keyboard shortcuts",
450 "docs.editor.shortcuts.close": "Close",
451 "docs.editor.shortcuts.editing": "Writing",
452 "docs.editor.shortcuts.indent": "Indent, or nest a list item",
453 "docs.editor.shortcuts.outdent": "Outdent, or lift a list item",
454 "docs.editor.shortcuts.undoformat": "Undo an autoformat, keeping what you typed",
455 "docs.editor.shortcuts.linebreak": "Break a line without starting a paragraph",
456 // The one a person cannot guess and most needs. Tab indents inside
457 // the document rather than moving focus, so this is the way out —
458 // WCAG 2.1.2 permits the repurposed key precisely because there is
459 // an exit the person has been told about, and this row is the
460 // telling.
461 "docs.editor.shortcuts.leave": "Leave the document for the toolbar",
462
463 // docs.export.* — the two download links, now items in the File
464 // menu (menu.go) rather than buttons pinned to the formatting bar.
465 // The long forms are what the items say AND their accessible names;
466 // the short forms are kept because [docs-doc-actions] is gone but
467 // the strings are still what a compact surface would want, and
468 // deleting a catalog key is not free.
469 "docs.export.pdf.short": "PDF",
470 "docs.export.pdf.label": "Download as PDF",
471 "docs.export.markdown.short": "Markdown",
472 "docs.export.markdown.label": "Download as Markdown",
473}
474
475// Locales and DefaultLocale declare this app's locale set for
476// cmd/docs/main.go's rastrillo.Options — one locale today. Without
477// Options.Locales set, rastrillo installs no locale middleware and
478// rastrillo.T falls back to returning its key verbatim (documented,
479// harmless — it's what a direct App.Handler() call in a test sees,
480// never having gone through that middleware); main.go wiring these in
481// is what makes Catalog's strings actually resolve on the real page.
482var Locales = []string{"en"}
483
484const DefaultLocale = "en"
485
486// viewsFS embeds this app's own page templates — SKILL.md's "one
487// *template.Template per page (layout + page)" pattern, the page half
488// as a real file per Task 3's file list rather than an inline Go
489// string (as Task 1's home page was): a page with a group loop and a
490// style block reads better as markup than as a quoted constant.
491//
492//go:embed views/*.html views/partials/*.html
493var viewsFS embed.FS
494
495// headTemplate is the app-wide "head" definition newRenderer parses
496// into the base template — the shell's own app slot in <head>, which
497// today carries the tab icon. Deliberately outside pageTemplates
498// below: it defines "head", never "content", so it is not a page and
499// must not be cloned per page.
500const headTemplate = "views/head.html"
501
502// layoutTemplate defines "layout", the document every page renders into.
503const layoutTemplate = "views/layout.html"
504
505// pageTemplates maps a page name to its file under views/.
506var pageTemplates = map[string]string{
507 "error": "views/error.html",
508 "home-link": "views/home_link.html",
509 "list": "views/list.html",
510 "public": "views/public.html",
511 "share": "views/share.html",
512 "show": "views/show.html",
513 "signin": "views/signin.html",
514}
515
516// renderer owns the app's parsed templates and its fingerprinted
517// static-asset registry.
518type renderer struct {
519 pages map[string]*template.Template
520 assets *rastrillo.Assets
521 // frame builds a page's chrome for the pages the renderer draws on
522 // its own (home_render.go); app.go sets it to handlers.frame.
523 frame func(*http.Request, frameOptions) Frame
524}
525
526// newRenderer builds the renderer: the vendored design-system CSS/JS
527// as an in-memory static tree, rastrillo's shell partials and layout,
528// and this app's own pages, each cloned from the same base so every
529// page shares helpers and the shell without sharing a "content"
530// definition.
531func newRenderer() (*renderer, error) {
532 staticFS, err := vendoredStaticFS()
533 if err != nil {
534 return nil, err
535 }
536 assets := rastrillo.NewAssets(staticFS)
537
538 base := template.New("").Funcs(ui.Funcs()).Funcs(template.FuncMap{
539 "asset": assets.Path,
540 })
541 base, err = base.ParseFS(ui.Templates(), "*.html")
542 if err != nil {
543 return nil, fmt.Errorf("docs: parse rastrillo ui partials: %w", err)
544 }
545
546 // estilo's menu and chrome templates (the bar, account menu,
547 // switcher, error body and sign-in pages), then this app's layout
548 // and "head", all into the base every page is cloned from.
549 if base, err = base.ParseFS(estilomenu.Templates(), "templates/*.html"); err != nil {
550 return nil, fmt.Errorf("docs: parse estilo menu templates: %w", err)
551 }
552 if base, err = base.ParseFS(chrome.Templates(), "templates/*.html"); err != nil {
553 return nil, fmt.Errorf("docs: parse estilo chrome templates: %w", err)
554 }
555 if base, err = base.ParseFS(viewsFS, layoutTemplate, headTemplate); err != nil {
556 return nil, fmt.Errorf("docs: parse layout: %w", err)
557 }
558
559 base, err = base.Parse(savestatus.Template())
560 if err != nil {
561 return nil, err
562 }
563
564 pages := make(map[string]*template.Template, len(pageTemplates))
565 for name, path := range pageTemplates {
566 page, err := base.Clone()
567 if err != nil {
568 return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err)
569 }
570 // This app's own shared partials (views/partials/*.html —
571 // today, the jump-to palette, which every signed-in page
572 // carries) are parsed into every page alongside it, the same
573 // way rastrillo's own partials are parsed into the base
574 // above. Per page rather than once into the base because the
575 // base is cloned, and a clone made before the partials were
576 // parsed would not have them.
577 if page, err = page.ParseFS(viewsFS, "views/partials/*.html"); err != nil {
578 return nil, fmt.Errorf("docs: parse shared partials for %q: %w", name, err)
579 }
580 if page, err = page.ParseFS(viewsFS, path); err != nil {
581 return nil, fmt.Errorf("docs: parse page %q: %w", name, err)
582 }
583 pages[name] = page
584 }
585
586 return &renderer{pages: pages, assets: assets}, nil
587}
588
589// page renders the named page's "layout" template — its own content
590// inside views/layout.html and the estilo bar — into w.
591func (rd *renderer) page(w http.ResponseWriter, r *http.Request, name string, data any) error {
592 return rd.pageStatus(w, r, name, data, 0)
593}
594
595func (rd *renderer) pageStatus(w http.ResponseWriter, r *http.Request, name string, data any, status int) error {
596 tmpl, ok := rd.pages[name]
597 if !ok {
598 return fmt.Errorf("docs: unknown page %q", name)
599 }
600 // Render into a buffer first: a mid-template error must not leave
601 // a half-written 200 on the wire.
602 var buf bytes.Buffer
603 if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
604 return fmt.Errorf("docs: render page %q: %w", name, err)
605 }
606 w.Header().Set("Content-Type", "text/html; charset=utf-8")
607 pageHeaders(w)
608 return writeGuardedBody(w, r, status, buf.Bytes())
609}
610
611// vendoredStaticFS builds the static/ tree rastrillo.Assets serves —
612// estilo's theme for Docs (Calma, with Docs' accent; estilo AGENTS.md,
613// decisions table, row 8), plus tokens.css and the three scripts
614// the shell's <head> links unconditionally (the shim and busy rule from
615// estilo's busy; ui.SelectJS/DatetimeJS)
616// taken straight from the selected rastrillo module. Nothing here is copied to
617// static/ by hand: rastrillo new would normally vendor these to disk
618// once at scaffold time (ui.VendoredAssets's own doc comment), but
619// this app was hand-built rather than scaffolded, so it takes the
620// same bytes at process start instead, from an in-memory fs.FS.
621// testing/fstest.MapFS is the standard library's only in-memory
622// fs.FS — used here as a data structure, not as a test helper.
623func vendoredStaticFS() (fs.FS, error) {
624 docsTheme, ok := theme.ForApp("docs")
625 if !ok {
626 return nil, fmt.Errorf("docs: estilo's theme ring has no docs")
627 }
628 files := fstest.MapFS{
629 "static/estilo-save-status.css": {Data: savestatus.CSS()},
630 "static/estilo-save-status.mjs": {Data: savestatus.JS()},
631 "static/tokens.css": {Data: ui.TokensCSS()},
632 "static/theme.css": {Data: docsTheme},
633 // rastrillo's busy rule (Paul, 2026-09-29): a submit button's
634 // label gives way to a spinner for at least 650ms, the button
635 // disables, and Back resets it. estilo carries rastrillo's shim
636 // without the old busy rule, busy.js and the spinner-in-place CSS.
637 "static/rastrillo.js": {Data: busy.ShimJS()},
638 "static/estilo/busy.js": {Data: busy.JS()},
639 "static/estilo/busy.css": {Data: busy.CSS()},
640 "static/select.js": {Data: ui.SelectJS()},
641 "static/datetime.js": {Data: ui.DatetimeJS()},
642 // estilo's shared chrome, fingerprinted like the theme.
643 "static/estilo/base.css": {Data: base.CSS()},
644 "static/estilo/menu.css": {Data: estilomenu.CSS()},
645 "static/estilo/chrome.css": {Data: chrome.CSS()},
646 // The sky behind the sign-in door; images from sky.Handler.
647 "static/estilo/sky.css": {Data: sky.CSS()},
648 "static/estilo/chrome.js": {Data: chrome.JS()},
649 }
650 for _, name := range []string{"icon-32.png", "apple-touch-icon.png", "brand.css", "LICENSE.lucide"} {
651 body, err := brandFS.ReadFile("brand/" + name)
652 if err != nil {
653 return nil, err
654 }
655 files["static/brand/"+name] = &fstest.MapFile{Data: body}
656 }
657 return files, nil
658}
659