| 1 | // fixtures/fonts.mjs — the font vocabulary, in one place. |
| 2 | // |
| 3 | // Three faces, one of which is the default. That last word is the whole |
| 4 | // reason this file exists, because the default is not a property of any |
| 5 | // face: it is a decision, held in four places that cannot see each |
| 6 | // other, and until it moved nothing had to state it. |
| 7 | // |
| 8 | // - views/show.html's `[docs-text]` rule, which is what an unmarked |
| 9 | // run is set in on screen; |
| 10 | // - internal/export/pdf.go's styleFor, which is what an unmarked run |
| 11 | // is set in on paper; |
| 12 | // - internal/fonts' `Default`, which names the face those two must |
| 13 | // agree on; |
| 14 | // - and this module, which is what the toolbar SAYS an unmarked run |
| 15 | // is set in. |
| 16 | // |
| 17 | // A toolbar disagreeing with the other three is the quiet failure. It |
| 18 | // does not throw and it does not lose anything: the font control simply |
| 19 | // reads "Serif" over text that is drawn in sans, and a person who wants |
| 20 | // serif selects it, watches the control already say Serif, and gets |
| 21 | // nothing. internal/docs's TestTheToolbarDefaultFontIsTheDefaultFace is |
| 22 | // what holds this file's DEFAULT_FONT to internal/fonts' Default, since |
| 23 | // Go and Node cannot import each other. |
| 24 | // |
| 25 | // The rule the whole vocabulary turns on: THE DEFAULT CARRIES NO MARK. |
| 26 | // Choosing it clears the group rather than setting anything, so a |
| 27 | // document only ever contains a font mark where somebody asked for a |
| 28 | // face other than the one they would have got anyway. That is what |
| 29 | // makes a document written before any of this existed render as today's |
| 30 | // default, and it is why moving the default is a one-line change here |
| 31 | // with real consequences rather than a cosmetic relabelling. |
| 32 | |
| 33 | // The faces a person can choose, in the order the toolbar shows them — |
| 34 | // default first, because a <select> shows its first option until |
| 35 | // something sets a value, and with no JavaScript nothing ever does. |
| 36 | export const FONTS = ['sans', 'serif', 'mono'] |
| 37 | |
| 38 | // The face an unmarked run is in. |
| 39 | // |
| 40 | // One line, and moving it changes what most of the text in most |
| 41 | // documents is set in — on screen, on paper, and in what this control |
| 42 | // says. It is not a preference expressed here: internal/fonts' Default |
| 43 | // is the one that governs layout, and internal/docs's |
| 44 | // TestTheToolbarDefaultFontIsTheDefaultFace fails if this line and that |
| 45 | // constant name different faces. |
| 46 | export const DEFAULT_FONT = 'sans' |
| 47 | |
| 48 | // Every font mark, including the default's. The default's mark is never |
| 49 | // written by the toolbar, but it is real, and the reason is the reverse |
| 50 | // of the obvious one: no document written before the default moved can |
| 51 | // contain font_serif, because serif WAS the absence of a mark then. |
| 52 | // What those documents contain is font_sans — the mark of the face that |
| 53 | // is now the default — written by everyone who chose sans while it was |
| 54 | // still a choice. That mark has to go on meaning sans and has to be |
| 55 | // cleared like any other. A paste can bring any of the three in, and |
| 56 | // POST /d/{sqid}/updates can carry anything at all. So the group this |
| 57 | // clears, and the group fontForMarks reads, is all three. |
| 58 | // |
| 59 | // The ORDER is mono, sans, serif, and it is not a listing preference: |
| 60 | // it is the order the other two renderers resolve a run carrying more |
| 61 | // than one font mark. core/src/fragment.rs pushes its spans in MARKS |
| 62 | // order (serif, sans, mono) and closes them reversed, so mono ends up |
| 63 | // innermost and the innermost family wins the cascade; |
| 64 | // internal/export/pdf.go's styleFor tests font_mono first and reaches |
| 65 | // the same answer. fontForMarks below returns its first hit, so this |
| 66 | // list has to run the same way round or the control names a face the |
| 67 | // text is not drawn in. |
| 68 | export const FONT_MARKS = ['font_mono', 'font_sans', 'font_serif'] |
| 69 | |
| 70 | // fontMark maps a chosen face to the mark to set: null for the default, |
| 71 | // which is spelled as the absence of one, and undefined for a value |
| 72 | // this module does not know. |
| 73 | // |
| 74 | // undefined and null are different answers on purpose. null means |
| 75 | // "clear the group", which is a real edit; undefined means "this is not |
| 76 | // one of ours", and a caller that treated it as null would clear a |
| 77 | // person's font because a stale page sent an option this build dropped. |
| 78 | export function fontMark(value) { |
| 79 | if (!FONTS.includes(value)) return undefined |
| 80 | if (value === DEFAULT_FONT) return null |
| 81 | return 'font_' + value |
| 82 | } |
| 83 | |
| 84 | // fontForMarks is the inverse, and it is what keeps the control honest: |
| 85 | // given the font marks active on the selection, which face is it in. |
| 86 | // No marks means the default — not "unknown", and not whichever face |
| 87 | // used to be the default when this line was written. |
| 88 | // |
| 89 | // FONT_MARKS order decides a run carrying two font marks, which the |
| 90 | // schema's own `excludes` makes impossible through the editor and |
| 91 | // POST /d/{sqid}/updates makes possible anyway. Not arbitrary: it is |
| 92 | // the order both renderers already resolve such a run in, so the answer |
| 93 | // here is the face actually drawn rather than merely a stable guess. |
| 94 | // See FONT_MARKS above for the mechanism on each side. |
| 95 | export function fontForMarks(names) { |
| 96 | for (const mark of FONT_MARKS) { |
| 97 | if (names.includes(mark)) return mark.slice('font_'.length) |
| 98 | } |
| 99 | return DEFAULT_FONT |
| 100 | } |
| 101 | |
| 102 | // Every face has a mark, every mark has a face, the default is one of |
| 103 | // them, and it is the one the scriptless <select> shows. |
| 104 | // |
| 105 | // Runs at module load, like toolbar.mjs's alignmentsAreCovered and for |
| 106 | // the reason that one's comment gives at length: a guard that only runs |
| 107 | // when a browser mounts the editor is dead code in the gate, so a |
| 108 | // vocabulary that had drifted would pass CI and throw for every user. |
| 109 | // fonts.test.mjs imports this module, which is what makes this call |
| 110 | // execute on every gate run. |
| 111 | function fontsAreCovered() { |
| 112 | const marked = FONT_MARKS.map((m) => m.slice('font_'.length)) |
| 113 | if ([...marked].sort().join() !== [...FONTS].sort().join()) { |
| 114 | throw new Error(`fonts: marks [${marked}] do not match the faces [${FONTS}]`) |
| 115 | } |
| 116 | if (!FONTS.includes(DEFAULT_FONT)) { |
| 117 | throw new Error(`fonts: the default ${JSON.stringify(DEFAULT_FONT)} is not one of [${FONTS}]`) |
| 118 | } |
| 119 | if (FONTS[0] !== DEFAULT_FONT) { |
| 120 | throw new Error( |
| 121 | `fonts: the toolbar lists ${JSON.stringify(FONTS[0])} first but the default is ` + |
| 122 | `${JSON.stringify(DEFAULT_FONT)} — with no JavaScript the control would name a face the text is not in`, |
| 123 | ) |
| 124 | } |
| 125 | // There is deliberately no fourth check that fontMark(DEFAULT_FONT) |
| 126 | // is null. One was written here and deleted: fontMark returns null |
| 127 | // exactly when its argument is in FONTS and equals DEFAULT_FONT, and |
| 128 | // the check above has already thrown if DEFAULT_FONT is not in FONTS |
| 129 | // — so it compared a value to its own derivation and could not throw |
| 130 | // for ANY value of DEFAULT_FONT. Verified rather than reasoned: |
| 131 | // running fontMark over every face and over three non-faces makes the |
| 132 | // condition false in all six cases. |
| 133 | // |
| 134 | // This is the eighth assertion found in this repo unable to fail |
| 135 | // against the thing it names, and all eight have had one shape — a |
| 136 | // value compared to itself, or to something derived from itself. "The |
| 137 | // default is spelled as the absence of a mark" is a property of |
| 138 | // fontMark's own definition, so the place to state it is that |
| 139 | // function, and the place to TEST it is fonts.test.mjs, where the |
| 140 | // expectation is a literal. |
| 141 | } |
| 142 | |
| 143 | fontsAreCovered() |
| 144 | |