@magicpages/pagemoji 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.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/element.d.ts +9 -0
- package/dist/element.js +302 -0
- package/dist/embed.d.ts +41 -0
- package/dist/embed.js +68 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pagemoji.js +51 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.js +26 -0
- package/package.json +40 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Magic Pages – Managed Ghost CMS Hosting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @magicpages/pagemoji
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Emoji reactions for any web page. Pick up to five emojis, and readers react with a click. No account, no cookies, no tracking.
|
|
4
|
+
|
|
5
|
+
This package is the widget. Most sites don't need it: load the script from the free hosted server at `pagemoji.magicpages.co`, or from your own. Embed options, styling, privacy and self-hosting are in the [main README](https://github.com/magicpages/pagemoji#readme).
|
|
6
|
+
|
|
7
|
+
```html
|
|
8
|
+
<script src="https://pagemoji.magicpages.co/v1/pagemoji.js" defer></script>
|
|
9
|
+
<pagemoji-reactions emojis="❤️ 👏 💡"></pagemoji-reactions>
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Bundling it yourself
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @magicpages/pagemoji
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { definePagemoji } from '@magicpages/pagemoji';
|
|
20
|
+
|
|
21
|
+
definePagemoji({ api: 'https://pagemoji.magicpages.co' });
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`definePagemoji` registers `<pagemoji-reactions>` and sets the server for widgets without an `api` attribute.
|
|
25
|
+
|
|
26
|
+
## Generating embed code
|
|
27
|
+
|
|
28
|
+
`@magicpages/pagemoji/embed` validates emoji input and writes the embed code, for building your own generator:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import { embedCode, parseEmojis } from '@magicpages/pagemoji/embed';
|
|
32
|
+
|
|
33
|
+
const { emojis } = parseEmojis('❤️ 👏 💡');
|
|
34
|
+
embedCode('html', { emojis, server: 'https://pagemoji.magicpages.co' });
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -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;
|
package/dist/element.js
ADDED
|
@@ -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
|
+
}
|
package/dist/embed.d.ts
ADDED
|
@@ -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('&', '&')
|
|
38
|
+
.replaceAll('"', '"')
|
|
39
|
+
.replaceAll('<', '<')
|
|
40
|
+
.replaceAll('>', '>');
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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';
|
package/dist/pagemoji.js
ADDED
|
@@ -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
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
}
|