@openbrf/display 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +126 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +85 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +476 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +335 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +41 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +341 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +10 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +107 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +708 -0
  63. package/src/rotation.ts +515 -0
  64. package/src/slides.ts +595 -0
  65. package/src/styles.ts +113 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
package/src/styles.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The renderer's stylesheet.
3
+ *
4
+ * Its colours, type, shape and motion are the default theme's (Porttavlan,
5
+ * from `@openbrf/tokens`), so a screen looks like the association's own pages,
6
+ * and every pair it sets on another is one the token contract holds to WCAG
7
+ * AA. Dates, times and apartment numbers are in the data face, as everywhere
8
+ * else (DESIGN.md, the Mono-Grid Rule).
9
+ * A screen is read from across a stairwell, so every size is a fraction of the
10
+ * canvas's shorter side (`--obrf-display-u`, a hundredth of it), which the
11
+ * renderer sets from the element it is mounted on.
12
+ *
13
+ * Every class starts with `obrf-display`, so the sheet restyles nothing of the
14
+ * page it is mounted in.
15
+ */
16
+ import {
17
+ PORTTAVLAN_DARK,
18
+ PORTTAVLAN_LIGHT,
19
+ type TokenSet,
20
+ } from "@openbrf/tokens";
21
+ import { ELSEWHERE_ATTRIBUTE } from "./fit.ts";
22
+
23
+ function palette(tokens: TokenSet): string {
24
+ return [
25
+ `--obrf-display-ground: ${tokens["surface-page"]};`,
26
+ `--obrf-display-panel: ${tokens["surface-raised"]};`,
27
+ `--obrf-display-ink: ${tokens["text-primary"]};`,
28
+ `--obrf-display-muted: ${tokens["text-secondary"]};`,
29
+ `--obrf-display-accent: ${tokens["accent-trust"]};`,
30
+ `--obrf-display-danger: ${tokens["status-danger"]};`,
31
+ `--obrf-display-on-danger: ${tokens["on-status-danger"]};`,
32
+ `--obrf-display-rule: ${tokens["border-subtle"]};`,
33
+ `--obrf-display-font: ${tokens["font-ui"]};`,
34
+ `--obrf-display-data-font: ${tokens["font-data"]};`,
35
+ `--obrf-display-radius-control: ${tokens["radius-control"]};`,
36
+ `--obrf-display-radius-panel: ${tokens["radius-panel"]};`,
37
+ `--obrf-display-motion-duration: ${tokens["motion-duration"]};`,
38
+ `--obrf-display-motion-easing: ${tokens["motion-easing"]};`,
39
+ ].join(" ");
40
+ }
41
+
42
+ const u = (times: number): string => `calc(var(--obrf-display-u) * ${times})`;
43
+
44
+ /**
45
+ * The stylesheet `mountRenderer` adds to the document once, as a `<style>`
46
+ * element with the attribute `data-obrf-display`. Exported for a page that
47
+ * would rather serve it as a file of its own, as a page under a strict content
48
+ * security policy has to: it passes `stylesheet: false` to `mountRenderer` and
49
+ * links the file with the same attribute. Written for Chromium 85, so a
50
+ * selector list inside `:not()`, `inset` and the like stay out of it.
51
+ */
52
+ export const DISPLAY_STYLESHEET = `
53
+ .obrf-display { --obrf-display-u: 1vmin; position: relative; width: 100%; height: 100%; overflow: hidden; background: var(--obrf-display-ground); color: var(--obrf-display-ink); font-family: var(--obrf-display-font); }
54
+ .obrf-display--light { ${palette(PORTTAVLAN_LIGHT)} color-scheme: light; }
55
+ .obrf-display--dark { ${palette(PORTTAVLAN_DARK)} color-scheme: dark; }
56
+ .obrf-display [hidden] { display: none !important; }
57
+ .obrf-display [${ELSEWHERE_ATTRIBUTE}]:not(td):not(th) { display: none !important; }
58
+ .obrf-display *, .obrf-display *::before, .obrf-display *::after { box-sizing: border-box; }
59
+ .obrf-display-canvas { position: absolute; left: 50%; top: 50%; width: 100%; height: 100%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: ${u(2.5)}; padding: ${u(4)} ${u(5)}; font-size: ${u(3.4)}; line-height: 1.3; }
60
+ .obrf-display-clock { display: flex; justify-content: space-between; align-items: baseline; gap: ${u(3)}; padding-bottom: ${u(1.5)}; border-bottom: ${u(0.25)} solid var(--obrf-display-rule); color: var(--obrf-display-muted); font-size: ${u(3.6)}; }
61
+ .obrf-display-clock, .obrf-display-date, .obrf-display-when, .obrf-display-door-apartment, .obrf-display-data { font-family: var(--obrf-display-data-font); }
62
+ .obrf-display-clock-time { color: var(--obrf-display-ink); font-size: ${u(5)}; font-weight: 700; font-variant-numeric: tabular-nums; }
63
+ .obrf-display-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
64
+ .obrf-display-slide { display: flex; flex-direction: column; gap: ${u(2.5)}; height: 100%; min-height: 0; overflow: hidden; font-size: ${u(3.4)}; }
65
+ .obrf-display-slide--enter { animation: obrf-display-enter var(--obrf-display-motion-duration) var(--obrf-display-motion-easing); }
66
+ @keyframes obrf-display-enter { from { opacity: 0; } to { opacity: 1; } }
67
+ @media (prefers-reduced-motion: reduce) { .obrf-display-slide--enter { animation: none; } }
68
+ .obrf-display-heading { margin: 0; font-size: ${u(6.5)}; font-weight: 700; line-height: 1.1; overflow-wrap: anywhere; }
69
+ .obrf-display-subheading { margin: 0; font-size: ${u(4)}; font-weight: 700; color: var(--obrf-display-muted); }
70
+ .obrf-display-text { margin: 0; white-space: pre-line; overflow-wrap: anywhere; }
71
+ .obrf-display-muted { color: var(--obrf-display-muted); }
72
+ .obrf-display-date { margin: 0; color: var(--obrf-display-accent); font-weight: 700; }
73
+ .obrf-display-news { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; }
74
+ .obrf-display-news--picture { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: ${u(5)}; }
75
+ .obrf-display--portrait .obrf-display-news--picture { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
76
+ .obrf-display-copy { display: flex; flex-direction: column; gap: ${u(2)}; min-height: 0; overflow: hidden; }
77
+ .obrf-display-picture { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
78
+ .obrf-display-figure { display: flex; flex-direction: column; gap: ${u(2)}; height: 100%; margin: 0; }
79
+ .obrf-display-figure .obrf-display-picture { flex: 1 1 auto; }
80
+ .obrf-display-caption { font-size: ${u(4)}; text-align: center; }
81
+ .obrf-display-list { display: flex; flex-direction: column; gap: ${u(2)}; margin: 0; padding: 0; list-style: none; }
82
+ .obrf-display-event { display: grid; grid-template-columns: ${u(24)} minmax(0, 1fr); gap: ${u(3)}; padding-bottom: ${u(1.5)}; border-bottom: ${u(0.2)} solid var(--obrf-display-rule); }
83
+ .obrf-display-when { display: flex; flex-direction: column; color: var(--obrf-display-accent); font-weight: 700; font-variant-numeric: tabular-nums; }
84
+ .obrf-display-what { grid-column: 2; display: flex; flex-direction: column; gap: ${u(0.5)}; min-width: 0; }
85
+ .obrf-display-title { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
86
+ .obrf-display-event--called-off .obrf-display-title { text-decoration: line-through; }
87
+ .obrf-display-description { margin: 0; color: var(--obrf-display-muted); font-size: ${u(2.8)}; white-space: pre-line; overflow-wrap: anywhere; }
88
+ .obrf-display-badge { align-self: flex-start; padding: 0 ${u(1)}; border-radius: var(--obrf-display-radius-control); background: var(--obrf-display-danger); color: var(--obrf-display-on-danger); font-weight: 700; }
89
+ .obrf-display-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: ${u(1)} ${u(3)}; margin: 0; }
90
+ .obrf-display-facts dt { grid-column: 1; color: var(--obrf-display-muted); }
91
+ .obrf-display-facts dd { grid-column: 2; margin: 0; font-weight: 700; white-space: pre-line; overflow-wrap: anywhere; }
92
+ .obrf-display-agenda { margin: 0; padding: 0; list-style: none; columns: 2; column-gap: ${u(5)}; font-size: ${u(3)}; }
93
+ .obrf-display--portrait .obrf-display-agenda { columns: 1; }
94
+ .obrf-display-agenda li { break-inside: avoid; display: flex; gap: ${u(1.5)}; }
95
+ .obrf-display-agenda-position { min-width: ${u(4)}; color: var(--obrf-display-muted); font-variant-numeric: tabular-nums; }
96
+ .obrf-display-resources { display: grid; grid-template-columns: repeat(auto-fill, minmax(${u(52)}, 1fr)); gap: ${u(2)}; margin: 0; padding: 0; list-style: none; }
97
+ .obrf-display-resource { display: flex; flex-direction: column; gap: ${u(0.8)}; padding: ${u(1.5)} ${u(2)}; border-radius: var(--obrf-display-radius-panel); background: var(--obrf-display-panel); }
98
+ .obrf-display-day { display: flex; margin: 0; flex-wrap: wrap; gap: ${u(0.5)} ${u(1.5)}; font-size: ${u(2.8)}; }
99
+ .obrf-display-day-label { min-width: ${u(14)}; color: var(--obrf-display-muted); }
100
+ .obrf-display-busy { font-weight: 700; font-variant-numeric: tabular-nums; }
101
+ .obrf-display-table { width: 100%; border-collapse: collapse; }
102
+ .obrf-display-table th, .obrf-display-table td { padding: ${u(0.8)} ${u(1.5)}; border-bottom: ${u(0.2)} solid var(--obrf-display-rule); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
103
+ .obrf-display-table th { color: var(--obrf-display-muted); }
104
+ .obrf-display-doors { display: grid; grid-template-columns: repeat(auto-fill, minmax(${u(30)}, 1fr)); gap: ${u(1.5)}; margin: 0; padding: 0; list-style: none; font-size: ${u(2.8)}; }
105
+ .obrf-display-door { display: flex; flex-direction: column; padding: ${u(1)} ${u(1.5)}; border-radius: var(--obrf-display-radius-panel); background: var(--obrf-display-panel); }
106
+ .obrf-display-door-apartment { font-weight: 700; }
107
+ .obrf-display-message { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: ${u(3)}; height: 100%; text-align: center; }
108
+ .obrf-display-message-title { margin: 0; font-size: ${u(7)}; font-weight: 700; }
109
+ .obrf-display-empty { display: flex; align-items: center; justify-content: center; height: 100%; margin: 0; color: var(--obrf-display-muted); font-size: ${u(4)}; }
110
+ .obrf-display-message-detail { max-width: 40ch; margin: 0; color: var(--obrf-display-muted); font-size: ${u(4)}; }
111
+ .obrf-display-note { position: absolute; right: ${u(2)}; bottom: ${u(1.2)}; color: var(--obrf-display-muted); font-size: ${u(2.2)}; }
112
+ .obrf-display-off { position: absolute; top: 0; right: 0; bottom: 0; left: 0; z-index: 1; background: black; }
113
+ `;
package/src/text.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shortening a text to a limit of the contract, the way the contract says:
3
+ * ending it with "…", the ellipsis counted within the limit.
4
+ *
5
+ * Measured in UTF-16 code units, which is never more lenient than the
6
+ * contract's code points, and never cutting a surrogate pair in two.
7
+ */
8
+
9
+ const ELLIPSIS = "…";
10
+
11
+ /** The text cut to leave room for the ellipsis within `max`, and the ellipsis. */
12
+ function withEllipsis(text: string, max: number): string {
13
+ if (max < 1) {
14
+ return "";
15
+ }
16
+ let base = text.trimEnd();
17
+ if (base.length + 1 > max) {
18
+ let cut = max - 1;
19
+ const last = base.charCodeAt(cut - 1);
20
+ if (last >= 0xd800 && last <= 0xdbff) {
21
+ cut -= 1;
22
+ }
23
+ base = base.slice(0, cut).trimEnd();
24
+ }
25
+ return `${base}${ELLIPSIS}`;
26
+ }
27
+
28
+ /** The text as it is when within `max`, and otherwise shortened to it with "…". */
29
+ export function shortenText(text: string, max: number): string {
30
+ return text.length <= max ? text : withEllipsis(text, max);
31
+ }
32
+
33
+ /** Below this, the start of a further paragraph says nothing worth its room. */
34
+ const SHORTEST_FRAGMENT = 20;
35
+
36
+ /**
37
+ * Texts that are at most `max` together: blank ones dropped, and the first one
38
+ * that does not fit shortened with "…", or left out with the ellipsis on the
39
+ * one before it when too little room is left to say anything.
40
+ */
41
+ export function shortenTexts(texts: readonly string[], max: number): string[] {
42
+ const kept: string[] = [];
43
+ let left = max;
44
+ for (const text of texts) {
45
+ if (text.trim() === "") {
46
+ continue;
47
+ }
48
+ if (text.length <= left) {
49
+ kept.push(text);
50
+ left -= text.length;
51
+ continue;
52
+ }
53
+ if (left >= SHORTEST_FRAGMENT || kept.length === 0) {
54
+ const fragment = withEllipsis(text, left);
55
+ if (fragment !== "") {
56
+ kept.push(fragment);
57
+ }
58
+ } else {
59
+ const last = kept.pop() ?? "";
60
+ kept.push(withEllipsis(last, last.length + left));
61
+ }
62
+ break;
63
+ }
64
+ return kept;
65
+ }
package/src/time.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Instants and the calendar day of a time zone, with nothing but `Intl`.
3
+ *
4
+ * The association's day is the document's `timeZone`, never the browser's:
5
+ * a screen whose clock is set to another zone still shows Stockholm's today.
6
+ */
7
+ import type { Instant, IsoWeekday } from "./contract.ts";
8
+
9
+ /** A calendar date and a time of day, as a wall clock in one zone shows it. */
10
+ export interface ZonedParts {
11
+ year: number;
12
+ /** 1 to 12. */
13
+ month: number;
14
+ day: number;
15
+ /** 0 to 23. */
16
+ hour: number;
17
+ minute: number;
18
+ second: number;
19
+ weekday: IsoWeekday;
20
+ }
21
+
22
+ const MINUTE = 60 * 1000;
23
+ const DAY = 24 * 60 * MINUTE;
24
+
25
+ const partFormatters = new Map<string, Intl.DateTimeFormat>();
26
+
27
+ function partFormatter(timeZone: string): Intl.DateTimeFormat {
28
+ let formatter = partFormatters.get(timeZone);
29
+ if (formatter === undefined) {
30
+ formatter = new Intl.DateTimeFormat("en-US", {
31
+ timeZone,
32
+ hourCycle: "h23",
33
+ year: "numeric",
34
+ month: "numeric",
35
+ day: "numeric",
36
+ hour: "numeric",
37
+ minute: "numeric",
38
+ second: "numeric",
39
+ });
40
+ partFormatters.set(timeZone, formatter);
41
+ }
42
+ return formatter;
43
+ }
44
+
45
+ /** Whether `Intl` knows the zone. */
46
+ export function isTimeZone(timeZone: string): boolean {
47
+ try {
48
+ partFormatter(timeZone);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /** The wall clock of `timeZone` at an instant. */
56
+ export function zonedParts(time: number, timeZone: string): ZonedParts {
57
+ const values: Record<string, number> = {};
58
+ for (const part of partFormatter(timeZone).formatToParts(time)) {
59
+ if (part.type !== "literal") {
60
+ values[part.type] = Number(part.value);
61
+ }
62
+ }
63
+ const year = values["year"] ?? 1970;
64
+ const month = values["month"] ?? 1;
65
+ const day = values["day"] ?? 1;
66
+ return {
67
+ year,
68
+ month,
69
+ day,
70
+ hour: (values["hour"] ?? 0) % 24,
71
+ minute: values["minute"] ?? 0,
72
+ second: values["second"] ?? 0,
73
+ weekday: isoWeekday(year, month, day),
74
+ };
75
+ }
76
+
77
+ function isoWeekday(year: number, month: number, day: number): IsoWeekday {
78
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
79
+ return (weekday === 0 ? 7 : weekday) as IsoWeekday;
80
+ }
81
+
82
+ /** How far the zone's wall clock is ahead of UTC at an instant, in ms. */
83
+ function offset(time: number, timeZone: string): number {
84
+ const parts = zonedParts(time, timeZone);
85
+ const wall = Date.UTC(
86
+ parts.year,
87
+ parts.month - 1,
88
+ parts.day,
89
+ parts.hour,
90
+ parts.minute,
91
+ parts.second,
92
+ );
93
+ return wall - Math.floor(time / 1000) * 1000;
94
+ }
95
+
96
+ /**
97
+ * The first instant of a calendar date in `timeZone`: its midnight, or, in a
98
+ * zone that skips midnight on the day the clocks go forward, the moment the
99
+ * day begins.
100
+ */
101
+ export function zonedDayStart(
102
+ year: number,
103
+ month: number,
104
+ day: number,
105
+ timeZone: string,
106
+ ): number {
107
+ const wall = Date.UTC(year, month - 1, day);
108
+ const first = wall - offset(wall, timeZone);
109
+ const second = wall - offset(first, timeZone);
110
+ const onTheDay = [first, second]
111
+ .filter((candidate) => {
112
+ const parts = zonedParts(candidate, timeZone);
113
+ return parts.year === year && parts.month === month && parts.day === day;
114
+ })
115
+ .sort((a, b) => a - b);
116
+ return onTheDay[0] ?? Math.max(first, second);
117
+ }
118
+
119
+ /** The first instant of the calendar day `days` after the one `time` falls on. */
120
+ export function zonedDayStartAfter(
121
+ time: number,
122
+ days: number,
123
+ timeZone: string,
124
+ ): number {
125
+ const today = zonedParts(time, timeZone);
126
+ const date = new Date(
127
+ Date.UTC(today.year, today.month - 1, today.day + days),
128
+ );
129
+ return zonedDayStart(
130
+ date.getUTCFullYear(),
131
+ date.getUTCMonth() + 1,
132
+ date.getUTCDate(),
133
+ timeZone,
134
+ );
135
+ }
136
+
137
+ /** An instant floored to its minute. */
138
+ export function floorToMinute(time: number): number {
139
+ return Math.floor(time / MINUTE) * MINUTE;
140
+ }
141
+
142
+ /** An instant as the contract writes one: UTC, whole seconds, "Z". */
143
+ export function toInstant(time: number): Instant {
144
+ return `${new Date(Math.floor(time / 1000) * 1000).toISOString().slice(0, 19)}Z`;
145
+ }
146
+
147
+ export { DAY, MINUTE };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The renderer's words in a screen's locale, with i18next's interpolation and
3
+ * plural keys, so the translations read the same here as in packages/i18n.
4
+ */
5
+ import type { ScreenLocale } from "./contract.ts";
6
+ import { WORDS, type WordKey } from "./words.ts";
7
+
8
+ type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
9
+
10
+ type PluralBase<Key> = Key extends `${infer Base}_other` ? Base : never;
11
+
12
+ /** A word to look up: a key, or the base of a plural key such as `placesLeft`. */
13
+ export type Phrase =
14
+ Exclude<WordKey, `${string}_${PluralCategory}`> | PluralBase<WordKey>;
15
+
16
+ const pluralRules = new Map<ScreenLocale, Intl.PluralRules>();
17
+
18
+ function pluralCategory(locale: ScreenLocale, count: number): string {
19
+ let rules = pluralRules.get(locale);
20
+ if (rules === undefined) {
21
+ rules = new Intl.PluralRules(locale);
22
+ pluralRules.set(locale, rules);
23
+ }
24
+ return rules.select(count);
25
+ }
26
+
27
+ function lookup(locale: ScreenLocale, key: string): string | undefined {
28
+ const words: Readonly<Record<string, string>> = WORDS[locale];
29
+ return Object.prototype.hasOwnProperty.call(words, key)
30
+ ? words[key]
31
+ : undefined;
32
+ }
33
+
34
+ /**
35
+ * The word for `phrase` in `locale`, its `{{name}}` placeholders filled from
36
+ * `values`. A numeric `count` picks the plural form, `<phrase>_one` or
37
+ * `<phrase>_other`, by the locale's plural rules.
38
+ */
39
+ export function translate(
40
+ locale: ScreenLocale,
41
+ phrase: Phrase,
42
+ values: Readonly<Record<string, string | number>> = {},
43
+ ): string {
44
+ const count = values["count"];
45
+ const text =
46
+ (typeof count === "number"
47
+ ? (lookup(locale, `${phrase}_${pluralCategory(locale, count)}`) ??
48
+ lookup(locale, `${phrase}_other`))
49
+ : undefined) ??
50
+ lookup(locale, phrase) ??
51
+ phrase;
52
+ return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (placeholder, name: string) => {
53
+ const value = values[name];
54
+ return value === undefined ? placeholder : String(value);
55
+ });
56
+ }
package/src/words.ts ADDED
@@ -0,0 +1,91 @@
1
+ // Generated by scripts/words.mjs from the screenContent namespace of
2
+ // packages/i18n/src/locales/en.json and sv.json. Do not edit it by hand:
3
+ // change the translations there and run `pnpm --filter @openbrf/display words`.
4
+ import type { ScreenLocale } from "./contract.ts";
5
+
6
+ const en = {
7
+ loading: "Loading",
8
+ comingEvents: "Coming events",
9
+ calledOff: "Called off",
10
+ placesLeft_one: "{{count}} place left",
11
+ placesLeft_other: "{{count}} places left",
12
+ fullyBooked: "Fully booked",
13
+ ordinaryMeeting: "Ordinary general meeting",
14
+ extraordinaryMeeting: "Extraordinary general meeting",
15
+ meetingWhen: "When",
16
+ meetingWhere: "Where",
17
+ digitalParticipation: "Taking part digitally",
18
+ agenda: "Agenda",
19
+ freeAndBusy: "Free and busy",
20
+ today: "Today",
21
+ tomorrow: "Tomorrow",
22
+ busy: "Busy",
23
+ busyAllDay: "Busy all day",
24
+ freeAllDay: "Free all day",
25
+ freeRestOfDay: "Free for the rest of the day",
26
+ freeBetween: "Free {{from}}–{{to}}",
27
+ timeRange: "{{from}}–{{to}}",
28
+ floor: "Floor {{floor}}",
29
+ nothingToShow: "Nothing to show right now",
30
+ lastUpdated: "Last updated {{time}}",
31
+ notConnected: "Not connected",
32
+ notConnectedDetail:
33
+ "The screen has no connection just now. It keeps trying by itself.",
34
+ linkInvalid: "This display link is no longer valid",
35
+ linkInvalidDetail:
36
+ "Ask the board for a new display link and open it on this screen.",
37
+ linkMissing: "The display link is not complete",
38
+ linkMissingDetail:
39
+ "Open the whole display link from the board on this screen, everything after the # sign included.",
40
+ paused: "The screen is paused",
41
+ pausedDetail:
42
+ "Screen content is switched off for now. The screen starts again by itself when it is switched back on.",
43
+ } as const;
44
+
45
+ /** A word the renderer shows that is not in the document. */
46
+ export type WordKey = keyof typeof en;
47
+
48
+ /** The renderer's own words in every screen language, English the canonical one. */
49
+ export const WORDS: Readonly<
50
+ Record<ScreenLocale, Readonly<Record<WordKey, string>>>
51
+ > = {
52
+ en,
53
+ sv: {
54
+ loading: "Laddar",
55
+ comingEvents: "Kommande evenemang",
56
+ calledOff: "Inställt",
57
+ placesLeft_one: "{{count}} plats kvar",
58
+ placesLeft_other: "{{count}} platser kvar",
59
+ fullyBooked: "Fullbokat",
60
+ ordinaryMeeting: "Ordinarie föreningsstämma",
61
+ extraordinaryMeeting: "Extra föreningsstämma",
62
+ meetingWhen: "När",
63
+ meetingWhere: "Var",
64
+ digitalParticipation: "Delta digitalt",
65
+ agenda: "Dagordning",
66
+ freeAndBusy: "Ledigt och bokat",
67
+ today: "I dag",
68
+ tomorrow: "I morgon",
69
+ busy: "Bokat",
70
+ busyAllDay: "Bokat hela dagen",
71
+ freeAllDay: "Ledigt hela dagen",
72
+ freeRestOfDay: "Ledigt resten av dagen",
73
+ freeBetween: "Ledigt {{from}}–{{to}}",
74
+ timeRange: "{{from}}–{{to}}",
75
+ floor: "Våning {{floor}}",
76
+ nothingToShow: "Inget att visa just nu",
77
+ lastUpdated: "Senast uppdaterad {{time}}",
78
+ notConnected: "Inte ansluten",
79
+ notConnectedDetail:
80
+ "Skärmen har ingen anslutning just nu. Den fortsätter att försöka av sig själv.",
81
+ linkInvalid: "Visningslänken gäller inte längre",
82
+ linkInvalidDetail:
83
+ "Be styrelsen om en ny visningslänk och öppna den på den här skärmen.",
84
+ linkMissing: "Visningslänken är inte hel",
85
+ linkMissingDetail:
86
+ "Öppna hela visningslänken från styrelsen på den här skärmen, även allt efter tecknet #.",
87
+ paused: "Skärmen är pausad",
88
+ pausedDetail:
89
+ "Skärminnehållet är avstängt just nu. Skärmen startar igen av sig själv när det slås på.",
90
+ },
91
+ };