| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 34 | var 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. |
| 482 | var Locales = []string{"en"} |
| 483 | |
| 484 | const 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 |
| 493 | var 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. |
| 500 | const headTemplate = "views/head.html" |
| 501 | |
| 502 | // layoutTemplate defines "layout", the document every page renders into. |
| 503 | const layoutTemplate = "views/layout.html" |
| 504 | |
| 505 | // pageTemplates maps a page name to its file under views/. |
| 506 | var 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. |
| 518 | type 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. |
| 531 | func 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. |
| 591 | func (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 | |
| 595 | func (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. |
| 623 | func 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 | |