@cube-drone/marquee-html-renderer 0.9.0 → 0.9.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.
@@ -0,0 +1,47 @@
1
+ import type { Attrs, Node } from "@cube-drone/marquee-parser";
2
+ /** The language-defined animated effects (SPEC.md, "Text effects"). */
3
+ export declare const EFFECT_NAMES: ReadonlySet<string>;
4
+ /** One animated unit: `[start, end)` in its text node's `value` (UTF-16
5
+ * offsets), and the `--mq-o` phase offset it wears. */
6
+ export interface EffectUnit {
7
+ start: number;
8
+ end: number;
9
+ offset: string;
10
+ }
11
+ export interface EffectLook {
12
+ /** The container's classes: `mq-<name>`, plus `mq-split` when split. */
13
+ className: string;
14
+ /** Custom properties on the container (`--mq-speed`, `--mq-rate`, a
15
+ * reveal's step), in emission order. */
16
+ vars: [string, string][];
17
+ /** Data attributes on the container (a marquee's direction). */
18
+ data: [string, string][];
19
+ /** Per-unit animation, or null when the run animates as one piece (no
20
+ * `by=`, no units, or past the unit cap). Keyed by text node identity. */
21
+ units: Map<Node, EffectUnit[]> | null;
22
+ /** With units: the word groups - `[start, end)` value ranges, each a run
23
+ * of text between spaces that holds units - which must wrap as one. Units
24
+ * are inline-blocks (transforms need them to be), and a line may break on
25
+ * either side of every inline-block; a `white-space: nowrap` group puts
26
+ * the break opportunities back where the spaces are. */
27
+ groups: Map<Node, [number, number][]> | null;
28
+ }
29
+ /** How an effect span looks, or null if `name` isn't an effect. */
30
+ export declare function effectLook(name: string, attrs: Attrs, children: Node[]): EffectLook | null;
31
+ /** A text node, laid out for a split render: plain text that rides along,
32
+ * animated units, and word groups holding units (and the punctuation that
33
+ * clings to them) that must not wrap apart. */
34
+ export type SplitPiece = {
35
+ kind: "text";
36
+ text: string;
37
+ } | {
38
+ kind: "unit";
39
+ text: string;
40
+ offset: string;
41
+ } | {
42
+ kind: "group";
43
+ pieces: SplitPiece[];
44
+ };
45
+ export declare function splitPieces(node: Node & {
46
+ type: "text";
47
+ }, look: EffectLook): SplitPiece[];
@@ -0,0 +1,252 @@
1
+ // The effect vocabulary's look, as data: which classes, custom properties
2
+ // and data attributes an effect span's container wears, and - for by=letter
3
+ // / by=word - exactly which runs of which text nodes are animated units and
4
+ // with what phase offset. The HTML renderer builds its markup from this, and
5
+ // any other surface that draws effects (the live editor's in-place marks)
6
+ // takes the same answer, so the two can't disagree about segmentation,
7
+ // phase order, offsets, or caps.
8
+ const TOKEN = /^[a-z][a-z0-9-]{0,31}$/;
9
+ const COUNT = /^[0-9]{1,4}$/;
10
+ /** The language-defined animated effects (SPEC.md, "Text effects"). */
11
+ export const EFFECT_NAMES = new Set([
12
+ "marquee", "blink", "rainbow", "bounce", "jitter", "wave", "rubber", "typewriter", "fadein",
13
+ ]);
14
+ /** How an effect span looks, or null if `name` isn't an effect. */
15
+ export function effectLook(name, attrs, children) {
16
+ if (!EFFECT_NAMES.has(name)) {
17
+ return null;
18
+ }
19
+ const look = { className: `mq-${name}`, vars: [], data: [], units: null, groups: null };
20
+ const by = attrs["by"] === "letter" || attrs["by"] === "word" ? attrs["by"] : null;
21
+ let splitBy = null;
22
+ switch (name) {
23
+ case "marquee":
24
+ if (TOKEN.test(attrs["direction"] ?? "")) {
25
+ look.data.push(["direction", attrs["direction"]]);
26
+ }
27
+ if (COUNT.test(attrs["speed"] ?? "")) {
28
+ look.vars.push(["--mq-speed", attrs["speed"]]);
29
+ }
30
+ return look;
31
+ case "blink":
32
+ // Split blink: ramp is theater-marquee chase lights, scatter is
33
+ // twinkle. The rate rides the container; units inherit it.
34
+ if (COUNT.test(attrs["rate"] ?? "")) {
35
+ look.vars.push(["--mq-rate", attrs["rate"]]);
36
+ }
37
+ splitBy = by;
38
+ break;
39
+ case "typewriter":
40
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
41
+ // word-at-a-time). speed= is units per second; the container carries
42
+ // the per-unit delay step, each unit its ordinal in --mq-o.
43
+ splitBy = by === "word" ? "word" : "letter";
44
+ look.vars.push(["--mq-tw-step", `${revealStep(attrs["speed"], 14)}s`]);
45
+ break;
46
+ case "fadein":
47
+ // Bare [fadein] fades the whole run in once; by= drifts units in on
48
+ // staggered starts (the same one-shot family as typewriter).
49
+ if (by !== null) {
50
+ splitBy = by;
51
+ look.vars.push(["--mq-fi-step", `${revealStep(attrs["speed"], 16)}s`]);
52
+ }
53
+ break;
54
+ default: // rainbow, bounce, jitter, wave, rubber
55
+ splitBy = by;
56
+ }
57
+ if (splitBy === null) {
58
+ return look;
59
+ }
60
+ const units = splitUnits(name, splitBy, attrs["phase"], children);
61
+ if (units === null) {
62
+ // Past the cap (or nothing to split): the run animates whole, and the
63
+ // split's container knobs go with the split.
64
+ look.vars = [];
65
+ return look;
66
+ }
67
+ look.className += " mq-split";
68
+ look.units = units;
69
+ look.groups = wordGroups(units);
70
+ return look;
71
+ }
72
+ export function splitPieces(node, look) {
73
+ const units = look.units?.get(node) ?? [];
74
+ const groups = look.groups?.get(node) ?? [];
75
+ const lay = (from, to) => {
76
+ const out = [];
77
+ let at = from;
78
+ for (const u of units) {
79
+ if (u.start < from || u.end > to)
80
+ continue;
81
+ if (u.start > at)
82
+ out.push({ kind: "text", text: node.value.slice(at, u.start) });
83
+ out.push({ kind: "unit", text: node.value.slice(u.start, u.end), offset: u.offset });
84
+ at = u.end;
85
+ }
86
+ if (to > at)
87
+ out.push({ kind: "text", text: node.value.slice(at, to) });
88
+ return out;
89
+ };
90
+ const out = [];
91
+ let at = 0;
92
+ for (const [start, end] of groups) {
93
+ out.push(...lay(at, start), { kind: "group", pieces: lay(start, end) });
94
+ at = end;
95
+ }
96
+ out.push(...lay(at, node.value.length));
97
+ return out;
98
+ }
99
+ /** Scripts written without spaces between words wrap between characters,
100
+ * so their runs are never grouped (a whole sentence would become one
101
+ * unbreakable word): Han, kana, fullwidth forms, Thai, Lao, Khmer, Myanmar. */
102
+ const SPACELESS = /[\u0E00-\u0EFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u{20000}-\u{3FFFF}]/u;
103
+ function wordGroups(units) {
104
+ const out = new Map();
105
+ for (const [node, list] of units) {
106
+ if (node.type !== "text")
107
+ continue;
108
+ const groups = [];
109
+ for (const m of node.value.matchAll(/\S+/g)) {
110
+ const start = m.index;
111
+ const end = start + m[0].length;
112
+ const inside = list.filter((u) => u.start >= start && u.end <= end);
113
+ // Nothing to hold together: no units, a spaceless script, or a run
114
+ // that is exactly one unit (already a single inline-block).
115
+ if (inside.length === 0 || SPACELESS.test(m[0]))
116
+ continue;
117
+ if (inside.length === 1 && inside[0].start === start && inside[0].end === end)
118
+ continue;
119
+ groups.push([start, end]);
120
+ }
121
+ out.set(node, groups);
122
+ }
123
+ return out;
124
+ }
125
+ // -- per-unit effects (by=letter / by=word): each unit carries a phase
126
+ // offset in --mq-o; the stylesheet replays the effect's keyframes through a
127
+ // negative animation-delay. Locale pinned so segmentation (and thus output)
128
+ // is stable.
129
+ const SEGMENTERS = {
130
+ letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
131
+ word: new Intl.Segmenter("en", { granularity: "word" }),
132
+ };
133
+ /** DOM-weight discipline: past this many units, the run animates whole.
134
+ * Loopers pay a live animation per element forever, so they cap low;
135
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
136
+ * and its natural material is long text, so it caps high. */
137
+ const MAX_SPLIT_UNITS = 400;
138
+ const MAX_REVEAL_UNITS = 2000;
139
+ function splitUnits(effect, by, phaseAttr, children) {
140
+ // Each effect has a natural phase order (jitter scatters, the rest sweep);
141
+ // the knob overrides it either way. Invalid values degrade to the default.
142
+ const phase = phaseAttr === "scatter" || phaseAttr === "ramp" ? phaseAttr : effect === "jitter" ? "scatter" : "ramp";
143
+ const total = countUnits(children, by);
144
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
145
+ if (total === 0 || total > cap) {
146
+ return null;
147
+ }
148
+ const state = { effect, by, phase, i: 0, total };
149
+ const units = new Map();
150
+ collectUnits(children, state, units);
151
+ return units;
152
+ }
153
+ /** The nodes a split descends into: text, through emphasis/strong/strike.
154
+ * Anything else (a link, an emoji, a nested span) renders whole, un-split. */
155
+ function collectUnits(nodes, state, out) {
156
+ for (const node of nodes) {
157
+ if (node.type === "text") {
158
+ const list = [];
159
+ for (const seg of SEGMENTERS[state.by].segment(node.value)) {
160
+ if (isUnit(seg, state.by)) {
161
+ list.push({ start: seg.index, end: seg.index + seg.segment.length, offset: unitOffset(state) });
162
+ state.i += 1;
163
+ }
164
+ }
165
+ out.set(node, list);
166
+ }
167
+ else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
168
+ collectUnits(node.children, state, out);
169
+ }
170
+ }
171
+ }
172
+ function countUnits(nodes, by) {
173
+ let n = 0;
174
+ for (const node of nodes) {
175
+ if (node.type === "text") {
176
+ for (const seg of SEGMENTERS[by].segment(node.value)) {
177
+ if (isUnit(seg, by)) {
178
+ n += 1;
179
+ }
180
+ }
181
+ }
182
+ else if (node.type === "emphasis" || node.type === "strong" || node.type === "strikethrough") {
183
+ n += countUnits(node.children, by);
184
+ }
185
+ }
186
+ return n;
187
+ }
188
+ /** A segment gets wrapped if it's animatable: for words, word-like segments
189
+ * (spaces and bare punctuation ride along); for letters, anything that
190
+ * isn't whitespace. */
191
+ function isUnit(seg, by) {
192
+ return by === "word" ? seg.isWordLike === true : !/^\s+$/.test(seg.segment);
193
+ }
194
+ /** Offsets are deterministic (goldens exist; a document renders the same
195
+ * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
196
+ * integer hash - randomness-shaped, never random. */
197
+ function unitOffset(state) {
198
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
199
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
200
+ // looping effects replay. phase=scatter reveals in a
201
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
202
+ // point, nudged coprime with the total, walks a well-spread permutation
203
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
204
+ // -1, so forty-unit runs typed in backwards.)
205
+ if (isReveal(state.effect)) {
206
+ const o = state.phase === "scatter" ? (state.i * scatterStride(state.total)) % state.total : state.i;
207
+ return String(o);
208
+ }
209
+ let o;
210
+ if (state.phase === "scatter") {
211
+ o = ((state.i * 7919) % 101) / 101;
212
+ }
213
+ else {
214
+ switch (state.effect) {
215
+ case "rainbow":
216
+ o = state.i / state.total; // gradient across the whole run
217
+ break;
218
+ case "wave":
219
+ o = (state.i % 8) / 8; // fixed ripple wavelength
220
+ break;
221
+ case "bounce":
222
+ o = (state.i % 6) / 6;
223
+ break;
224
+ default:
225
+ o = (state.i % 8) / 8; // jitter in ramp mode: a rippling shudder
226
+ break;
227
+ }
228
+ }
229
+ return String(Math.round(o * 1000) / 1000);
230
+ }
231
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
232
+ * seconds; invalid or absent falls to the effect's default rate. */
233
+ function revealStep(speedAttr, dflt) {
234
+ const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0 ? Number(speedAttr) : dflt;
235
+ return Math.round(1000 / speed) / 1000;
236
+ }
237
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
238
+ function isReveal(effect) {
239
+ return effect === "typewriter" || effect === "fadein";
240
+ }
241
+ function gcd(a, b) {
242
+ return b === 0 ? a : gcd(b, a % b);
243
+ }
244
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
245
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
246
+ function scatterStride(total) {
247
+ let stride = Math.max(1, Math.round(total * 0.618));
248
+ while (stride < total && gcd(stride, total) !== 1) {
249
+ stride += 1;
250
+ }
251
+ return gcd(stride, total) === 1 ? stride : 1;
252
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Profile } from "./profile.ts";
2
2
  export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.ts";
3
3
  export { bareWebProfile } from "./profile.ts";
4
+ export { EFFECT_NAMES, effectLook, splitPieces, type EffectLook, type EffectUnit, type SplitPiece } from "./effects.ts";
4
5
  export type { EmojiResolution, Profile, MediaResolution, TurbolinkLevel } from "./profile.ts";
5
6
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown
6
7
  * dialect versions, exactly as the parser does. */
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { parse } from "@cube-drone/marquee-parser";
8
8
  import { render } from "./render.js";
9
9
  export { render, escapeText, escapeAttr, FONTS, usedFontTokens, containerLook } from "./render.js";
10
10
  export { bareWebProfile } from "./profile.js";
11
+ export { EFFECT_NAMES, effectLook, splitPieces } from "./effects.js";
11
12
  /** Parse and render in one step. Throws UnsupportedVersionError for unknown
12
13
  * dialect versions, exactly as the parser does. */
13
14
  export function renderMarquee(source, profile) {