@magicpages/pagemoji 0.0.0-stage → 0.1.0

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,9 @@
1
+ export declare class PagemojiReactions extends HTMLElement {
2
+ #private;
3
+ connectedCallback(): void;
4
+ }
5
+ export interface DefineOptions {
6
+ /** Base URL of the Pagemoji server, used by widgets without an `api` attribute. */
7
+ api?: string;
8
+ }
9
+ export declare function definePagemoji(options?: DefineOptions): void;
@@ -0,0 +1,302 @@
1
+ import { MAX_EMOJIS_PER_WIDGET, MAX_KEYS_PER_REQUEST, isValidPageId, normalizeHost, pageRef, } from './protocol.js';
2
+ const DEFAULT_EMOJIS = ['❤️', '👏', '💡'];
3
+ // No cookies, and no page URL in a Referer header: the request itself carries
4
+ // everything the server needs.
5
+ const REQUEST = { credentials: 'omit', referrerPolicy: 'strict-origin' };
6
+ // Every colour derives from the surrounding text colour and an accent, so the
7
+ // widget fits light and dark themes without configuration. Ghost sites supply
8
+ // their accent through --ghost-accent-color.
9
+ const STYLES = `
10
+ :host {
11
+ --_accent: var(--pagemoji-accent, var(--ghost-accent-color, currentColor));
12
+ display: block;
13
+ color: var(--pagemoji-fg, inherit);
14
+ font-size: var(--pagemoji-font-size, 0.9375rem);
15
+ }
16
+ :host([hidden]) { display: none; }
17
+ .r {
18
+ display: flex; flex-wrap: wrap; align-items: center;
19
+ gap: var(--pagemoji-gap, 0.5rem);
20
+ justify-content: var(--pagemoji-justify, flex-start);
21
+ }
22
+ .l { margin-inline-end: 0.25rem; }
23
+ button, .i {
24
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.4em;
25
+ font: inherit; color: inherit; line-height: 1;
26
+ }
27
+ button {
28
+ min-height: 2.25em;
29
+ padding: var(--pagemoji-padding, 0.3em 0.75em);
30
+ border: 1px solid var(--pagemoji-border, color-mix(in srgb, currentColor 14%, transparent));
31
+ border-radius: var(--pagemoji-radius, 999px);
32
+ background: var(--pagemoji-bg, color-mix(in srgb, currentColor 5%, transparent));
33
+ cursor: pointer;
34
+ -webkit-tap-highlight-color: transparent;
35
+ transition: background-color 0.15s, border-color 0.15s;
36
+ }
37
+ button:hover { background: var(--pagemoji-bg-hover, color-mix(in srgb, currentColor 10%, transparent)); }
38
+ button[aria-pressed='true'] {
39
+ border-color: var(--pagemoji-border-active, color-mix(in srgb, var(--_accent) 60%, transparent));
40
+ background: var(--pagemoji-bg-active, color-mix(in srgb, var(--_accent) 14%, transparent));
41
+ }
42
+ button:active { background: color-mix(in srgb, var(--_accent) 24%, transparent); }
43
+ button:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
44
+ :host([variant='outline']) button:not(:hover, [aria-pressed='true']) { background: transparent; }
45
+ :host([variant='plain']) button { padding-inline: 0.5em; border-color: transparent; }
46
+ :host([variant='plain']) button:not(:hover, :active, [aria-pressed='true']) { background: transparent; }
47
+ .e { position: relative; display: inline-block; }
48
+ .g { display: inline-block; font-size: 1.2em; }
49
+ .c { display: inline-block; min-width: 1ch; font-variant-numeric: tabular-nums; }
50
+ .c:empty { display: none; }
51
+ [aria-pressed='true'] .c { font-weight: 600; }
52
+ .i .c { opacity: 0.75; }
53
+ .p { position: absolute; left: 50%; top: 50%; translate: -50% -50%; font-size: 0.6em; pointer-events: none; }
54
+ [hidden] { display: none !important; }
55
+ @media (hover: hover) and (prefers-reduced-motion: no-preference) {
56
+ .g { transition: transform 0.15s ease-out; }
57
+ button:hover .g { transform: scale(1.15); }
58
+ }
59
+ `;
60
+ let defaultApi;
61
+ function parseUrl(value) {
62
+ try {
63
+ return new URL(value, document.baseURI);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ const BURST_SIZE = 6;
70
+ export class PagemojiReactions extends HTMLElement {
71
+ static #queue = [];
72
+ #root = this.attachShadow({ mode: 'open' });
73
+ #api = '';
74
+ #ref = null;
75
+ #page = null;
76
+ #url = '';
77
+ #emojis = [];
78
+ #items = new Map();
79
+ #counts = {};
80
+ #pressed = new Set();
81
+ #busy = new Set();
82
+ connectedCallback() {
83
+ // Moving the element around the DOM must not fetch again.
84
+ if (this.#ref)
85
+ return;
86
+ const api = parseUrl(this.getAttribute('api') ?? defaultApi ?? '');
87
+ if (!api)
88
+ return;
89
+ this.#api = api.href.endsWith('/') ? api.href : `${api.href}/`;
90
+ const here = new URL(location.href);
91
+ let target = here;
92
+ const candidate = this.getAttribute('url') ??
93
+ document.querySelector('link[rel="canonical"]')?.href;
94
+ const parsed = candidate ? parseUrl(candidate) : null;
95
+ // A canonical URL on another host usually marks a republished post; the
96
+ // reactions still belong to this site.
97
+ if (parsed && normalizeHost(parsed.hostname) === normalizeHost(here.hostname))
98
+ target = parsed;
99
+ const page = this.getAttribute('page');
100
+ this.#page = page && isValidPageId(page) ? page : null;
101
+ this.#ref = pageRef(target, this.#page);
102
+ this.#url = here.origin + target.pathname;
103
+ const emojis = new Set((this.getAttribute('emojis') ?? '').split(/\s+/).filter(Boolean));
104
+ this.#emojis = emojis.size ? [...emojis].slice(0, MAX_EMOJIS_PER_WIDGET) : DEFAULT_EMOJIS;
105
+ if (PagemojiReactions.#queue.push(this) === 1) {
106
+ queueMicrotask(() => void PagemojiReactions.#flush());
107
+ }
108
+ }
109
+ /** One counts request per server, site and emoji set, however many widgets the page has. */
110
+ static async #flush() {
111
+ const widgets = PagemojiReactions.#queue;
112
+ PagemojiReactions.#queue = [];
113
+ const groups = new Map();
114
+ for (const widget of widgets) {
115
+ const id = [widget.#api, widget.#ref?.host, ...widget.#emojis].join(' ');
116
+ let group = groups.get(id);
117
+ if (!group)
118
+ groups.set(id, (group = []));
119
+ group.push(widget);
120
+ }
121
+ await Promise.all([...groups.values()].map((group) => PagemojiReactions.#load(group)));
122
+ }
123
+ static async #load(group) {
124
+ const [first] = group;
125
+ const host = first ? first.#ref?.host : undefined;
126
+ if (!first || !host)
127
+ return;
128
+ const keys = [...new Set(group.map((widget) => widget.#ref?.key ?? ''))];
129
+ for (let start = 0; start < keys.length; start += MAX_KEYS_PER_REQUEST) {
130
+ const query = new URLSearchParams({ host });
131
+ for (const key of keys.slice(start, start + MAX_KEYS_PER_REQUEST))
132
+ query.append('key', key);
133
+ for (const emoji of first.#emojis)
134
+ query.append('emoji', emoji);
135
+ try {
136
+ const response = await fetch(new URL(`v1/counts?${query}`, first.#api), REQUEST);
137
+ if (!response.ok)
138
+ continue;
139
+ const { counts } = await response.json();
140
+ for (const widget of group) {
141
+ const key = widget.#ref?.key ?? '';
142
+ if (key in counts)
143
+ widget.#show(counts[key] ?? {});
144
+ }
145
+ }
146
+ catch {
147
+ // Server unreachable: the widgets stay empty.
148
+ }
149
+ }
150
+ }
151
+ #show(counts) {
152
+ this.#counts = counts;
153
+ if (this.#items.size === 0)
154
+ this.#build();
155
+ this.#update();
156
+ }
157
+ #build() {
158
+ const readonly = this.hasAttribute('readonly');
159
+ const list = document.createElement('div');
160
+ list.className = 'r';
161
+ list.setAttribute('part', 'list');
162
+ const text = this.getAttribute('label')?.trim();
163
+ if (text) {
164
+ const label = document.createElement('span');
165
+ label.className = 'l';
166
+ label.setAttribute('part', 'label');
167
+ label.textContent = text;
168
+ list.append(label);
169
+ }
170
+ for (const emoji of this.#emojis) {
171
+ const element = document.createElement(readonly ? 'span' : 'button');
172
+ const burst = document.createElement('span');
173
+ const glyph = document.createElement('span');
174
+ const count = document.createElement('span');
175
+ burst.className = 'e';
176
+ glyph.className = 'g';
177
+ glyph.setAttribute('part', 'emoji');
178
+ count.className = 'c';
179
+ count.setAttribute('part', 'count');
180
+ glyph.textContent = emoji;
181
+ burst.append(glyph);
182
+ element.append(burst, count);
183
+ if (element instanceof HTMLButtonElement) {
184
+ element.type = 'button';
185
+ element.addEventListener('click', () => void this.#toggle(emoji));
186
+ }
187
+ else {
188
+ element.className = 'i';
189
+ element.setAttribute('part', 'item');
190
+ }
191
+ this.#items.set(emoji, { element, burst, glyph, count });
192
+ list.append(element);
193
+ }
194
+ const style = document.createElement('style');
195
+ style.textContent = STYLES;
196
+ this.#root.replaceChildren(style, list);
197
+ }
198
+ #update() {
199
+ const readonly = this.hasAttribute('readonly');
200
+ const format = new Intl.NumberFormat(undefined, { notation: 'compact' });
201
+ for (const [emoji, { element, count }] of this.#items) {
202
+ const value = this.#counts[emoji] ?? 0;
203
+ count.textContent = value > 0 ? format.format(value) : '';
204
+ if (readonly)
205
+ element.hidden = value === 0;
206
+ else {
207
+ const pressed = this.#pressed.has(emoji);
208
+ element.setAttribute('aria-pressed', String(pressed));
209
+ element.setAttribute('part', pressed ? 'button pressed' : 'button');
210
+ }
211
+ }
212
+ }
213
+ /**
214
+ * Feedback for the reader's own click, never for counts loading or changing
215
+ * by themselves. Adding celebrates; taking a reaction back stays quiet.
216
+ */
217
+ #animate(emoji, adding) {
218
+ const item = this.#items.get(emoji);
219
+ if (!item || !window.matchMedia?.('(prefers-reduced-motion: no-preference)').matches)
220
+ return;
221
+ const { burst, glyph, count } = item;
222
+ for (const animation of glyph.getAnimations())
223
+ animation.cancel();
224
+ glyph.animate({
225
+ transform: adding
226
+ ? ['scale(1)', 'scale(1.4)', 'scale(0.92)', 'scale(1)']
227
+ : ['scale(1)', 'scale(0.8)', 'scale(1)'],
228
+ }, { duration: adding ? 400 : 200, easing: 'ease-out' });
229
+ if (count.textContent) {
230
+ count.animate({ transform: [`translateY(${adding ? 0.4 : -0.4}em)`, 'none'], opacity: [0, 1] }, { duration: 200, easing: 'ease-out' });
231
+ }
232
+ if (!adding)
233
+ return;
234
+ const offset = Math.random();
235
+ for (let i = 0; i < BURST_SIZE; i++) {
236
+ const angle = ((i + offset) / BURST_SIZE) * 2 * Math.PI;
237
+ const distance = 1.3 + Math.random() * 0.5;
238
+ const particle = document.createElement('span');
239
+ particle.className = 'p';
240
+ particle.textContent = emoji;
241
+ particle.setAttribute('aria-hidden', 'true');
242
+ burst.append(particle);
243
+ particle.animate({
244
+ transform: [
245
+ 'scale(0.6)',
246
+ `translate(${Math.cos(angle) * distance}em, ${Math.sin(angle) * distance}em) scale(0.3)`,
247
+ ],
248
+ opacity: [1, 1, 0],
249
+ }, { duration: 450, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' }).onfinish = () => particle.remove();
250
+ }
251
+ }
252
+ async #toggle(emoji) {
253
+ if (this.#busy.has(emoji))
254
+ return;
255
+ this.#busy.add(emoji);
256
+ const adding = !this.#pressed.has(emoji);
257
+ const previous = this.#counts[emoji] ?? 0;
258
+ const flip = (on) => {
259
+ if (on)
260
+ this.#pressed.add(emoji);
261
+ else
262
+ this.#pressed.delete(emoji);
263
+ };
264
+ flip(adding);
265
+ this.#counts = { ...this.#counts, [emoji]: Math.max(0, previous + (adding ? 1 : -1)) };
266
+ this.#update();
267
+ this.#animate(emoji, adding);
268
+ const body = {
269
+ url: this.#url,
270
+ emoji,
271
+ action: adding ? 'add' : 'remove',
272
+ ...(this.#page ? { page: this.#page } : {}),
273
+ };
274
+ try {
275
+ const response = await fetch(new URL('v1/reactions', this.#api), {
276
+ ...REQUEST,
277
+ method: 'POST',
278
+ headers: { 'content-type': 'text/plain;charset=UTF-8' },
279
+ body: JSON.stringify(body),
280
+ });
281
+ if (!response.ok)
282
+ throw new Error(`HTTP ${response.status}`);
283
+ const result = await response.json();
284
+ this.#counts = result.counts;
285
+ }
286
+ catch {
287
+ flip(!adding);
288
+ this.#counts = { ...this.#counts, [emoji]: previous };
289
+ }
290
+ finally {
291
+ this.#busy.delete(emoji);
292
+ this.#update();
293
+ }
294
+ }
295
+ }
296
+ export function definePagemoji(options = {}) {
297
+ if (options.api)
298
+ defaultApi = options.api;
299
+ if (!customElements.get('pagemoji-reactions')) {
300
+ customElements.define('pagemoji-reactions', PagemojiReactions);
301
+ }
302
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What every generator needs: turn what a site owner typed or picked into a
3
+ * valid emoji selection, and that selection into embed code. Generators share
4
+ * this module so they all produce the same code.
5
+ */
6
+ import { MAX_EMOJIS_PER_WIDGET } from './protocol.js';
7
+ export { MAX_EMOJIS_PER_WIDGET };
8
+ export declare function isEmoji(value: string): boolean;
9
+ export interface EmojiSelection {
10
+ /** Valid, deduplicated, in input order, at most `MAX_EMOJIS_PER_WIDGET`. */
11
+ emojis: string[];
12
+ /** Input that isn't a single emoji. */
13
+ invalid: string[];
14
+ /** Valid emojis beyond the limit. */
15
+ dropped: string[];
16
+ }
17
+ /** Accepts emojis with or without spaces between them: `❤️👏` and `❤️ 👏` are the same. */
18
+ export declare function parseEmojis(input: string): EmojiSelection;
19
+ export type EmbedTarget =
20
+ /** Script and element, for any HTML page. */
21
+ 'html'
22
+ /** One script tag for header/footer injection; mounts itself after the post body. */
23
+ | 'inject'
24
+ /** Element for a Ghost `post.hbs`, keyed by post ID. */
25
+ | 'ghost-post'
26
+ /** Read-only element for Ghost post cards. */
27
+ | 'ghost-card';
28
+ /** `soft` is the default and needs no attribute. */
29
+ export type WidgetVariant = 'soft' | 'outline' | 'plain';
30
+ export interface EmbedOptions {
31
+ emojis: readonly string[];
32
+ /** Base URL of the Pagemoji server, e.g. `https://pagemoji.magicpages.co`. */
33
+ server: string;
34
+ variant?: WidgetVariant;
35
+ /** Text shown before the buttons, e.g. "What did you think?". Not used on cards. */
36
+ label?: string;
37
+ /** Where `inject` mounts the widget. Defaults to Ghost's post body. */
38
+ mount?: string;
39
+ }
40
+ export declare const DEFAULT_MOUNT = "after:.gh-content";
41
+ export declare function embedCode(target: EmbedTarget, options: EmbedOptions): string;
package/dist/embed.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What every generator needs: turn what a site owner typed or picked into a
3
+ * valid emoji selection, and that selection into embed code. Generators share
4
+ * this module so they all produce the same code.
5
+ */
6
+ import { MAX_EMOJIS_PER_WIDGET } from './protocol.js';
7
+ export { MAX_EMOJIS_PER_WIDGET };
8
+ /** A single fully-qualified emoji: skin tones, ZWJ sequences, flags and keycaps included. */
9
+ const SINGLE_EMOJI = /^\p{RGI_Emoji}$/v;
10
+ export function isEmoji(value) {
11
+ return SINGLE_EMOJI.test(value);
12
+ }
13
+ /** Accepts emojis with or without spaces between them: `❤️👏` and `❤️ 👏` are the same. */
14
+ export function parseEmojis(input) {
15
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
16
+ const selection = { emojis: [], invalid: [], dropped: [] };
17
+ const seen = new Set();
18
+ for (const word of input.split(/\s+/).filter(Boolean)) {
19
+ for (const { segment } of segmenter.segment(word)) {
20
+ if (!isEmoji(segment)) {
21
+ selection.invalid.push(segment);
22
+ }
23
+ else if (!seen.has(segment)) {
24
+ seen.add(segment);
25
+ if (selection.emojis.length < MAX_EMOJIS_PER_WIDGET)
26
+ selection.emojis.push(segment);
27
+ else
28
+ selection.dropped.push(segment);
29
+ }
30
+ }
31
+ }
32
+ return selection;
33
+ }
34
+ export const DEFAULT_MOUNT = 'after:.gh-content';
35
+ function escape(value) {
36
+ return value
37
+ .replaceAll('&', '&amp;')
38
+ .replaceAll('"', '&quot;')
39
+ .replaceAll('<', '&lt;')
40
+ .replaceAll('>', '&gt;');
41
+ }
42
+ export function embedCode(target, options) {
43
+ if (options.emojis.length === 0 || options.emojis.length > MAX_EMOJIS_PER_WIDGET) {
44
+ throw new RangeError(`Pick between 1 and ${MAX_EMOJIS_PER_WIDGET} emojis.`);
45
+ }
46
+ const emojis = escape(options.emojis.join(' '));
47
+ const script = escape(new URL('/v1/pagemoji.js', options.server).href);
48
+ const optional = [
49
+ options.variant && options.variant !== 'soft' ? `variant="${options.variant}"` : '',
50
+ options.label?.trim() ? `label="${escape(options.label.trim())}"` : '',
51
+ ].filter(Boolean);
52
+ const element = (...attributes) => `<pagemoji-reactions ${[...attributes, `emojis="${emojis}"`].join(' ')}></pagemoji-reactions>`;
53
+ switch (target) {
54
+ case 'html':
55
+ return `<script src="${script}" defer></script>\n${element(...optional)}`;
56
+ case 'inject':
57
+ return [
58
+ `<script src="${script}" defer`,
59
+ ` data-emojis="${emojis}"`,
60
+ ...optional.map((attribute) => ` data-${attribute}`),
61
+ ` data-mount="${escape(options.mount ?? DEFAULT_MOUNT)}"></script>`,
62
+ ].join('\n');
63
+ case 'ghost-post':
64
+ return element('page="{{id}}"', ...optional);
65
+ case 'ghost-card':
66
+ return element('page="{{id}}"', 'readonly');
67
+ }
68
+ }
@@ -0,0 +1 @@
1
+ export { PagemojiReactions, definePagemoji, type DefineOptions } from './element.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { PagemojiReactions, definePagemoji } from './element.js';
@@ -0,0 +1,51 @@
1
+ (function(){"use strict";const w=/^[A-Za-z0-9._:-]{1,200}$/;function S(i){return w.test(i)}function m(i){const t=i.toLowerCase().replace(/\.$/,"");return t.startsWith("www.")?t.slice(4):t}function k(i,t){const e=m(i.hostname);return t?{host:e,key:`id:${t}`}:{host:e,key:i.pathname.replace(/\/+$/,"")||"/"}}const A=["❤️","👏","💡"],g={credentials:"omit",referrerPolicy:"strict-origin"},x=`
2
+ :host {
3
+ --_accent: var(--pagemoji-accent, var(--ghost-accent-color, currentColor));
4
+ display: block;
5
+ color: var(--pagemoji-fg, inherit);
6
+ font-size: var(--pagemoji-font-size, 0.9375rem);
7
+ }
8
+ :host([hidden]) { display: none; }
9
+ .r {
10
+ display: flex; flex-wrap: wrap; align-items: center;
11
+ gap: var(--pagemoji-gap, 0.5rem);
12
+ justify-content: var(--pagemoji-justify, flex-start);
13
+ }
14
+ .l { margin-inline-end: 0.25rem; }
15
+ button, .i {
16
+ display: inline-flex; align-items: center; justify-content: center; gap: 0.4em;
17
+ font: inherit; color: inherit; line-height: 1;
18
+ }
19
+ button {
20
+ min-height: 2.25em;
21
+ padding: var(--pagemoji-padding, 0.3em 0.75em);
22
+ border: 1px solid var(--pagemoji-border, color-mix(in srgb, currentColor 14%, transparent));
23
+ border-radius: var(--pagemoji-radius, 999px);
24
+ background: var(--pagemoji-bg, color-mix(in srgb, currentColor 5%, transparent));
25
+ cursor: pointer;
26
+ -webkit-tap-highlight-color: transparent;
27
+ transition: background-color 0.15s, border-color 0.15s;
28
+ }
29
+ button:hover { background: var(--pagemoji-bg-hover, color-mix(in srgb, currentColor 10%, transparent)); }
30
+ button[aria-pressed='true'] {
31
+ border-color: var(--pagemoji-border-active, color-mix(in srgb, var(--_accent) 60%, transparent));
32
+ background: var(--pagemoji-bg-active, color-mix(in srgb, var(--_accent) 14%, transparent));
33
+ }
34
+ button:active { background: color-mix(in srgb, var(--_accent) 24%, transparent); }
35
+ button:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
36
+ :host([variant='outline']) button:not(:hover, [aria-pressed='true']) { background: transparent; }
37
+ :host([variant='plain']) button { padding-inline: 0.5em; border-color: transparent; }
38
+ :host([variant='plain']) button:not(:hover, :active, [aria-pressed='true']) { background: transparent; }
39
+ .e { position: relative; display: inline-block; }
40
+ .g { display: inline-block; font-size: 1.2em; }
41
+ .c { display: inline-block; min-width: 1ch; font-variant-numeric: tabular-nums; }
42
+ .c:empty { display: none; }
43
+ [aria-pressed='true'] .c { font-weight: 600; }
44
+ .i .c { opacity: 0.75; }
45
+ .p { position: absolute; left: 50%; top: 50%; translate: -50% -50%; font-size: 0.6em; pointer-events: none; }
46
+ [hidden] { display: none !important; }
47
+ @media (hover: hover) and (prefers-reduced-motion: no-preference) {
48
+ .g { transition: transform 0.15s ease-out; }
49
+ button:hover .g { transform: scale(1.15); }
50
+ }
51
+ `;let b;function v(i){try{return new URL(i,document.baseURI)}catch{return null}}const y=6;class p extends HTMLElement{static#i=[];#u=this.attachShadow({mode:"open"});#n="";#e=null;#s=null;#p="";#o=[];#a=new Map;#t={};#r=new Set;#c=new Set;connectedCallback(){if(this.#e)return;const t=v(this.getAttribute("api")??b??"");if(!t)return;this.#n=t.href.endsWith("/")?t.href:`${t.href}/`;const e=new URL(location.href);let o=e;const a=this.getAttribute("url")??document.querySelector('link[rel="canonical"]')?.href,n=a?v(a):null;n&&m(n.hostname)===m(e.hostname)&&(o=n);const s=this.getAttribute("page");this.#s=s&&S(s)?s:null,this.#e=k(o,this.#s),this.#p=e.origin+o.pathname;const r=new Set((this.getAttribute("emojis")??"").split(/\s+/).filter(Boolean));this.#o=r.size?[...r].slice(0,5):A,p.#i.push(this)===1&&queueMicrotask(()=>void p.#d())}static async#d(){const t=p.#i;p.#i=[];const e=new Map;for(const o of t){const a=[o.#n,o.#e?.host,...o.#o].join(" ");let n=e.get(a);n||e.set(a,n=[]),n.push(o)}await Promise.all([...e.values()].map(o=>p.#h(o)))}static async#h(t){const[e]=t,o=e?e.#e?.host:void 0;if(!e||!o)return;const a=[...new Set(t.map(n=>n.#e?.key??""))];for(let n=0;n<a.length;n+=100){const s=new URLSearchParams({host:o});for(const r of a.slice(n,n+100))s.append("key",r);for(const r of e.#o)s.append("emoji",r);try{const r=await fetch(new URL(`v1/counts?${s}`,e.#n),g);if(!r.ok)continue;const{counts:c}=await r.json();for(const l of t){const h=l.#e?.key??"";h in c&&l.#m(c[h]??{})}}catch{}}}#m(t){this.#t=t,this.#a.size===0&&this.#f(),this.#l()}#f(){const t=this.hasAttribute("readonly"),e=document.createElement("div");e.className="r",e.setAttribute("part","list");const o=this.getAttribute("label")?.trim();if(o){const n=document.createElement("span");n.className="l",n.setAttribute("part","label"),n.textContent=o,e.append(n)}for(const n of this.#o){const s=document.createElement(t?"span":"button"),r=document.createElement("span"),c=document.createElement("span"),l=document.createElement("span");r.className="e",c.className="g",c.setAttribute("part","emoji"),l.className="c",l.setAttribute("part","count"),c.textContent=n,r.append(c),s.append(r,l),s instanceof HTMLButtonElement?(s.type="button",s.addEventListener("click",()=>void this.#b(n))):(s.className="i",s.setAttribute("part","item")),this.#a.set(n,{element:s,burst:r,glyph:c,count:l}),e.append(s)}const a=document.createElement("style");a.textContent=x,this.#u.replaceChildren(a,e)}#l(){const t=this.hasAttribute("readonly"),e=new Intl.NumberFormat(void 0,{notation:"compact"});for(const[o,{element:a,count:n}]of this.#a){const s=this.#t[o]??0;if(n.textContent=s>0?e.format(s):"",t)a.hidden=s===0;else{const r=this.#r.has(o);a.setAttribute("aria-pressed",String(r)),a.setAttribute("part",r?"button pressed":"button")}}}#g(t,e){const o=this.#a.get(t);if(!o||!window.matchMedia?.("(prefers-reduced-motion: no-preference)").matches)return;const{burst:a,glyph:n,count:s}=o;for(const c of n.getAnimations())c.cancel();if(n.animate({transform:e?["scale(1)","scale(1.4)","scale(0.92)","scale(1)"]:["scale(1)","scale(0.8)","scale(1)"]},{duration:e?400:200,easing:"ease-out"}),s.textContent&&s.animate({transform:[`translateY(${e?.4:-.4}em)`,"none"],opacity:[0,1]},{duration:200,easing:"ease-out"}),!e)return;const r=Math.random();for(let c=0;c<y;c++){const l=(c+r)/y*2*Math.PI,h=1.3+Math.random()*.5,u=document.createElement("span");u.className="p",u.textContent=t,u.setAttribute("aria-hidden","true"),a.append(u),u.animate({transform:["scale(0.6)",`translate(${Math.cos(l)*h}em, ${Math.sin(l)*h}em) scale(0.3)`],opacity:[1,1,0]},{duration:450,easing:"cubic-bezier(0.2, 0.7, 0.3, 1)"}).onfinish=()=>u.remove()}}async#b(t){if(this.#c.has(t))return;this.#c.add(t);const e=!this.#r.has(t),o=this.#t[t]??0,a=s=>{s?this.#r.add(t):this.#r.delete(t)};a(e),this.#t={...this.#t,[t]:Math.max(0,o+(e?1:-1))},this.#l(),this.#g(t,e);const n={url:this.#p,emoji:t,action:e?"add":"remove",...this.#s?{page:this.#s}:{}};try{const s=await fetch(new URL("v1/reactions",this.#n),{...g,method:"POST",headers:{"content-type":"text/plain;charset=UTF-8"},body:JSON.stringify(n)});if(!s.ok)throw new Error(`HTTP ${s.status}`);const r=await s.json();this.#t=r.counts}catch{a(!e),this.#t={...this.#t,[t]:o}}finally{this.#c.delete(t),this.#l()}}}function M(i={}){i.api&&(b=i.api),customElements.get("pagemoji-reactions")||customElements.define("pagemoji-reactions",p)}const f=document.currentScript,d=f instanceof HTMLScriptElement&&f.src?f:null;M(d?{api:new URL("../",d.src).href}:{});const E=d?.dataset.mount?.match(/^(before|after|prepend|append):(.+)$/);if(d&&E){const[,i,t=""]=E,e=()=>{const o=document.querySelector(t);if(!o)return;const a=document.createElement("pagemoji-reactions");for(const n of["emojis","page","variant","label"]){const s=d.dataset[n];s&&a.setAttribute(n,s)}i==="before"?o.before(a):i==="after"?o.after(a):i==="prepend"?o.prepend(a):o.append(a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()}})();
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The contract between the widget and the server: limits, page identity and
3
+ * the JSON shapes on the wire. Both sides import this module, so a page key is
4
+ * computed by exactly one function.
5
+ */
6
+ export declare const MAX_EMOJIS_PER_WIDGET = 5;
7
+ export declare const MAX_KEYS_PER_REQUEST = 100;
8
+ export interface PageRef {
9
+ host: string;
10
+ /** `id:<page id>` when the page has a stable ID, otherwise the path. */
11
+ key: string;
12
+ }
13
+ export declare function isValidPageId(value: string): boolean;
14
+ /** Lowercase, without a trailing dot or a leading `www.`. */
15
+ export declare function normalizeHost(hostname: string): string;
16
+ /**
17
+ * The page a reaction belongs to. Paths start with `/` and IDs with `id:`, so
18
+ * the two kinds of key can never collide.
19
+ */
20
+ export declare function pageRef(url: URL, pageId?: string | null): PageRef;
21
+ /** `GET /v1/counts`: `{ [key]: { [emoji]: count } }` */
22
+ export interface CountsResponse {
23
+ counts: Record<string, Record<string, number>>;
24
+ }
25
+ /**
26
+ * `POST /v1/reactions`. Sent as `text/plain` so the browser doesn't need a CORS
27
+ * preflight; the body is still JSON. `url` is origin and path only.
28
+ */
29
+ export interface ReactionRequest {
30
+ url: string;
31
+ page?: string;
32
+ emoji: string;
33
+ action: 'add' | 'remove';
34
+ }
35
+ export interface ReactionResponse {
36
+ key: string;
37
+ /** False when the duplicate guard turned the request into a no-op. */
38
+ applied: boolean;
39
+ /** Every emoji counted on the page, not only the ones in this widget. */
40
+ counts: Record<string, number>;
41
+ }
42
+ export interface ErrorResponse {
43
+ error: string;
44
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The contract between the widget and the server: limits, page identity and
3
+ * the JSON shapes on the wire. Both sides import this module, so a page key is
4
+ * computed by exactly one function.
5
+ */
6
+ export const MAX_EMOJIS_PER_WIDGET = 5;
7
+ export const MAX_KEYS_PER_REQUEST = 100;
8
+ const PAGE_ID = /^[A-Za-z0-9._:-]{1,200}$/;
9
+ export function isValidPageId(value) {
10
+ return PAGE_ID.test(value);
11
+ }
12
+ /** Lowercase, without a trailing dot or a leading `www.`. */
13
+ export function normalizeHost(hostname) {
14
+ const host = hostname.toLowerCase().replace(/\.$/, '');
15
+ return host.startsWith('www.') ? host.slice(4) : host;
16
+ }
17
+ /**
18
+ * The page a reaction belongs to. Paths start with `/` and IDs with `id:`, so
19
+ * the two kinds of key can never collide.
20
+ */
21
+ export function pageRef(url, pageId) {
22
+ const host = normalizeHost(url.hostname);
23
+ if (pageId)
24
+ return { host, key: `id:${pageId}` };
25
+ return { host, key: url.pathname.replace(/\/+$/, '') || '/' };
26
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@magicpages/pagemoji",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Emoji reactions for any web page. No cookies, no tracking.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/magicpages/pagemoji.git",
9
+ "directory": "packages/widget"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./embed": {
22
+ "types": "./dist/embed.d.ts",
23
+ "default": "./dist/embed.js"
24
+ },
25
+ "./protocol": {
26
+ "types": "./dist/protocol.d.ts",
27
+ "default": "./dist/protocol.js"
28
+ },
29
+ "./pagemoji.js": "./dist/pagemoji.js"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json && vite build",
33
+ "lint": "tsc --noEmit",
34
+ "test": "vitest run"
35
+ },
36
+ "devDependencies": {
37
+ "jsdom": "25.0.1",
38
+ "typescript": "5.9.3",
39
+ "vite": "6.4.3",
40
+ "vitest": "4.1.11"
41
+ }
42
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.