@magicpages/pagemoji 0.1.0 → 0.2.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.
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 ADDED
@@ -0,0 +1,39 @@
1
+ # @magicpages/pagemoji
2
+
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
package/dist/element.js CHANGED
@@ -11,15 +11,15 @@ const STYLES = `
11
11
  --_accent: var(--pagemoji-accent, var(--ghost-accent-color, currentColor));
12
12
  display: block;
13
13
  color: var(--pagemoji-fg, inherit);
14
- font-size: var(--pagemoji-font-size, 0.9375rem);
14
+ font-size: var(--pagemoji-font-size, 0.875em);
15
15
  }
16
16
  :host([hidden]) { display: none; }
17
17
  .r {
18
18
  display: flex; flex-wrap: wrap; align-items: center;
19
- gap: var(--pagemoji-gap, 0.5rem);
19
+ gap: var(--pagemoji-gap, 0.5em);
20
20
  justify-content: var(--pagemoji-justify, flex-start);
21
21
  }
22
- .l { margin-inline-end: 0.25rem; }
22
+ .l { margin-inline-end: 0.25em; }
23
23
  button, .i {
24
24
  display: inline-flex; align-items: center; justify-content: center; gap: 0.4em;
25
25
  font: inherit; color: inherit; line-height: 1;
package/dist/embed.d.ts CHANGED
@@ -34,8 +34,8 @@ export interface EmbedOptions {
34
34
  variant?: WidgetVariant;
35
35
  /** Text shown before the buttons, e.g. "What did you think?". Not used on cards. */
36
36
  label?: string;
37
- /** Where `inject` mounts the widget. Defaults to Ghost's post body. */
37
+ /** Where `inject` mounts the widget. Defaults to `auto`: after the post body, on posts only. */
38
38
  mount?: string;
39
39
  }
40
- export declare const DEFAULT_MOUNT = "after:.gh-content";
40
+ export declare const DEFAULT_MOUNT = "auto";
41
41
  export declare function embedCode(target: EmbedTarget, options: EmbedOptions): string;
package/dist/embed.js CHANGED
@@ -31,7 +31,7 @@ export function parseEmojis(input) {
31
31
  }
32
32
  return selection;
33
33
  }
34
- export const DEFAULT_MOUNT = 'after:.gh-content';
34
+ export const DEFAULT_MOUNT = 'auto';
35
35
  function escape(value) {
36
36
  return value
37
37
  .replaceAll('&', '&amp;')
@@ -0,0 +1,14 @@
1
+ export type Placement = 'before' | 'after' | 'prepend' | 'append';
2
+ export interface MountPoint {
3
+ target: Element;
4
+ where: Placement;
5
+ }
6
+ /**
7
+ * `.gh-content` is the post body in Ghost's official themes, even on a members-only post that
8
+ * shows only a signup prompt. Elsewhere it's the element with the most paragraphs as direct
9
+ * children, which holds for Ghost's `{{content}}` and WordPress's `the_content()` whatever the
10
+ * theme calls the wrapper.
11
+ */
12
+ export declare function findPostBody(root: Document): Element | null;
13
+ /** `auto`, or `before:`, `after:`, `prepend:` or `append:` and a CSS selector. */
14
+ export declare function resolveMount(spec: string, root: Document): MountPoint | null;
package/dist/mount.js ADDED
@@ -0,0 +1,48 @@
1
+ /** Ghost's `{{body_class}}` and WordPress's `body_class()` mark single posts with these. */
2
+ const POST_PAGE = 'body.post-template, body.single-post';
3
+ /**
4
+ * `.gh-content` is the post body in Ghost's official themes, even on a members-only post that
5
+ * shows only a signup prompt. Elsewhere it's the element with the most paragraphs as direct
6
+ * children, which holds for Ghost's `{{content}}` and WordPress's `the_content()` whatever the
7
+ * theme calls the wrapper.
8
+ */
9
+ export function findPostBody(root) {
10
+ if (!root.querySelector(POST_PAGE))
11
+ return null;
12
+ const ghost = root.querySelector('.gh-content');
13
+ if (ghost)
14
+ return ghost;
15
+ const counts = new Map();
16
+ let best = null;
17
+ let bestCount = 0;
18
+ for (const paragraph of root.querySelectorAll('body p')) {
19
+ const parent = paragraph.parentElement;
20
+ if (!parent || parent.closest('header, footer, nav, aside, form'))
21
+ continue;
22
+ const count = (counts.get(parent) ?? 0) + 1;
23
+ counts.set(parent, count);
24
+ if (count > bestCount) {
25
+ best = parent;
26
+ bestCount = count;
27
+ }
28
+ }
29
+ return best;
30
+ }
31
+ /** `auto`, or `before:`, `after:`, `prepend:` or `append:` and a CSS selector. */
32
+ export function resolveMount(spec, root) {
33
+ if (spec === 'auto') {
34
+ // Inside, not after: themes lay the post body out as a grid, and outside it the widget
35
+ // would leave the text column.
36
+ const target = findPostBody(root);
37
+ return target ? { target, where: 'append' } : null;
38
+ }
39
+ const match = spec.match(/^(before|after|prepend|append):(.+)$/);
40
+ if (!match)
41
+ return null;
42
+ const [, where = '', selector = ''] = match;
43
+ const target = root.querySelector(selector);
44
+ return target && isPlacement(where) ? { target, where } : null;
45
+ }
46
+ function isPlacement(value) {
47
+ return value === 'before' || value === 'after' || value === 'prepend' || value === 'append';
48
+ }
package/dist/pagemoji.js CHANGED
@@ -1,17 +1,17 @@
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=`
1
+ (function(){"use strict";const w=/^[A-Za-z0-9._:-]{1,200}$/;function S(a){return w.test(a)}function m(a){const t=a.toLowerCase().replace(/\.$/,"");return t.startsWith("www.")?t.slice(4):t}function A(a,t){const e=m(a.hostname);return t?{host:e,key:`id:${t}`}:{host:e,key:a.pathname.replace(/\/+$/,"")||"/"}}const k=["❤️","👏","💡"],g={credentials:"omit",referrerPolicy:"strict-origin"},x=`
2
2
  :host {
3
3
  --_accent: var(--pagemoji-accent, var(--ghost-accent-color, currentColor));
4
4
  display: block;
5
5
  color: var(--pagemoji-fg, inherit);
6
- font-size: var(--pagemoji-font-size, 0.9375rem);
6
+ font-size: var(--pagemoji-font-size, 0.875em);
7
7
  }
8
8
  :host([hidden]) { display: none; }
9
9
  .r {
10
10
  display: flex; flex-wrap: wrap; align-items: center;
11
- gap: var(--pagemoji-gap, 0.5rem);
11
+ gap: var(--pagemoji-gap, 0.5em);
12
12
  justify-content: var(--pagemoji-justify, flex-start);
13
13
  }
14
- .l { margin-inline-end: 0.25rem; }
14
+ .l { margin-inline-end: 0.25em; }
15
15
  button, .i {
16
16
  display: inline-flex; align-items: center; justify-content: center; gap: 0.4em;
17
17
  font: inherit; color: inherit; line-height: 1;
@@ -48,4 +48,4 @@ button:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
48
48
  .g { transition: transform 0.15s ease-out; }
49
49
  button:hover .g { transform: scale(1.15); }
50
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()}})();
51
+ `;let b;function y(a){try{return new URL(a,document.baseURI)}catch{return null}}const v=6;class u extends HTMLElement{static#i=[];#p=this.attachShadow({mode:"open"});#n="";#e=null;#o=null;#u="";#s=[];#r=new Map;#t={};#a=new Set;#c=new Set;connectedCallback(){if(this.#e)return;const t=y(this.getAttribute("api")??b??"");if(!t)return;this.#n=t.href.endsWith("/")?t.href:`${t.href}/`;const e=new URL(location.href);let s=e;const r=this.getAttribute("url")??document.querySelector('link[rel="canonical"]')?.href,o=r?y(r):null;o&&m(o.hostname)===m(e.hostname)&&(s=o);const n=this.getAttribute("page");this.#o=n&&S(n)?n:null,this.#e=A(s,this.#o),this.#u=e.origin+s.pathname;const i=new Set((this.getAttribute("emojis")??"").split(/\s+/).filter(Boolean));this.#s=i.size?[...i].slice(0,5):k,u.#i.push(this)===1&&queueMicrotask(()=>void u.#d())}static async#d(){const t=u.#i;u.#i=[];const e=new Map;for(const s of t){const r=[s.#n,s.#e?.host,...s.#s].join(" ");let o=e.get(r);o||e.set(r,o=[]),o.push(s)}await Promise.all([...e.values()].map(s=>u.#h(s)))}static async#h(t){const[e]=t,s=e?e.#e?.host:void 0;if(!e||!s)return;const r=[...new Set(t.map(o=>o.#e?.key??""))];for(let o=0;o<r.length;o+=100){const n=new URLSearchParams({host:s});for(const i of r.slice(o,o+100))n.append("key",i);for(const i of e.#s)n.append("emoji",i);try{const i=await fetch(new URL(`v1/counts?${n}`,e.#n),g);if(!i.ok)continue;const{counts:c}=await i.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.#r.size===0&&this.#f(),this.#l()}#f(){const t=this.hasAttribute("readonly"),e=document.createElement("div");e.className="r",e.setAttribute("part","list");const s=this.getAttribute("label")?.trim();if(s){const o=document.createElement("span");o.className="l",o.setAttribute("part","label"),o.textContent=s,e.append(o)}for(const o of this.#s){const n=document.createElement(t?"span":"button"),i=document.createElement("span"),c=document.createElement("span"),l=document.createElement("span");i.className="e",c.className="g",c.setAttribute("part","emoji"),l.className="c",l.setAttribute("part","count"),c.textContent=o,i.append(c),n.append(i,l),n instanceof HTMLButtonElement?(n.type="button",n.addEventListener("click",()=>void this.#b(o))):(n.className="i",n.setAttribute("part","item")),this.#r.set(o,{element:n,burst:i,glyph:c,count:l}),e.append(n)}const r=document.createElement("style");r.textContent=x,this.#p.replaceChildren(r,e)}#l(){const t=this.hasAttribute("readonly"),e=new Intl.NumberFormat(void 0,{notation:"compact"});for(const[s,{element:r,count:o}]of this.#r){const n=this.#t[s]??0;if(o.textContent=n>0?e.format(n):"",t)r.hidden=n===0;else{const i=this.#a.has(s);r.setAttribute("aria-pressed",String(i)),r.setAttribute("part",i?"button pressed":"button")}}}#g(t,e){const s=this.#r.get(t);if(!s||!window.matchMedia?.("(prefers-reduced-motion: no-preference)").matches)return;const{burst:r,glyph:o,count:n}=s;for(const c of o.getAnimations())c.cancel();if(o.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"}),n.textContent&&n.animate({transform:[`translateY(${e?.4:-.4}em)`,"none"],opacity:[0,1]},{duration:200,easing:"ease-out"}),!e)return;const i=Math.random();for(let c=0;c<v;c++){const l=(c+i)/v*2*Math.PI,h=1.3+Math.random()*.5,p=document.createElement("span");p.className="p",p.textContent=t,p.setAttribute("aria-hidden","true"),r.append(p),p.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=()=>p.remove()}}async#b(t){if(this.#c.has(t))return;this.#c.add(t);const e=!this.#a.has(t),s=this.#t[t]??0,r=n=>{n?this.#a.add(t):this.#a.delete(t)};r(e),this.#t={...this.#t,[t]:Math.max(0,s+(e?1:-1))},this.#l(),this.#g(t,e);const o={url:this.#u,emoji:t,action:e?"add":"remove",...this.#o?{page:this.#o}:{}};try{const n=await fetch(new URL("v1/reactions",this.#n),{...g,method:"POST",headers:{"content-type":"text/plain;charset=UTF-8"},body:JSON.stringify(o)});if(!n.ok)throw new Error(`HTTP ${n.status}`);const i=await n.json();this.#t=i.counts}catch{r(!e),this.#t={...this.#t,[t]:s}}finally{this.#c.delete(t),this.#l()}}}function M(a={}){a.api&&(b=a.api),customElements.get("pagemoji-reactions")||customElements.define("pagemoji-reactions",u)}const _="body.post-template, body.single-post";function T(a){if(!a.querySelector(_))return null;const t=a.querySelector(".gh-content");if(t)return t;const e=new Map;let s=null,r=0;for(const o of a.querySelectorAll("body p")){const n=o.parentElement;if(!n||n.closest("header, footer, nav, aside, form"))continue;const i=(e.get(n)??0)+1;e.set(n,i),i>r&&(s=n,r=i)}return s}function j(a,t){if(a==="auto"){const n=T(t);return n?{target:n,where:"append"}:null}const e=a.match(/^(before|after|prepend|append):(.+)$/);if(!e)return null;const[,s="",r=""]=e,o=t.querySelector(r);return o&&C(s)?{target:o,where:s}:null}function C(a){return a==="before"||a==="after"||a==="prepend"||a==="append"}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;if(d&&E){const a=()=>{const t=j(E,document);if(!t)return;const e=document.createElement("pagemoji-reactions");for(const s of["emojis","page","variant","label"]){const r=d.dataset[s];r&&e.setAttribute(s,r)}t.target[t.where](e)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",a):a()}})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magicpages/pagemoji",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Emoji reactions for any web page. No cookies, no tracking.",
5
5
  "license": "MIT",
6
6
  "repository": {