@pmeweb/plugin-chat 0.0.0-stage → 1.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.
package/src/Chat.astro ADDED
@@ -0,0 +1,110 @@
1
+ ---
2
+ // <Chat config={plugins.chat} lang="fr" siteKey={TURNSTILE_SITE_KEY} opener={opener} logo={logoUrl} site={site} privacyHref="/confidentialite/" />
3
+ // Once, in Base.astro, on every page (manifest markup.pages "all"). The bubble and the opener are
4
+ // plain HTML and CSS until touched: the inlined trigger shows them on the site's real hosts only
5
+ // (they ship `hidden`), and imports the panel (dist/widget.js, its URL in data-pmw-lazy) on the
6
+ // first touch. A site restyles through the --pmw-chat-* variables; it never forks this file.
7
+ //
8
+ // - `opener` is THIS page's line (the site's dictionary `chat.openers[path]`, else
9
+ // `chat.defaultOpener` with the entry's title). No opener throws at build: every page has one.
10
+ // - The avatar is config.avatar (src/faces.js): a face `photos-<n>` or `illustration-<n>` shipped in
11
+ // this package, an icon `sans-visage-<n>` drawn inline, or `logo` (the `logo` prop, a URL). A face
12
+ // is a 96 px WebP imported `?url&no-inline` (a cached file, never a data: URL repeated in every
13
+ // page or refused by a CSP); only the chosen one is in the page. An icon is the SVG's own text,
14
+ // inlined at build: no request at all. `alt=""`: the visible « Assistant virtuel » names the
15
+ // button (spec § 6).
16
+ // - The button's accessible name starts with its visible text (WCAG 2.5.3).
17
+ // - config.photos === true (and only true) turns the panel's photo picker on.
18
+ // - `site` is pmeweb.json: its `design.colors` colour the panel and the sans-visage circle (src/colors.js), as one
19
+ // zero-specificity rule a site's own --pmw-chat-* value beats. Absent, the package defaults.
20
+ // - config.enabled is the client's switch (manifest activeWhen). Anything but `true` is off: the
21
+ // component renders NOTHING (no markup, no script, no CSS: chat.css is inlined here rather than
22
+ // imported, or the bundler would put it in every page), except in a « Contenu » preview build
23
+ // (PMEWEB_PORTAL_PREVIEW exactly '1', set by the site builder for its preview build and dev
24
+ // server, never for the publish preflight), where the bubble shows forced into the demo
25
+ // (`demo: true`: no request, whatever the host). No visitor-side flag turns it on.
26
+ import code from '../dist/trigger.iife.js?raw';
27
+ import lazy from '../dist/widget.js?url&no-inline';
28
+ import { langOf, stringsFor } from './strings.js';
29
+ import { AVATAR_PATTERN, FACES, ICONS } from './faces.js';
30
+ import { HEX_RE, avatarTheme, panelTheme, siteColors, themeCss } from './colors.js';
31
+ import chatCss from './chat.css?inline';
32
+
33
+ interface Props {
34
+ config?: Record<string, unknown>;
35
+ lang?: string;
36
+ siteKey: string;
37
+ opener: string;
38
+ logo?: string | { src: string };
39
+ privacyHref?: string;
40
+ strings?: Record<string, string>;
41
+ site?: Record<string, unknown>;
42
+ }
43
+ const { config = {}, lang: rawLang = 'fr', siteKey, opener, logo, privacyHref, strings = {}, site } = Astro.props;
44
+ const enabled = config.enabled === true;
45
+ const show = enabled || process.env.PMEWEB_PORTAL_PREVIEW === '1';
46
+ const lang = langOf(rawLang);
47
+ const s = stringsFor(lang, strings);
48
+ if (typeof opener !== 'string' || opener.trim() === '') throw new Error('chat : pas d’accroche pour cette page');
49
+ if (typeof siteKey !== 'string' || siteKey === '') throw new Error('chat : la clé de site Turnstile manque');
50
+
51
+ const avatar = String(config.avatar ?? '');
52
+ const faces = import.meta.glob('../avatars/*.webp', { query: '?url&no-inline', import: 'default' });
53
+ const icons = import.meta.glob('../avatars/*.svg', { query: '?raw', import: 'default', eager: true });
54
+ let src: string | null = null;
55
+ let svg: string | null = null;
56
+ let kind: 'face' | 'icon' | 'logo';
57
+ if (!new RegExp(AVATAR_PATTERN).test(avatar)) throw new Error(`chat : avatar « ${avatar} » inconnu (${[...FACES, ...ICONS, 'logo'].join(', ')})`);
58
+ if (FACES.includes(avatar)) {
59
+ kind = 'face';
60
+ const face = faces[`../avatars/${avatar}.webp`];
61
+ if (!face) throw new Error(`chat : visage ${avatar} absent du paquet`);
62
+ // Only when shown: switched off, the build emits no face at all.
63
+ src = show ? ((await face()) as string) : null;
64
+ } else if (ICONS.includes(avatar)) {
65
+ kind = 'icon';
66
+ const raw = icons[`../avatars/${avatar}.svg`] as string | undefined;
67
+ if (!raw) throw new Error(`chat : icône ${avatar} absente du paquet`);
68
+ // The file's own <svg> tag (xmlns, a 48 px size) becomes a decorative one sized by the CSS.
69
+ svg = raw.trim().replace(/<svg\b[^>]*>/, '<svg viewBox="0 0 48 48" focusable="false">');
70
+ } else {
71
+ kind = 'logo';
72
+ src = typeof logo === 'string' ? logo : (logo?.src ?? null);
73
+ if (!src) throw new Error('chat : avatar « logo » sans la prop logo');
74
+ }
75
+ const colors = siteColors(site);
76
+ if (config.avatarBg !== undefined && !(typeof config.avatarBg === 'string' && HEX_RE.test(config.avatarBg))) {
77
+ throw new Error(`chat : avatarBg « ${config.avatarBg} » invalide (#rrggbb en minuscules)`);
78
+ }
79
+ const vars: Record<string, string> = panelTheme(colors);
80
+ if (kind === 'icon') {
81
+ const t = avatarTheme(config.avatarBg as string | undefined, colors);
82
+ vars['--pmw-chat-avatar-bg'] = t.bg;
83
+ vars['--pmw-chat-avatar-fg'] = t.fg;
84
+ if (t.ring) vars['--pmw-chat-avatar-ring'] = t.ring;
85
+ }
86
+ const theme = themeCss(vars);
87
+ const json = JSON.stringify({ lang, siteKey, avatar: src, kind, privacyHref: privacyHref ?? null, strings, ...(config.photos === true ? { photos: true } : {}), ...(enabled ? {} : { demo: true }) }).replace(/</g, '\\u003c');
88
+ // Every `</` and `<!--` escaped, so the inlined code cannot close or confuse its <script> tag.
89
+ const script = code.replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
90
+ ---
91
+ {show && <style is:inline set:html={chatCss} />}
92
+ {show && theme && <style is:inline set:html={theme} />}
93
+ {show && <div id="pmw-chat" class="pmw-chat" data-pmw-chat-opener={opener} data-pmw-lazy={lazy} lang={lang === 'en' ? 'en' : 'fr-CA'} hidden>
94
+ <div class="pmw-chat__teaser" hidden>
95
+ <button type="button" class="pmw-chat__teaser-text" data-pmw-chat-open>{opener}</button>
96
+ <button type="button" class="pmw-chat__teaser-close" data-pmw-chat-dismiss aria-label={s.dismissTeaser}>
97
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
98
+ </button>
99
+ </div>
100
+ <button type="button" class="pmw-chat__bubble" data-pmw-chat-open aria-haspopup="dialog" aria-label={s.openLabel}>
101
+ {svg ? <span class="pmw-chat__avatar pmw-chat__avatar--icon" aria-hidden="true" set:html={svg} /> : (
102
+ <span class:list={['pmw-chat__avatar', kind === 'logo' && 'pmw-chat__avatar--logo']} aria-hidden="true">
103
+ <img src={src} alt="" width="48" height="48" decoding="async" />
104
+ </span>
105
+ )}
106
+ <span class="pmw-chat__label">{s.assistant}</span>
107
+ </button>
108
+ <script type="application/json" data-pmw-chat-config set:html={json} />
109
+ </div>}
110
+ {show && <script is:inline data-pmw-plugin="chat" set:html={script} />}
package/src/build.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export declare const CONTEXT_FORMAT: 1
2
+ export declare const MAX_CONTEXT_BYTES: number
3
+
4
+ export interface ChatContextLang {
5
+ /** The business's name in this language (the bot says it; pmeweb-chat falls back to the host). */
6
+ business?: string
7
+ /** What src/pages/llms.txt.ts renders for this language. */
8
+ llms?: string
9
+ /** The pricing text (the source of public/pricing.md). */
10
+ pricing?: string
11
+ /** src/i18n/<lang>.ts: flattened to `key: value` lines, strings only. */
12
+ dictionary?: object
13
+ }
14
+
15
+ export interface ChatContextEntry {
16
+ collection: string
17
+ /** Absent: the entry belongs to every language. */
18
+ lang?: 'fr' | 'en'
19
+ title: string
20
+ data?: Record<string, unknown>
21
+ body?: string
22
+ }
23
+
24
+ /** The JSON text of plugins/chat/context.json. Deterministic; throws above 200 KB or on an empty language. */
25
+ export declare function chatContext(sources: {
26
+ langs: Partial<Record<'fr' | 'en', ChatContextLang>>
27
+ collections?: ChatContextEntry[]
28
+ }): string
29
+
30
+ export declare function flattenDictionary(dict: object, prefix?: string): string
package/src/build.js ADDED
@@ -0,0 +1,87 @@
1
+ // @pmeweb/plugin-chat/build: the site's context file for pmeweb-chat (manifest `build`:
2
+ // plugins/chat/context.json, one file for every language). The site renders it from a prerendered
3
+ // route with sources it already has (llms.txt, its pricing text, its dictionaries, its published
4
+ // collection entries); pmeweb-chat reads `langs[lang]` (src/context.ts there):
5
+ //
6
+ // { "format": 1, "langs": { "fr": { "business": "...", "sections": [{ "title", "text" }] } } }
7
+ //
8
+ // DETERMINISTIC: same sources, same bytes. No date, no random id, no network, no model call (the
9
+ // builder holds no secret, and a rebuild must not spend money). Not public: the site's worker serves
10
+ // it only to a signed request (plugin-kit's pluginArtifacts), never to a visitor.
11
+
12
+ export const CONTEXT_FORMAT = 1
13
+ /** About 50k tokens at most: past this the prompt would cost too much per message. */
14
+ export const MAX_CONTEXT_BYTES = 200 * 1024
15
+
16
+ const LANGS = ['fr', 'en']
17
+ const TITLES = {
18
+ fr: { llms: 'llms.txt', pricing: 'Tarifs', dictionary: 'Textes du site', colon: ' : ' },
19
+ en: { llms: 'llms.txt', pricing: 'Pricing', dictionary: 'Site text', colon: ': ' },
20
+ }
21
+
22
+ const text = (v) => (typeof v === 'string' ? v.trim() : '')
23
+ const oneLine = (v) => v.replace(/\s*\n\s*/g, ' ').trim()
24
+
25
+ /** A dictionary as `path.to.key: value` lines, strings only, in the dictionary's own order. */
26
+ export function flattenDictionary(dict, prefix = '') {
27
+ const out = []
28
+ const walk = (v, path) => {
29
+ if (typeof v === 'string') {
30
+ if (v.trim() !== '') out.push(`${path}: ${oneLine(v)}`)
31
+ } else if (Array.isArray(v)) {
32
+ v.forEach((x, i) => walk(x, path ? `${path}.${i}` : String(i)))
33
+ } else if (v && typeof v === 'object') {
34
+ for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k)
35
+ }
36
+ }
37
+ walk(dict, prefix)
38
+ return out.join('\n')
39
+ }
40
+
41
+ /** One collection entry: its string fields as `key: value` lines (the title itself left out), then its body. */
42
+ function entryText(e) {
43
+ const lines = []
44
+ for (const [k, v] of Object.entries(e.data ?? {})) {
45
+ if (typeof v !== 'string' || v.trim() === '' || v === e.title) continue
46
+ lines.push(`${k}: ${oneLine(v)}`)
47
+ }
48
+ const body = text(e.body)
49
+ return [lines.join('\n'), body].filter(Boolean).join('\n\n')
50
+ }
51
+
52
+ /**
53
+ * @param {{
54
+ * langs: Partial<Record<'fr' | 'en', { business?: string, llms?: string, pricing?: string, dictionary?: object }>>,
55
+ * collections?: { collection: string, lang?: 'fr' | 'en', title: string, data?: Record<string, unknown>, body?: string }[],
56
+ * }} sources an entry with no `lang` belongs to every language
57
+ * @returns {string} the JSON text of plugins/chat/context.json
58
+ */
59
+ export function chatContext({ langs = {}, collections = [] } = {}) {
60
+ const keys = Object.keys(langs)
61
+ if (keys.length === 0 || keys.some((l) => !LANGS.includes(l))) {
62
+ throw new Error(`chat : une langue fr ou en au moins, et aucune autre (reçu ${keys.join(', ') || 'rien'})`)
63
+ }
64
+ const out = { format: CONTEXT_FORMAT, langs: {} }
65
+ for (const lang of LANGS.filter((l) => keys.includes(l))) {
66
+ const src = langs[lang] ?? {}
67
+ const t = TITLES[lang]
68
+ const sections = []
69
+ const add = (title, body) => { if (body) sections.push({ title, text: body }) }
70
+ add(t.llms, text(src.llms))
71
+ add(t.pricing, text(src.pricing))
72
+ add(t.dictionary, src.dictionary ? flattenDictionary(src.dictionary) : '')
73
+ for (const e of collections) {
74
+ if (e.lang !== undefined && e.lang !== lang) continue
75
+ add(`${e.collection}${t.colon}${text(e.title)}`, entryText(e))
76
+ }
77
+ if (sections.length === 0) throw new Error(`chat : contexte ${lang} vide`)
78
+ const part = {}
79
+ if (text(src.business)) part.business = text(src.business)
80
+ part.sections = sections
81
+ out.langs[lang] = part
82
+ }
83
+ const json = JSON.stringify(out)
84
+ const bytes = new TextEncoder().encode(json).length
85
+ if (bytes > MAX_CONTEXT_BYTES) throw new Error(`chat : contexte de ${bytes} octets, au-delà de 200 Ko (le prompt serait trop long)`)
86
+ return json
87
+ }
package/src/chat.css ADDED
@@ -0,0 +1,65 @@
1
+ /* @pmeweb/plugin-chat: the bubble and the opener, on the page from the first paint. Structure only:
2
+ the --pmw-chat-* variables are NOT declared here, each use carries its default as a fallback, so
3
+ a site sets them anywhere (:root, or #pmw-chat) without fighting an unlayered rule. The panel's
4
+ own rules arrive with the lazy widget (widget.css). Default text pairs pass AA
5
+ (a11y-tools/contrast.mjs): #1f2328 on #ffffff 15.8:1, #ffffff on #0a1b3d 16.98:1, #4b5563 on
6
+ #ffffff 7.56:1. */
7
+ .pmw-chat[hidden] { display: none; }
8
+ .pmw-chat {
9
+ position: fixed; z-index: 80;
10
+ right: calc(env(safe-area-inset-right, 0px) + var(--pmw-chat-right, 16px));
11
+ bottom: calc(env(safe-area-inset-bottom, 0px) + var(--pmw-chat-bottom, 16px));
12
+ display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
13
+ font-family: var(--pmw-chat-font, inherit); font-size: 16px; line-height: 1.4;
14
+ color: var(--pmw-chat-fg, #1f2328);
15
+ }
16
+ .pmw-chat button { font: inherit; cursor: pointer; }
17
+ .pmw-chat button:focus-visible {
18
+ outline: 3px solid var(--pmw-chat-focus, #0a1b3d); outline-offset: 2px;
19
+ box-shadow: 0 0 0 2px #ffffff;
20
+ }
21
+ .pmw-chat__bubble {
22
+ display: flex; align-items: center; gap: 10px; min-height: 56px;
23
+ padding: 4px 18px 4px 4px; border-radius: 999px;
24
+ background: var(--pmw-chat-bubble-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
25
+ border: 1px solid var(--pmw-chat-border, #c9ced6);
26
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
27
+ }
28
+ .pmw-chat__avatar {
29
+ flex: none; width: 48px; height: 48px; border-radius: 50%; overflow: hidden;
30
+ display: flex; align-items: center; justify-content: center;
31
+ background: var(--pmw-chat-avatar-bg, var(--pmw-chat-accent, #0a1b3d));
32
+ color: var(--pmw-chat-avatar-fg, var(--pmw-chat-accent-fg, #ffffff));
33
+ }
34
+ /* A sans-visage circle under 3:1 against the page: a thin ring in ink (Chat.astro sets the ring
35
+ variable only then, src/colors.js avatarTheme). */
36
+ .pmw-chat__avatar--icon { box-shadow: 0 0 0 1.5px var(--pmw-chat-avatar-ring, transparent); }
37
+ .pmw-chat__avatar img { display: block; width: 48px; height: 48px; object-fit: cover; }
38
+ .pmw-chat__avatar--logo { background: #ffffff; border: 1px solid var(--pmw-chat-border, #c9ced6); }
39
+ .pmw-chat__avatar--logo img { object-fit: contain; width: 36px; height: 36px; }
40
+ .pmw-chat__avatar svg { display: block; width: 100%; height: 100%; }
41
+ .pmw-chat__label { font-size: 15px; font-weight: 600; white-space: nowrap; }
42
+
43
+ /* The opener: once per visit, after 20 s, at 30 % of the page, or on exit intent (trigger.js). */
44
+ .pmw-chat__teaser[hidden] { display: none; }
45
+ .pmw-chat__teaser {
46
+ position: relative; max-width: min(300px, calc(100vw - 32px));
47
+ padding: 12px 40px 12px 14px; border-radius: 12px;
48
+ background: var(--pmw-chat-bubble-bg, #ffffff); color: var(--pmw-chat-fg, #1f2328);
49
+ border: 1px solid var(--pmw-chat-border, #c9ced6); box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
50
+ animation: pmw-chat-in 0.22s ease both;
51
+ }
52
+ .pmw-chat__teaser-text { display: block; padding: 0; border: 0; background: none; color: inherit; text-align: left; font-size: 15px; }
53
+ .pmw-chat__teaser-close {
54
+ position: absolute; top: 2px; right: 2px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 50%;
55
+ display: flex; align-items: center; justify-content: center;
56
+ background: none; color: var(--pmw-chat-muted, #4b5563);
57
+ }
58
+ .pmw-chat__teaser-close svg { width: 16px; height: 16px; }
59
+ @keyframes pmw-chat-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .pmw-chat__teaser { animation: none; }
62
+ }
63
+ /* While the panel is open the bubble and the opener step aside. */
64
+ .pmw-chat[data-open] .pmw-chat__bubble, .pmw-chat[data-open] .pmw-chat__teaser { display: none; }
65
+ @media print { .pmw-chat { display: none; } }
package/src/colors.js ADDED
@@ -0,0 +1,104 @@
1
+ // The site's palette (pmeweb.json `design.colors`, five lowercase #rrggbb: primary, accent, ink,
2
+ // background, surface) turned into the --pmw-chat-* variables, at build, in Chat.astro. Every pair
3
+ // keeps its WCAG minimum: a choice that would not is dropped and the package default stays.
4
+ // contrast() is a11y-tools/contrast.mjs's ratio for opaque colours (tests/colors.test.js holds them
5
+ // equal); this package cannot import the workspace tool, since it is published alone.
6
+
7
+ export const HEX_RE = /^#[0-9a-f]{6}$/
8
+ /** The sans-visage circle without avatarBg nor design.colors.accent (the panel's default navy). */
9
+ export const DEFAULT_AVATAR_BG = '#0a1b3d'
10
+ /** The dark text colour when the site declares no ink, or when its ink is too light to help. */
11
+ export const INK_FALLBACK = '#14181f'
12
+ const WHITE = '#ffffff'
13
+ const KEYS = ['primary', 'accent', 'ink', 'background', 'surface']
14
+
15
+ function luminance(hex) {
16
+ const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
17
+ .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
18
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
19
+ }
20
+
21
+ export function contrast(a, b) {
22
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p)
23
+ return (x + 0.05) / (y + 0.05)
24
+ }
25
+
26
+ /** design.colors, its valid entries only (lowercased). */
27
+ export function siteColors(site) {
28
+ const c = site?.design?.colors
29
+ const out = {}
30
+ if (!c || typeof c !== 'object') return out
31
+ for (const k of KEYS) {
32
+ const v = typeof c[k] === 'string' ? c[k].toLowerCase() : ''
33
+ if (HEX_RE.test(v)) out[k] = v
34
+ }
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * The text colour for a fill: white or the site's ink, whichever contrasts more; when neither
40
+ * reaches `min` (a light ink on a light fill), the dark fallback if it does. Null when nothing does.
41
+ */
42
+ export function textOn(fill, ink, min) {
43
+ const pick = [WHITE, ink ?? INK_FALLBACK].sort((a, b) => contrast(b, fill) - contrast(a, fill))[0]
44
+ if (contrast(pick, fill) >= min) return pick
45
+ return contrast(INK_FALLBACK, fill) >= min ? INK_FALLBACK : null
46
+ }
47
+
48
+ /**
49
+ * The sans-visage circle: `avatarBg`, else the accent, else the package default. The glyph keeps
50
+ * 3:1 (a graphic). A circle under 3:1 against the page (or the bubble's surface) gets a thin ring
51
+ * in ink, so its edge still shows; never a text notice.
52
+ */
53
+ export function avatarTheme(avatarBg, colors = {}) {
54
+ const bg = avatarBg ?? colors.accent ?? DEFAULT_AVATAR_BG
55
+ const fg = textOn(bg, colors.ink, 3) ?? [WHITE, INK_FALLBACK].sort((a, b) => contrast(b, bg) - contrast(a, bg))[0]
56
+ const behind = [colors.background ?? WHITE, colors.surface].filter(Boolean)
57
+ const ring = behind.some((b) => contrast(bg, b) < 3) ? (colors.ink ?? INK_FALLBACK) : null
58
+ return { bg, fg, ring }
59
+ }
60
+
61
+ const ERRORS = ['#b42318', '#ffb4ab']
62
+
63
+ /** `fg` laid over `bg` at `alpha`, as an opaque #rrggbb. */
64
+ function mix(fg, bg, alpha) {
65
+ const ch = (h, i) => parseInt(h.slice(i, i + 2), 16)
66
+ return `#${[1, 3, 5].map((i) => Math.round(ch(fg, i) * alpha + ch(bg, i) * (1 - alpha)).toString(16).padStart(2, '0')).join('')}`
67
+ }
68
+
69
+ /**
70
+ * The panel (and the bubble beside it) in the site's colours, two groups each all-or-nothing:
71
+ * - primary: the header, the visitor's bubbles and the send button, text white or ink at 4.5:1;
72
+ * - surface: the panel and the bubble in `surface`, text in `ink` at 4.5:1, the bot's bubbles in
73
+ * `background`, and the colours that sit on them (muted, link, focus, error) re-derived so each
74
+ * keeps its minimum. A group whose pair fails sets nothing: the package defaults stay.
75
+ */
76
+ export function panelTheme(colors = {}) {
77
+ const v = {}
78
+ const { primary, ink, surface, background } = colors
79
+ if (primary) {
80
+ const fg = textOn(primary, ink, 4.5)
81
+ if (fg) Object.assign(v, { '--pmw-chat-accent': primary, '--pmw-chat-accent-fg': fg })
82
+ }
83
+ if (surface && ink && contrast(ink, surface) >= 4.5) {
84
+ const muted = mix(ink, surface, 0.75)
85
+ const page = background ?? WHITE
86
+ Object.assign(v, {
87
+ '--pmw-chat-panel-bg': surface,
88
+ '--pmw-chat-bubble-bg': surface,
89
+ '--pmw-chat-fg': ink,
90
+ '--pmw-chat-other-bg': background && contrast(ink, background) >= 4.5 ? background : surface,
91
+ '--pmw-chat-muted': contrast(muted, surface) >= 4.5 ? muted : ink,
92
+ '--pmw-chat-link': primary && contrast(primary, surface) >= 4.5 ? primary : ink,
93
+ '--pmw-chat-focus': contrast(ink, page) >= 3 ? ink : INK_FALLBACK,
94
+ '--pmw-chat-error': ERRORS.find((e) => contrast(e, surface) >= 4.5) ?? ink,
95
+ })
96
+ }
97
+ return v
98
+ }
99
+
100
+ /** One rule at zero specificity: a site's own --pmw-chat-* value, anywhere, still wins. */
101
+ export function themeCss(vars) {
102
+ const decl = Object.entries(vars).map(([k, v]) => `${k}:${v}`).join(';')
103
+ return decl ? `:where(:root){${decl}}` : ''
104
+ }
package/src/csp.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The CSP hosts the chat needs (Turnstile's script and frame), by directive, for `mergeCsp`. */
2
+ export declare function cspHosts(): Partial<Record<string, string[]>>
package/src/csp.js ADDED
@@ -0,0 +1,5 @@
1
+ // The chat posts through Turnstile: its widget's script and frame. The socket and /api/chat/start
2
+ // are same-origin, which 'self' in connect-src already covers.
3
+ export function cspHosts() {
4
+ return { 'script-src': ['https://challenges.cloudflare.com'], 'frame-src': ['https://challenges.cloudflare.com'] }
5
+ }
package/src/faces.js ADDED
@@ -0,0 +1,24 @@
1
+ // The avatars this package ships (avatars/), the one list the component, the manifest's `avatar`
2
+ // pattern and the tests read. A config names one as `<category>-<n>`: `photos-3`,
3
+ // `illustration-2` (a 96 px WebP face), `sans-visage-1` (an SVG drawn in currentColor on the
4
+ // `avatarBg` colour), or `logo` (the site's own, through the component's `logo` prop).
5
+ //
6
+ // ADDING A FACE IS NOT A BREAKING CHANGE: drop the file in avatars/, raise its category's count
7
+ // here (or add a category, `3d` when its faces exist), and copy the new AVATAR_PATTERN into
8
+ // manifest.json (tests/manifest.test.js holds the two equal). Every value a site already uses stays
9
+ // valid. Never renumber or remove a face a site may have chosen.
10
+
11
+ /** Faces per category, numbered from 1. */
12
+ export const FACE_COUNTS = { photos: 5, illustration: 5 }
13
+ export const ICON_COUNT = 5
14
+
15
+ const range = (n) => Array.from({ length: n }, (_, i) => i + 1)
16
+ export const FACES = Object.entries(FACE_COUNTS).flatMap(([c, n]) => range(n).map((i) => `${c}-${i}`))
17
+ export const ICONS = range(ICON_COUNT).map((i) => `sans-visage-${i}`)
18
+
19
+ const upTo = (n) => (n < 10 ? `[1-${n}]` : `(?:[1-9]|1[0-${n - 10}])`)
20
+ export const AVATAR_PATTERN = `^(?:${[
21
+ ...Object.entries(FACE_COUNTS).map(([c, n]) => `${c}-${upTo(n)}`),
22
+ `sans-visage-${upTo(ICON_COUNT)}`,
23
+ 'logo',
24
+ ].join('|')})$`
@@ -0,0 +1,53 @@
1
+ // GENERATED by build.mjs from src/protocol.ts (a verbatim copy of pmeweb-chat/src/protocol.ts). Do not edit.
2
+ const RPC_HEADER = "x-pmw-rpc";
3
+ const RPC_VERSION = 1;
4
+ const MAX_TEXT = 2e3;
5
+ const CLOSE = { unauthorized: 4401, expired: 4410, rpc: 4426, deleted: 4404 };
6
+ const ACTOR_HEADER = "x-pmw-actor";
7
+ function encodeActor(actor) {
8
+ return JSON.stringify(actor).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
9
+ }
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ function isUuid(v) {
12
+ return typeof v === "string" && UUID.test(v);
13
+ }
14
+ function parseClient(raw) {
15
+ let v;
16
+ try {
17
+ v = JSON.parse(raw);
18
+ } catch {
19
+ return null;
20
+ }
21
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return null;
22
+ const o = v;
23
+ switch (o.type) {
24
+ case "hello": {
25
+ const since = o.since;
26
+ if (typeof since !== "number" || !Number.isSafeInteger(since) || since < 0) return null;
27
+ return { type: "hello", since };
28
+ }
29
+ case "message": {
30
+ const { id, text } = o;
31
+ if (!isUuid(id)) return null;
32
+ if (typeof text !== "string" || text.length > MAX_TEXT || text.trim() === "") return null;
33
+ return { type: "message", id, text };
34
+ }
35
+ case "typing":
36
+ case "takeover":
37
+ case "handback":
38
+ case "close":
39
+ return { type: o.type };
40
+ default:
41
+ return null;
42
+ }
43
+ }
44
+ export {
45
+ ACTOR_HEADER,
46
+ CLOSE,
47
+ MAX_TEXT,
48
+ RPC_HEADER,
49
+ RPC_VERSION,
50
+ encodeActor,
51
+ isUuid,
52
+ parseClient
53
+ };
@@ -0,0 +1,113 @@
1
+ // The one message format of every chat socket: the visitor's widget, the owner's public page, the
2
+ // portal's tabs (spec § 5). Versioned by RPC_VERSION (plugin-rpc.json); a breaking change is a new
3
+ // version the service accepts alongside the old one until every site is updated.
4
+ //
5
+ // This file is copied VERBATIM into the plugin package and the portal, and a test in each compares
6
+ // the copy byte for byte with this one. So it imports nothing: what the service accepts (read from
7
+ // plugin-rpc.json) lives in src/rpc.ts, which only this repo has.
8
+ export const RPC_HEADER = 'x-pmw-rpc';
9
+ export const RPC_VERSION = 1;
10
+ export const MAX_TEXT = 2000;
11
+
12
+ export type Role = 'visitor' | 'bot' | 'owner';
13
+ export type Mode = 'bot' | 'human';
14
+
15
+ /** Client -> server. `id` is a client-made UUID: a message re-sent after a reconnect is stored once. */
16
+ export type ClientMsg =
17
+ | { type: 'hello'; since: number }
18
+ | { type: 'message'; id: string; text: string }
19
+ | { type: 'typing' }
20
+ | { type: 'takeover' }
21
+ | { type: 'handback' }
22
+ | { type: 'close' };
23
+
24
+ /** Server -> client. `delta` streams a bot answer; the final `message` replaces it. */
25
+ export type ServerMsg =
26
+ | { type: 'history'; messages: StoredMsg[]; mode: Mode; ownerName: string | null; closed: boolean }
27
+ | { type: 'message'; msg: StoredMsg }
28
+ | { type: 'delta'; id: string; text: string }
29
+ | { type: 'typing'; role: Role }
30
+ | { type: 'status'; mode: Mode; ownerName: string | null; closed: boolean }
31
+ | { type: 'fallback'; reason: 'cap' | 'model_down' }
32
+ /** To the visitor's sockets only, once per conversation: the widget fires pmw.event('chat-lead'). */
33
+ | { type: 'lead' }
34
+ | { type: 'error'; code: string };
35
+
36
+ export interface StoredMsg { seq: number; id: string; role: Role; text: string; at: number; by?: string; photos?: string[] }
37
+
38
+ /**
39
+ * Close codes (4000-4999 are the application's). A refusal is always one of these on an accepted
40
+ * socket, never an HTTP status: a browser's WebSocket sees only « non-101 » and would reconnect
41
+ * forever (spike task 1). A 44xx close means « do not reconnect »; 1006 means « reconnect ».
42
+ */
43
+ export const CLOSE = { unauthorized: 4401, expired: 4410, rpc: 4426, deleted: 4404 } as const;
44
+
45
+ /**
46
+ * Who is on a socket, and what they may do. Set ONLY by a trusted entrypoint, never by the client:
47
+ * ChatApi sets `visitor` after checking the visitor key, the portal sets `owner` (public link) or
48
+ * `staff` (portal session) after `resolveConversationAccess`. It rides to the Durable Object in
49
+ * ACTOR_HEADER and is stored on the socket, so every frame is judged by it.
50
+ */
51
+ export const ACTOR_HEADER = 'x-pmw-actor';
52
+ export type Scope = 'reply' | 'takeover' | 'close';
53
+ export interface Actor {
54
+ kind: 'visitor' | 'owner' | 'staff';
55
+ /** Shown to the visitor when this person takes over (owner, staff). */
56
+ name?: string;
57
+ /** SHA-256 hex of the public link's token: revoking the link closes exactly these sockets. */
58
+ tokenHash?: string;
59
+ /** SHA-256 hex of the visitor key, compared with the one stored at start (visitor only). */
60
+ keyHash?: string;
61
+ scope: Scope[];
62
+ }
63
+
64
+ /**
65
+ * The actor as an ASCII header value. Owner names carry accents (« Éric »); a header must be ASCII
66
+ * in the Fetch spec and workerd only tolerates UTF-8 as a quirk, so every non-ASCII character is
67
+ * written as a JSON \u escape, which JSON.parse reads back unchanged.
68
+ */
69
+ export function encodeActor(actor: Actor): string {
70
+ return JSON.stringify(actor).replace(/[\u0080-\uffff]/g, (c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
71
+ }
72
+
73
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
74
+
75
+ export function isUuid(v: unknown): v is string {
76
+ return typeof v === 'string' && UUID.test(v);
77
+ }
78
+
79
+ /**
80
+ * Parses one frame from a client. Anything unexpected is null, and the caller answers
81
+ * `error bad_message`: a frame is never half-trusted. Only the declared fields are copied, so an
82
+ * extra field (a `role`, a `by`) can never ride through to storage or to the other sockets.
83
+ */
84
+ export function parseClient(raw: string): ClientMsg | null {
85
+ let v: unknown;
86
+ try {
87
+ v = JSON.parse(raw);
88
+ } catch {
89
+ return null;
90
+ }
91
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return null;
92
+ const o = v as Record<string, unknown>;
93
+ switch (o.type) {
94
+ case 'hello': {
95
+ const since = o.since;
96
+ if (typeof since !== 'number' || !Number.isSafeInteger(since) || since < 0) return null;
97
+ return { type: 'hello', since };
98
+ }
99
+ case 'message': {
100
+ const { id, text } = o;
101
+ if (!isUuid(id)) return null;
102
+ if (typeof text !== 'string' || text.length > MAX_TEXT || text.trim() === '') return null;
103
+ return { type: 'message', id, text };
104
+ }
105
+ case 'typing':
106
+ case 'takeover':
107
+ case 'handback':
108
+ case 'close':
109
+ return { type: o.type };
110
+ default:
111
+ return null;
112
+ }
113
+ }