@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 +21 -0
- package/README.md +39 -0
- package/dist/element.js +3 -3
- package/dist/embed.d.ts +2 -2
- package/dist/embed.js +1 -1
- package/dist/mount.d.ts +14 -0
- package/dist/mount.js +48 -0
- package/dist/pagemoji.js +5 -5
- package/package.json +1 -1
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.
|
|
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.
|
|
19
|
+
gap: var(--pagemoji-gap, 0.5em);
|
|
20
20
|
justify-content: var(--pagemoji-justify, flex-start);
|
|
21
21
|
}
|
|
22
|
-
.l { margin-inline-end: 0.
|
|
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
|
|
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 = "
|
|
40
|
+
export declare const DEFAULT_MOUNT = "auto";
|
|
41
41
|
export declare function embedCode(target: EmbedTarget, options: EmbedOptions): string;
|
package/dist/embed.js
CHANGED
package/dist/mount.d.ts
ADDED
|
@@ -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(
|
|
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.
|
|
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.
|
|
11
|
+
gap: var(--pagemoji-gap, 0.5em);
|
|
12
12
|
justify-content: var(--pagemoji-justify, flex-start);
|
|
13
13
|
}
|
|
14
|
-
.l { margin-inline-end: 0.
|
|
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
|
|
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()}})();
|