@magicpages/pagemoji 0.1.1 → 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/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('&', '&')
@@ -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.1",
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": {