oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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.
36export 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.
46export 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.
68export 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.
78export 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.
95export 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.
111function 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
143fontsAreCovered()
144