@mostajs/qrpanel 0.4.0 → 0.6.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/CHANGELOG.md +56 -0
- package/browser/qr-scan.js +10144 -0
- package/dist/qr-image.d.ts +32 -0
- package/dist/qr-image.d.ts.map +1 -0
- package/dist/qr-image.js +68 -0
- package/dist/qr-image.js.map +1 -0
- package/dist/qr-svg.js +165 -0
- package/llms.txt +20 -0
- package/package.json +34 -38
- package/src/cli.ts +74 -0
- package/src/client.tsx +184 -0
- package/src/composer.ts +164 -0
- package/src/config.ts +213 -0
- package/src/index.js +6 -0
- package/src/index.ts +16 -0
- package/src/qr-decode.js +23 -0
- package/src/qr-figures.js +46 -0
- package/src/qr-image.tsx +105 -0
- package/src/qr-payloads.js +59 -0
- package/src/qr-png.js +40 -0
- package/src/qr-svg.js +185 -0
- package/src/server.ts +212 -0
- package/src/themes.ts +266 -0
package/src/qr-image.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @mostajs/qrpanel/qr-image — composant QR généré côté navigateur
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// QR code généré 100% dans le navigateur (data URL via `qrcode`), sans
|
|
5
|
+
// appel réseau ni round-trip serveur. Complète les deux briques
|
|
6
|
+
// existantes de qrpanel :
|
|
7
|
+
// - `<QrPanel>` (./client) attend un `qrSrc` déjà fourni par l'app.
|
|
8
|
+
// - `generateQr*` (./server) génère côté Node uniquement (@resvg natif).
|
|
9
|
+
// `<QrImage>` couvre le cas manquant : génération locale, côté client.
|
|
10
|
+
//
|
|
11
|
+
// Cas d'usage clé — COEP `require-corp` : sous cross-origin isolation
|
|
12
|
+
// (requise par ex. par ffmpeg.wasm / SharedArrayBuffer), une image QR
|
|
13
|
+
// servie par un service externe est bloquée par la politique. La
|
|
14
|
+
// génération locale est alors la seule option côté client.
|
|
15
|
+
//
|
|
16
|
+
// Sub-export dédié (`@mostajs/qrpanel/qr-image`) : `qrcode` n'est
|
|
17
|
+
// embarqué dans le bundle client que pour les consumers qui l'importent.
|
|
18
|
+
|
|
19
|
+
'use client'
|
|
20
|
+
|
|
21
|
+
import { useEffect, useState } from 'react'
|
|
22
|
+
import QRCode from 'qrcode'
|
|
23
|
+
|
|
24
|
+
export interface QrDataUrlOptions {
|
|
25
|
+
/** Côté de l'image en pixels. Défaut 200. */
|
|
26
|
+
size?: number
|
|
27
|
+
/** Marge blanche autour du QR (en modules). Défaut 2. */
|
|
28
|
+
margin?: number
|
|
29
|
+
/** Correction d'erreur : L 7 % | M 15 % | Q 25 % | H 30 %. Défaut 'M'. */
|
|
30
|
+
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
|
|
31
|
+
/** Couleur des modules sombres. Défaut '#0f172a'. */
|
|
32
|
+
darkColor?: string
|
|
33
|
+
/** Couleur du fond. Défaut '#ffffff'. */
|
|
34
|
+
lightColor?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseQrDataUrlResult {
|
|
38
|
+
/** Data URL `data:image/png;base64,…` du QR, ou '' tant que non prêt. */
|
|
39
|
+
src: string
|
|
40
|
+
/** True pendant la génération. */
|
|
41
|
+
loading: boolean
|
|
42
|
+
/** Message d'erreur si la génération a échoué, sinon null. */
|
|
43
|
+
error: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Hook : génère le data URL d'un QR localement (navigateur). */
|
|
47
|
+
export function useQrDataUrl(data: string, opts: QrDataUrlOptions = {}): UseQrDataUrlResult {
|
|
48
|
+
const {
|
|
49
|
+
size = 200, margin = 2, errorCorrectionLevel = 'M',
|
|
50
|
+
darkColor = '#0f172a', lightColor = '#ffffff',
|
|
51
|
+
} = opts
|
|
52
|
+
const [src, setSrc] = useState('')
|
|
53
|
+
const [loading, setLoading] = useState(true)
|
|
54
|
+
const [error, setError] = useState<string | null>(null)
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
let alive = true
|
|
58
|
+
setLoading(true); setError(null); setSrc('')
|
|
59
|
+
QRCode.toDataURL(data, {
|
|
60
|
+
width: size, margin, errorCorrectionLevel,
|
|
61
|
+
color: { dark: darkColor, light: lightColor },
|
|
62
|
+
})
|
|
63
|
+
.then((url) => { if (alive) { setSrc(url); setLoading(false) } })
|
|
64
|
+
.catch((e) => {
|
|
65
|
+
if (alive) { setError(e?.message || 'QR generation failed'); setLoading(false) }
|
|
66
|
+
})
|
|
67
|
+
return () => { alive = false }
|
|
68
|
+
}, [data, size, margin, errorCorrectionLevel, darkColor, lightColor])
|
|
69
|
+
|
|
70
|
+
return { src, loading, error }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface QrImageProps extends QrDataUrlOptions {
|
|
74
|
+
/** Donnée encodée dans le QR (URL, texte…). */
|
|
75
|
+
data: string
|
|
76
|
+
alt?: string
|
|
77
|
+
style?: React.CSSProperties
|
|
78
|
+
className?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** QR code généré localement et rendu en `<img>` data-URL (COEP-safe). */
|
|
82
|
+
export default function QrImage({
|
|
83
|
+
data, alt = 'QR code', style, className, ...opts
|
|
84
|
+
}: QrImageProps) {
|
|
85
|
+
const size = opts.size ?? 200
|
|
86
|
+
const { src, loading, error } = useQrDataUrl(data, opts)
|
|
87
|
+
|
|
88
|
+
const box: React.CSSProperties = {
|
|
89
|
+
width: size, height: size, display: 'flex',
|
|
90
|
+
alignItems: 'center', justifyContent: 'center',
|
|
91
|
+
background: '#fff', borderRadius: 6, border: '1px solid #e2e8f0',
|
|
92
|
+
fontSize: 11, color: '#94a3b8', textAlign: 'center',
|
|
93
|
+
...style,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (error) return <div style={box} className={className}>QR indisponible</div>
|
|
97
|
+
if (loading || !src) return <div style={box} className={className} aria-busy="true">…</div>
|
|
98
|
+
return (
|
|
99
|
+
<img
|
|
100
|
+
src={src} alt={alt} width={size} height={size}
|
|
101
|
+
style={{ background: '#fff', borderRadius: 6, display: 'block', ...style }}
|
|
102
|
+
className={className}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// @mostajs/qrpanel — formats de charge utile QR « scan-and-act ». Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Chaque helper renvoie la CHAÎNE normalisée à encoder (via qrSvg / qrLive / qrPng). Pur, zéro dépendance.
|
|
3
|
+
|
|
4
|
+
const escSep = (s) => String(s ?? '').replace(/([\\;,:"])/g, '\\$1'); // échappe les séparateurs des formats compacts
|
|
5
|
+
|
|
6
|
+
/** WiFi : scan = rejoindre le réseau. security ∈ WPA|WEP|nopass. */
|
|
7
|
+
export function qrWifi({ ssid, password = '', security = 'WPA', hidden = false } = {}) {
|
|
8
|
+
return `WIFI:T:${security};S:${escSep(ssid)};P:${escSep(password)};${hidden ? 'H:true;' : ''};`;
|
|
9
|
+
}
|
|
10
|
+
/** Téléphone : tel:+213… */
|
|
11
|
+
export const qrTel = (num) => `tel:${String(num ?? '').replace(/\s+/g, '')}`;
|
|
12
|
+
/** SMS pré-rempli : SMSTO:num:message */
|
|
13
|
+
export const qrSms = (num, body = '') => `SMSTO:${String(num ?? '').replace(/\s+/g, '')}:${body}`;
|
|
14
|
+
/** Email pré-rempli : mailto:adresse?subject=&body= */
|
|
15
|
+
export function qrEmail(to, { subject = '', body = '' } = {}) {
|
|
16
|
+
const q = []; if (subject) q.push('subject=' + encodeURIComponent(subject)); if (body) q.push('body=' + encodeURIComponent(body));
|
|
17
|
+
return `mailto:${to ?? ''}${q.length ? '?' + q.join('&') : ''}`;
|
|
18
|
+
}
|
|
19
|
+
/** Géolocalisation : geo:lat,lng (lieu d'un salon/atelier). */
|
|
20
|
+
export const qrGeo = (lat, lng) => `geo:${lat},${lng}`;
|
|
21
|
+
/** URL (normalise : ajoute https:// si absent). */
|
|
22
|
+
export const qrUrl = (u) => (/^[a-z]+:\/\//i.test(u) ? u : `https://${u}`);
|
|
23
|
+
|
|
24
|
+
/** MeCard (compact, très lisible par les scanners) : carte de contact. */
|
|
25
|
+
export function qrMeCard({ name, tel, email, url, note } = {}) {
|
|
26
|
+
let o = 'MECARD:';
|
|
27
|
+
if (name) o += `N:${escSep(name)};`;
|
|
28
|
+
if (tel) o += `TEL:${escSep(tel)};`;
|
|
29
|
+
if (email) o += `EMAIL:${escSep(email)};`;
|
|
30
|
+
if (url) o += `URL:${escSep(url)};`;
|
|
31
|
+
if (note) o += `NOTE:${escSep(note)};`;
|
|
32
|
+
return o + ';';
|
|
33
|
+
}
|
|
34
|
+
/** vCard 3.0 (riche) : carte de contact d'un sponsor/annonceur. */
|
|
35
|
+
export function qrVCard({ name, org, title, tel, email, url, adr, note } = {}) {
|
|
36
|
+
const L = ['BEGIN:VCARD', 'VERSION:3.0'];
|
|
37
|
+
if (name) L.push(`FN:${name}`, `N:${name};;;;`);
|
|
38
|
+
if (org) L.push(`ORG:${org}`);
|
|
39
|
+
if (title) L.push(`TITLE:${title}`);
|
|
40
|
+
if (tel) L.push(`TEL:${tel}`);
|
|
41
|
+
if (email) L.push(`EMAIL:${email}`);
|
|
42
|
+
if (url) L.push(`URL:${url}`);
|
|
43
|
+
if (adr) L.push(`ADR:;;${adr};;;;`);
|
|
44
|
+
if (note) L.push(`NOTE:${note}`);
|
|
45
|
+
L.push('END:VCARD');
|
|
46
|
+
return L.join('\n');
|
|
47
|
+
}
|
|
48
|
+
/** Événement calendrier (VEVENT) : « ajouter ce live/atelier à mon agenda ». start/end = Date | ISO. */
|
|
49
|
+
export function qrEvent({ title, start, end, location = '', description = '' } = {}) {
|
|
50
|
+
const fmt = (d) => (d instanceof Date ? d : new Date(d)).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
51
|
+
const L = ['BEGIN:VEVENT'];
|
|
52
|
+
if (title) L.push(`SUMMARY:${title}`);
|
|
53
|
+
if (start) L.push(`DTSTART:${fmt(start)}`);
|
|
54
|
+
if (end) L.push(`DTEND:${fmt(end)}`);
|
|
55
|
+
if (location) L.push(`LOCATION:${location}`);
|
|
56
|
+
if (description) L.push(`DESCRIPTION:${description}`);
|
|
57
|
+
L.push('END:VEVENT');
|
|
58
|
+
return L.join('\n');
|
|
59
|
+
}
|
package/src/qr-png.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// @mostajs/qrpanel — sortie PNG (data:URL) d'un QR. Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Pour les contextes où le SVG inline est bloqué (emails : billets, magic-link). Node (node:zlib pour l'IDAT).
|
|
3
|
+
import zlib from 'node:zlib';
|
|
4
|
+
import { qrMatrix } from './qr-svg.js';
|
|
5
|
+
|
|
6
|
+
const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })();
|
|
7
|
+
const crc32 = (b) => { let c = 0xFFFFFFFF; for (let i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; };
|
|
8
|
+
const u32 = (n) => Uint8Array.of((n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255);
|
|
9
|
+
const cat = (...a) => { const o = new Uint8Array(a.reduce((s, x) => s + x.length, 0)); let p = 0; for (const x of a) { o.set(x, p); p += x.length; } return o; };
|
|
10
|
+
const chunk = (type, data) => { const body = cat(Uint8Array.from([...type].map((c) => c.charCodeAt(0))), data); return cat(u32(data.length), body, u32(crc32(body))); };
|
|
11
|
+
const toB64 = (bytes) => (typeof Buffer !== 'undefined' ? Buffer.from(bytes).toString('base64') : btoa(String.fromCharCode(...bytes)));
|
|
12
|
+
|
|
13
|
+
/** QR en PNG (niveaux de gris) → data:image/png;base64,… { scale=6, margin=4 }. */
|
|
14
|
+
export function qrPng(text, { scale = 6, margin = 4 } = {}) {
|
|
15
|
+
const { matrix, size } = qrMatrix(text);
|
|
16
|
+
const dim = (size + margin * 2) * scale;
|
|
17
|
+
const stride = dim + 1; // 1 octet de filtre par ligne
|
|
18
|
+
const raw = new Uint8Array(stride * dim);
|
|
19
|
+
for (let y = 0; y < dim; y++) {
|
|
20
|
+
raw[y * stride] = 0; // filtre None
|
|
21
|
+
const my = Math.floor(y / scale) - margin;
|
|
22
|
+
for (let x = 0; x < dim; x++) {
|
|
23
|
+
const mx = Math.floor(x / scale) - margin;
|
|
24
|
+
const dark = my >= 0 && my < size && mx >= 0 && mx < size && matrix[my][mx];
|
|
25
|
+
raw[y * stride + 1 + x] = dark ? 0 : 255;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const sig = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10);
|
|
29
|
+
const ihdr = cat(u32(dim), u32(dim), Uint8Array.of(8, 0, 0, 0, 0)); // 8 bits, grayscale
|
|
30
|
+
const idat = new Uint8Array(zlib.deflateSync(raw));
|
|
31
|
+
const png = cat(sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0)));
|
|
32
|
+
return 'data:image/png;base64,' + toB64(png);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const _esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
36
|
+
|
|
37
|
+
/** Figure PNG d'un QR avec légende (pendant de qrLive pour le rendu PNG : billets/badges imprimables). { scale=5, margin=2, maxWidth=240 }. */
|
|
38
|
+
export function qrPngFigure(data, caption = '', { scale = 5, margin = 2, maxWidth = 240 } = {}) {
|
|
39
|
+
return `<figure style="margin:0;text-align:center"><img src="${qrPng(data, { scale, margin })}" alt="QR" style="max-width:${maxWidth}px;width:60vw;image-rendering:pixelated">${caption ? `<figcaption style="font-size:12px;margin-top:4px;opacity:.7">${_esc(caption)}</figcaption>` : ''}</figure>`;
|
|
40
|
+
}
|
package/src/qr-svg.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// @mostajs/qrpanel/qr-svg — encodeur QR Code ZÉRO-DÉPENDANCE → SVG (server-side, pas de natif, pas de npm). Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
2
|
+
// Complète qrpanel (qr-image = navigateur via `qrcode` ; server.js = @resvg natif) par un encodeur PUR JS server-side, idéal apps zéro-dép.
|
|
3
|
+
// Modes numérique & octet (byte/UTF-8), niveau de correction M, versions 1→10 (auto). Reed-Solomon GF(256), 8 masques + pénalité.
|
|
4
|
+
// Vérifié contre le vecteur de référence publié (« 01234567 », v1-M → 10 mots de code EC connus). Sortie : matrice booléenne + SVG.
|
|
5
|
+
|
|
6
|
+
// ── GF(256) — corps de Galois, primitif 0x11d (x^8+x^4+x^3+x^2+1). ──
|
|
7
|
+
const EXP = new Array(512), LOG = new Array(256);
|
|
8
|
+
(() => { let x = 1; for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; } for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; })();
|
|
9
|
+
const gmul = (a, b) => (a === 0 || b === 0) ? 0 : EXP[LOG[a] + LOG[b]];
|
|
10
|
+
|
|
11
|
+
// Polynôme générateur Reed-Solomon de degré `n`.
|
|
12
|
+
function rsGenPoly(n) {
|
|
13
|
+
let g = [1];
|
|
14
|
+
for (let i = 0; i < n; i++) { const ng = new Array(g.length + 1).fill(0); for (let j = 0; j < g.length; j++) { ng[j] ^= gmul(g[j], 1); ng[j + 1] ^= gmul(g[j], EXP[i]); } g = ng; }
|
|
15
|
+
return g;
|
|
16
|
+
}
|
|
17
|
+
// Mots de code de correction d'erreur pour `data` (octets) avec `ecLen` symboles.
|
|
18
|
+
function rsEncode(data, ecLen) {
|
|
19
|
+
const gen = rsGenPoly(ecLen); const res = new Array(ecLen).fill(0); // gen a ecLen+1 coeffs (gen[0]=1, ignoré dans la division)
|
|
20
|
+
for (const d of data) { const factor = d ^ res[0]; res.shift(); res.push(0); if (factor !== 0) for (let i = 0; i < ecLen; i++) res[i] ^= gmul(gen[i + 1], factor); }
|
|
21
|
+
return res;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Tables niveau M, versions 1→10 : [totalDataCodewords, ecPerBlock, [ [nbBlocks, dataPerBlock], ... ] ]. ──
|
|
25
|
+
const VERSIONS_M = {
|
|
26
|
+
1: [16, 10, [[1, 16]]], 2: [28, 16, [[1, 28]]], 3: [44, 26, [[1, 44]]], 4: [64, 18, [[2, 32]]],
|
|
27
|
+
5: [86, 24, [[2, 43]]], 6: [108, 16, [[4, 27]]], 7: [124, 18, [[4, 31]]], 8: [154, 22, [[2, 38], [2, 39]]],
|
|
28
|
+
9: [182, 22, [[3, 36], [2, 37]]], 10: [216, 26, [[4, 43], [1, 44]]],
|
|
29
|
+
};
|
|
30
|
+
// Positions des motifs d'alignement (centres) par version.
|
|
31
|
+
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
|
32
|
+
const sizeOf = (v) => 17 + 4 * v;
|
|
33
|
+
|
|
34
|
+
const isNumeric = (s) => /^[0-9]+$/.test(s);
|
|
35
|
+
|
|
36
|
+
// ── Encodage des données (bitstream) selon le mode. ──
|
|
37
|
+
function encodeData(text, version, totalDataCw) {
|
|
38
|
+
const bits = [];
|
|
39
|
+
const push = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
|
40
|
+
if (isNumeric(text)) {
|
|
41
|
+
push(0b0001, 4); // mode numérique
|
|
42
|
+
push(text.length, version < 10 ? 10 : 12);
|
|
43
|
+
for (let i = 0; i < text.length; i += 3) { const chunk = text.substr(i, 3); push(parseInt(chunk, 10), chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4); }
|
|
44
|
+
} else {
|
|
45
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
46
|
+
push(0b0100, 4); // mode octet
|
|
47
|
+
push(bytes.length, version < 10 ? 8 : 16);
|
|
48
|
+
for (const b of bytes) push(b, 8);
|
|
49
|
+
}
|
|
50
|
+
const cap = totalDataCw * 8;
|
|
51
|
+
for (let i = 0; i < 4 && bits.length < cap; i++) bits.push(0); // terminateur
|
|
52
|
+
while (bits.length % 8 !== 0) bits.push(0); // alignement octet
|
|
53
|
+
const pads = [0xec, 0x11]; let p = 0;
|
|
54
|
+
while (bits.length < cap) { push(pads[p % 2], 8); p++; }
|
|
55
|
+
const cw = []; for (let i = 0; i < bits.length; i += 8) { let b = 0; for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j]; cw.push(b); }
|
|
56
|
+
return cw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Choisit la plus petite version (≤10) qui contient `text`.
|
|
60
|
+
function pickVersion(text) {
|
|
61
|
+
const len = isNumeric(text) ? null : Buffer.from(text, 'utf8').length;
|
|
62
|
+
for (let v = 1; v <= 10; v++) {
|
|
63
|
+
const totalData = VERSIONS_M[v][0];
|
|
64
|
+
// estimation grossière de capacité : entête (~4 + count bits) puis données
|
|
65
|
+
const headerBits = 4 + (v < 10 ? (isNumeric(text) ? 10 : 8) : (isNumeric(text) ? 12 : 16));
|
|
66
|
+
const dataBits = isNumeric(text) ? Math.ceil(text.length / 3) * 10 : (len * 8);
|
|
67
|
+
if (headerBits + dataBits <= totalData * 8) return v;
|
|
68
|
+
}
|
|
69
|
+
throw new Error('contenu trop long pour QR v≤10 (raccourcir l\'URL/jeton)');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Entrelace données + EC par blocs (spec QR).
|
|
73
|
+
function interleave(dataCw, version) {
|
|
74
|
+
const [, ecLen, groups] = VERSIONS_M[version];
|
|
75
|
+
const blocks = []; let idx = 0;
|
|
76
|
+
for (const [nb, dpb] of groups) for (let i = 0; i < nb; i++) { const d = dataCw.slice(idx, idx + dpb); idx += dpb; blocks.push({ d, e: rsEncode(d, ecLen) }); }
|
|
77
|
+
const maxD = Math.max(...blocks.map((b) => b.d.length));
|
|
78
|
+
const out = [];
|
|
79
|
+
for (let i = 0; i < maxD; i++) for (const b of blocks) if (i < b.d.length) out.push(b.d[i]);
|
|
80
|
+
for (let i = 0; i < ecLen; i++) for (const b of blocks) out.push(b.e[i]);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Matrice : motifs de repérage, séparateurs, timing, alignement, module sombre, réservé format. ──
|
|
85
|
+
// Information de version (18 bits BCH) — OBLIGATOIRE dès la version 7. Sans elle, les scanners rejettent le QR.
|
|
86
|
+
const VERSION_INFO = { 7: 0x07C94, 8: 0x085BC, 9: 0x09A99, 10: 0x0A4D3 };
|
|
87
|
+
|
|
88
|
+
function buildMatrix(version) {
|
|
89
|
+
const n = sizeOf(version);
|
|
90
|
+
const m = Array.from({ length: n }, () => new Array(n).fill(null)); // null = libre
|
|
91
|
+
const fn = Array.from({ length: n }, () => new Array(n).fill(false)); // true = fonction (non masquable)
|
|
92
|
+
const set = (r, c, v) => { m[r][c] = v ? 1 : 0; fn[r][c] = true; };
|
|
93
|
+
const finder = (r, c) => { for (let i = -1; i <= 7; i++) for (let j = -1; j <= 7; j++) { const rr = r + i, cc = c + j; if (rr < 0 || rr >= n || cc < 0 || cc >= n) continue; const inb = i >= 0 && i <= 6 && j >= 0 && j <= 6; const ring = i === 0 || i === 6 || j === 0 || j === 6; const core = i >= 2 && i <= 4 && j >= 2 && j <= 4; set(rr, cc, inb && (ring || core)); } };
|
|
94
|
+
finder(0, 0); finder(0, n - 7); finder(n - 7, 0);
|
|
95
|
+
for (let i = 0; i < n; i++) { if (m[6][i] === null) set(6, i, i % 2 === 0); if (m[i][6] === null) set(i, 6, i % 2 === 0); } // timing
|
|
96
|
+
const ac = ALIGN[version];
|
|
97
|
+
for (const r of ac) for (const c of ac) { if ((r <= 7 && c <= 7) || (r <= 7 && c >= n - 8) || (r >= n - 8 && c <= 7)) continue; for (let i = -2; i <= 2; i++) for (let j = -2; j <= 2; j++) set(r + i, c + j, Math.max(Math.abs(i), Math.abs(j)) !== 1); }
|
|
98
|
+
set(n - 8, 8, 1); // module sombre
|
|
99
|
+
// Réserve les zones d'info de format (sans valeur définitive) → marquées fonction.
|
|
100
|
+
for (let i = 0; i < 9; i++) { if (m[8][i] === null) { m[8][i] = 0; fn[8][i] = true; } if (m[i][8] === null) { m[i][8] = 0; fn[i][8] = true; } }
|
|
101
|
+
for (let i = 0; i < 8; i++) { if (m[8][n - 1 - i] === null) { m[8][n - 1 - i] = 0; fn[8][n - 1 - i] = true; } if (m[n - 1 - i][8] === null) { m[n - 1 - i][8] = 0; fn[n - 1 - i][8] = true; } }
|
|
102
|
+
// Information de version (v≥7) : 18 bits fixes, deux blocs 3×6 près des viseurs haut-droit et bas-gauche.
|
|
103
|
+
if (version >= 7) { const vi = VERSION_INFO[version]; for (let i = 0; i < 18; i++) { const bit = (vi >> i) & 1; const a = n - 11 + (i % 3), b = Math.floor(i / 3); set(b, a, bit); set(a, b, bit); } }
|
|
104
|
+
return { m, fn, n };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Place le bitstream en zigzag (colonnes de 2, de droite à gauche, en sautant la colonne 6).
|
|
108
|
+
function placeData(m, fn, n, bytes) {
|
|
109
|
+
let bitIdx = 0; const bitsArr = []; for (const b of bytes) for (let i = 7; i >= 0; i--) bitsArr.push((b >> i) & 1);
|
|
110
|
+
let up = true;
|
|
111
|
+
for (let col = n - 1; col > 0; col -= 2) {
|
|
112
|
+
if (col === 6) col--;
|
|
113
|
+
for (let k = 0; k < n; k++) { const row = up ? n - 1 - k : k; for (const c of [col, col - 1]) { if (!fn[row][c]) { m[row][c] = bitIdx < bitsArr.length ? bitsArr[bitIdx] : 0; bitIdx++; } } }
|
|
114
|
+
up = !up;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const MASKS = [
|
|
119
|
+
(r, c) => (r + c) % 2 === 0, (r) => r % 2 === 0, (r, c) => c % 3 === 0, (r, c) => (r + c) % 3 === 0,
|
|
120
|
+
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
|
|
121
|
+
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
|
|
122
|
+
];
|
|
123
|
+
function applyMask(m, fn, n, maskFn) { const o = m.map((row) => row.slice()); for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (!fn[r][c] && maskFn(r, c)) o[r][c] ^= 1; return o; }
|
|
124
|
+
|
|
125
|
+
// Info de format (niveau M = bits 00) + masque → 15 bits BCH, placée aux deux emplacements.
|
|
126
|
+
function placeFormat(m, n, maskId) {
|
|
127
|
+
const data = (0b00 << 3) | maskId; let bch = data << 10;
|
|
128
|
+
for (let i = 14; i >= 10; i--) if ((bch >> i) & 1) bch ^= 0b10100110111 << (i - 10);
|
|
129
|
+
const bits = (((data << 10) | bch) ^ 0b101010000010010);
|
|
130
|
+
const get = (i) => (bits >> i) & 1;
|
|
131
|
+
// 1ʳᵉ copie (autour du viseur haut-gauche) : bits 0-7 → colonne 8 (haut), bits 8-14 → ligne 8 (gauche). Ordre du SPEC (ISO 18004).
|
|
132
|
+
for (let i = 0; i <= 5; i++) m[i][8] = get(i);
|
|
133
|
+
m[7][8] = get(6); m[8][8] = get(7); m[8][7] = get(8);
|
|
134
|
+
for (let i = 9; i <= 14; i++) m[8][14 - i] = get(i);
|
|
135
|
+
// 2ᵉ copie : bits 0-7 → ligne 8 (droite), bits 8-14 → colonne 8 (bas).
|
|
136
|
+
for (let i = 0; i <= 7; i++) m[8][n - 1 - i] = get(i);
|
|
137
|
+
for (let i = 8; i <= 14; i++) m[n - 15 + i][8] = get(i);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function penalty(m, n) {
|
|
141
|
+
let p = 0;
|
|
142
|
+
for (let r = 0; r < n; r++) for (const line of [m[r], m.map((x) => x[r])]) { let run = 1; for (let c = 1; c < n; c++) { if (line[c] === line[c - 1]) { run++; if (run === 5) p += 3; else if (run > 5) p++; } else run = 1; } }
|
|
143
|
+
for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) if (m[r][c] === m[r][c + 1] && m[r][c] === m[r + 1][c] && m[r][c] === m[r + 1][c + 1]) p += 3;
|
|
144
|
+
let dark = 0; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += m[r][c]; const ratio = (dark * 100) / (n * n); p += Math.floor(Math.abs(ratio - 50) / 5) * 10;
|
|
145
|
+
return p;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Encode `text` en matrice QR (booléens true=module sombre). Niveau M, version auto ≤10. */
|
|
149
|
+
export function qrMatrix(text) {
|
|
150
|
+
const version = pickVersion(text);
|
|
151
|
+
const totalData = VERSIONS_M[version][0];
|
|
152
|
+
const dataCw = encodeData(text, version, totalData);
|
|
153
|
+
const finalCw = interleave(dataCw, version);
|
|
154
|
+
const { m, fn, n } = buildMatrix(version);
|
|
155
|
+
placeData(m, fn, n, finalCw);
|
|
156
|
+
let best = null, bestScore = Infinity, bestMask = 0;
|
|
157
|
+
for (let mask = 0; mask < 8; mask++) { const cand = applyMask(m, fn, n, MASKS[mask]); placeFormat(cand, n, mask); const s = penalty(cand, n); if (s < bestScore) { bestScore = s; best = cand; bestMask = mask; } }
|
|
158
|
+
return { matrix: best.map((row) => row.map((v) => v === 1)), size: n, version, mask: bestMask };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Rend `text` en SVG QR (scannable). `scale` px/module, `margin` modules de zone calme. */
|
|
162
|
+
export function qrSvg(text, { scale = 4, margin = 4, dark = '#000', light = '#fff' } = {}) {
|
|
163
|
+
const { matrix, size } = qrMatrix(text);
|
|
164
|
+
const dim = (size + margin * 2) * scale;
|
|
165
|
+
let rects = '';
|
|
166
|
+
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (matrix[r][c]) rects += `<rect x="${(c + margin) * scale}" y="${(r + margin) * scale}" width="${scale}" height="${scale}"/>`;
|
|
167
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges" role="img" aria-label="QR code"><rect width="${dim}" height="${dim}" fill="${light}"/><g fill="${dark}">${rects}</g></svg>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const _esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Figure QR « scannable à l'écran » : SVG GROS (scale élevé + quiet zone large) + légende, dans un conteneur responsive.
|
|
174
|
+
* Pensé pour un QR qu'on affiche pour être SCANNÉ (plateau live, billet, lien public) — pas une vignette.
|
|
175
|
+
* Garde des URLs COURTES (≤ ~84 octets / version 6) : l'encodeur ne pose pas les « version information » des versions ≥ 7.
|
|
176
|
+
* qrLive(data, caption?, { scale=6, margin=3, maxWidth=240, dark, light }?)
|
|
177
|
+
*/
|
|
178
|
+
export function qrLive(data, caption = '', { scale = 6, margin = 3, maxWidth = 240, dark, light } = {}) {
|
|
179
|
+
const svg = qrSvg(data, { scale, margin, ...(dark ? { dark } : {}), ...(light ? { light } : {}) });
|
|
180
|
+
return `<figure style="margin:0;text-align:center"><div style="max-width:${maxWidth}px;margin:auto">${svg}</div>${caption ? `<figcaption style="font-size:12px;margin-top:2px;opacity:.7">${_esc(caption)}</figcaption>` : ''}</figure>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Exposés pour les tests (vérification contre le vecteur de référence publié).
|
|
184
|
+
export const _internal = { rsEncode, encodeData, interleave, VERSIONS_M };
|
|
185
|
+
export default { qrMatrix, qrSvg, qrLive };
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// @mostajs/qrpanel/server — server-side QR generation (Node)
|
|
2
|
+
// Author: Dr Hamid MADANI <drmdh@msn.com>
|
|
3
|
+
//
|
|
4
|
+
// Wrap minimaliste de la lib `qrcode` (npm) + extension thématique
|
|
5
|
+
// pilotée par .qrconfig.json (chargée à process.cwd() au runtime, cache
|
|
6
|
+
// invalidé par mtime).
|
|
7
|
+
//
|
|
8
|
+
// Cross-OS natif (Linux, macOS, Windows) — qrcode est pure-JS, et la
|
|
9
|
+
// rasterization PNG composite passe par @resvg/resvg-js (rust prebuilt
|
|
10
|
+
// binaries cross-OS, pas de chromium ni puppeteer).
|
|
11
|
+
//
|
|
12
|
+
// API legacy 100% rétro-compatible :
|
|
13
|
+
// - generateQrPng/Svg/DataUrl(text, opts) marchent comme en 0.2.x
|
|
14
|
+
// si .qrconfig.json est absent ET opts.genimage non-précisé.
|
|
15
|
+
// - Le master toggle genimage=false (config ou opts) court-circuite
|
|
16
|
+
// toute la chaîne thématique.
|
|
17
|
+
|
|
18
|
+
import QRCode from 'qrcode'
|
|
19
|
+
import { signInviteToken } from '@mostajs/auth/lib/invite-token'
|
|
20
|
+
import {
|
|
21
|
+
loadQrConfig, type QrConfigDefaults,
|
|
22
|
+
} from './config.js'
|
|
23
|
+
import {
|
|
24
|
+
composeThemedSvg, composeThemedPng, composeThemedDataUrl, mergeComposeOpts,
|
|
25
|
+
type ComposeOptions,
|
|
26
|
+
} from './composer.js'
|
|
27
|
+
import type { ThemeKey } from './themes.js'
|
|
28
|
+
|
|
29
|
+
// ─── Public types ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface QrOptions {
|
|
32
|
+
/** Largeur/hauteur de l'image en pixels. Default 600 (ou config). */
|
|
33
|
+
width?: number
|
|
34
|
+
/** Marge blanche autour du QR (en modules). Default 2 (ou config). */
|
|
35
|
+
margin?: number
|
|
36
|
+
/**
|
|
37
|
+
* Niveau de correction d'erreur :
|
|
38
|
+
* L = 7 % | M = 15 % | Q = 25 % | H = 30 % (recommandé pour overlay)
|
|
39
|
+
*/
|
|
40
|
+
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H'
|
|
41
|
+
/** Couleur des modules sombres. Default '#0f172a'. */
|
|
42
|
+
darkColor?: string
|
|
43
|
+
/** Couleur du fond. Default '#ffffff'. */
|
|
44
|
+
lightColor?: string
|
|
45
|
+
|
|
46
|
+
// ── v0.3 — extension thématique ─────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Master toggle. `false` court-circuite tout le pipeline thématique
|
|
49
|
+
* et retombe sur le QR pur (comportement v0.2.x). Override de la
|
|
50
|
+
* valeur config.
|
|
51
|
+
*/
|
|
52
|
+
genimage?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Thème à appliquer en cadre (4 motifs aux coins). Pris dans le
|
|
55
|
+
* registry natif (12 thèmes). 'random' tire dans `themePool`.
|
|
56
|
+
* 'none' désactive le composite ponctuellement.
|
|
57
|
+
* Objet `{ svg }` → motif custom inline.
|
|
58
|
+
*/
|
|
59
|
+
theme?: ThemeKey | 'random' | 'none' | { svg: string; label?: string }
|
|
60
|
+
/** Sous-set des thèmes pour le tirage 'random'. Default = tous. */
|
|
61
|
+
themePool?: ThemeKey[]
|
|
62
|
+
/** Marge cadre image / canvas (proportion). Default 0.13. */
|
|
63
|
+
framePadding?: number
|
|
64
|
+
/** Taille du cartouche blanc central (proportion 0..1). Default 0.62. */
|
|
65
|
+
centerWhiteRatio?: number
|
|
66
|
+
/** Opacité du cadre image (0..1). Default 1. */
|
|
67
|
+
themeOpacity?: number
|
|
68
|
+
/** Couleur monochrome du cadre image. Default '#1e293b'. */
|
|
69
|
+
themeColor?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Internal helpers ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Décide si on bascule sur le pipeline composite ou le legacy.
|
|
76
|
+
* Order : opts.genimage > config.default.genimage. theme='none' force legacy.
|
|
77
|
+
*/
|
|
78
|
+
function shouldComposite(opts: QrOptions, cfg: QrConfigDefaults): boolean {
|
|
79
|
+
if (opts.genimage === false) return false
|
|
80
|
+
if (opts.genimage === true) {
|
|
81
|
+
// explicit on : ne respecte pas theme=none ? Si l'app dit explicitement
|
|
82
|
+
// genimage=true mais theme=none, on respecte theme=none (QR pur ponctuel).
|
|
83
|
+
const theme = opts.theme ?? cfg.theme
|
|
84
|
+
return theme !== 'none'
|
|
85
|
+
}
|
|
86
|
+
// Pas d'override opts → suit la config
|
|
87
|
+
if (cfg.genimage === false) return false
|
|
88
|
+
const theme = opts.theme ?? cfg.theme
|
|
89
|
+
return theme !== 'none'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function composeOptsFromQrOptions(opts: QrOptions, cfg: QrConfigDefaults): ComposeOptions {
|
|
93
|
+
return mergeComposeOpts(cfg, {
|
|
94
|
+
width: opts.width,
|
|
95
|
+
errorCorrectionLevel: opts.errorCorrectionLevel,
|
|
96
|
+
darkColor: opts.darkColor,
|
|
97
|
+
lightColor: opts.lightColor,
|
|
98
|
+
theme: opts.theme,
|
|
99
|
+
themePool: opts.themePool,
|
|
100
|
+
framePadding: opts.framePadding,
|
|
101
|
+
centerWhiteRatio: opts.centerWhiteRatio,
|
|
102
|
+
themeOpacity: opts.themeOpacity,
|
|
103
|
+
themeColor: opts.themeColor,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Public API : 3 generators ─────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Génère un PNG (Buffer) d'un QR code encodant `text`. */
|
|
110
|
+
export async function generateQrPng(text: string, opts: QrOptions = {}): Promise<Buffer> {
|
|
111
|
+
const cfg = loadQrConfig().default
|
|
112
|
+
if (shouldComposite(opts, cfg)) {
|
|
113
|
+
const composed = await composeThemedPng(text, composeOptsFromQrOptions(opts, cfg))
|
|
114
|
+
if (composed) return composed
|
|
115
|
+
}
|
|
116
|
+
// Legacy QR pur (qrcode lib direct)
|
|
117
|
+
return QRCode.toBuffer(text, {
|
|
118
|
+
width: opts.width ?? cfg.width,
|
|
119
|
+
margin: opts.margin ?? 2,
|
|
120
|
+
errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
|
|
121
|
+
color: {
|
|
122
|
+
dark: opts.darkColor ?? cfg.darkColor,
|
|
123
|
+
light: opts.lightColor ?? cfg.lightColor,
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Génère un SVG (string) d'un QR code encodant `text`. */
|
|
129
|
+
export async function generateQrSvg(text: string, opts: QrOptions = {}): Promise<string> {
|
|
130
|
+
const cfg = loadQrConfig().default
|
|
131
|
+
if (shouldComposite(opts, cfg)) {
|
|
132
|
+
const composed = await composeThemedSvg(text, composeOptsFromQrOptions(opts, cfg))
|
|
133
|
+
if (composed) return composed
|
|
134
|
+
}
|
|
135
|
+
return QRCode.toString(text, {
|
|
136
|
+
type: 'svg',
|
|
137
|
+
width: opts.width ?? cfg.width,
|
|
138
|
+
margin: opts.margin ?? 2,
|
|
139
|
+
errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
|
|
140
|
+
color: {
|
|
141
|
+
dark: opts.darkColor ?? cfg.darkColor,
|
|
142
|
+
light: opts.lightColor ?? cfg.lightColor,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Génère un Data URL `data:image/png;base64,...` */
|
|
148
|
+
export async function generateQrDataUrl(text: string, opts: QrOptions = {}): Promise<string> {
|
|
149
|
+
const cfg = loadQrConfig().default
|
|
150
|
+
if (shouldComposite(opts, cfg)) {
|
|
151
|
+
const composed = await composeThemedDataUrl(text, composeOptsFromQrOptions(opts, cfg))
|
|
152
|
+
if (composed) return composed
|
|
153
|
+
}
|
|
154
|
+
return QRCode.toDataURL(text, {
|
|
155
|
+
width: opts.width ?? cfg.width,
|
|
156
|
+
margin: opts.margin ?? 2,
|
|
157
|
+
errorCorrectionLevel: opts.errorCorrectionLevel ?? cfg.errorCorrectionLevel,
|
|
158
|
+
color: {
|
|
159
|
+
dark: opts.darkColor ?? cfg.darkColor,
|
|
160
|
+
light: opts.lightColor ?? cfg.lightColor,
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Helpers haut-niveau (URL builder + invite-token combiné) ─────
|
|
166
|
+
|
|
167
|
+
export interface BuildInviteUrlsOptions {
|
|
168
|
+
/** Base absolue (https://app.example.com), sans slash final. */
|
|
169
|
+
baseUrl: string
|
|
170
|
+
/** Path public direct (ex: '/projet-x'). Sera concaténé à baseUrl. */
|
|
171
|
+
directPath: string
|
|
172
|
+
/** Secret HMAC pour signer le token invite. */
|
|
173
|
+
inviteSecret: string | Buffer
|
|
174
|
+
/** Identifiant opaque encodé dans le token (resource id). */
|
|
175
|
+
inviteId: string
|
|
176
|
+
/** TTL en ms (default 60 jours). */
|
|
177
|
+
ttlMs?: number
|
|
178
|
+
/** Path du callback invite (default '/invite'). */
|
|
179
|
+
invitePath?: string
|
|
180
|
+
/** Meta libre encodée dans le token. */
|
|
181
|
+
inviteMeta?: Record<string, string | number | boolean>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface InviteUrls {
|
|
185
|
+
/** URL absolue mode "direct" (auth standard derrière). */
|
|
186
|
+
directUrl: string
|
|
187
|
+
/** URL absolue mode "invite" (token HMAC signé). */
|
|
188
|
+
inviteUrl: string
|
|
189
|
+
/** Le token signé seul (pour storage si besoin). */
|
|
190
|
+
inviteToken: string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Helper combiné — signe un invite-token et construit la paire d'URLs
|
|
195
|
+
* (direct + invite) prête à passer à `<QrPanel>` côté client.
|
|
196
|
+
*/
|
|
197
|
+
export function buildInviteUrls(opts: BuildInviteUrlsOptions): InviteUrls {
|
|
198
|
+
const base = String(opts.baseUrl).replace(/\/+$/, '')
|
|
199
|
+
const direct = opts.directPath.startsWith('/') ? opts.directPath : '/' + opts.directPath
|
|
200
|
+
const invitePath = (opts.invitePath ?? '/invite').replace(/\/+$/, '')
|
|
201
|
+
const inviteToken = signInviteToken(opts.inviteSecret, opts.inviteId, opts.ttlMs, opts.inviteMeta)
|
|
202
|
+
return {
|
|
203
|
+
directUrl: base + direct,
|
|
204
|
+
inviteUrl: `${base}${invitePath}/${inviteToken}`,
|
|
205
|
+
inviteToken,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── Re-exports utiles côté serveur ────────────────────────────────
|
|
210
|
+
|
|
211
|
+
export { listThemes, THEME_KEYS, THEMES, getTheme, type ThemeKey, type ThemeAsset } from './themes.js'
|
|
212
|
+
export { loadQrConfig, ensureQrConfig, DEFAULT_CONFIG, type QrConfig } from './config.js'
|