From ddb4c8175f31179be6c364faee60d56e100f62a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sun, 20 Sep 2026 22:31:40 +0800 Subject: [PATCH] feat(embed): versioned loader with postMessage whitelist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/embed-loader:手写零依赖加载器(esbuild bundle+minify→dist/v1/loader.js, IIFE target es2018 4.7KB)。自发现 EMBED_ORIGIN(currentScript 兜底扫描)、 data-slot 必填缺失静默 no-op、__photoliveEmbedV1 标记去重+同槽去重、 窄屏 <480px 强制 sm+默认收起、postMessage 双向白名单(source+origin 严格 校验,resize 夹取 60-640)、sessionStorage 收起记忆、零全局零 console 全静默。sync 脚本携 sha256 校验拷贝至 viewer publicDir(两份产物入库); src/params.js 为 loader/单测共用参数源(11 vitest 用例)。 Co-Authored-By: Claude Code --- .gitignore | 6 + apps/viewer/public/embed/v1/loader.js | 1 + packages/embed-loader/README-embed.md | 77 +++++++ packages/embed-loader/dist/v1/loader.js | 1 + packages/embed-loader/package.json | 18 ++ packages/embed-loader/src/loader.js | 243 +++++++++++++++++++++++ packages/embed-loader/src/params.js | 79 ++++++++ packages/embed-loader/src/params.test.js | 98 +++++++++ packages/embed-loader/sync-to-viewer.js | 31 +++ pnpm-lock.yaml | 83 ++++++++ 10 files changed, 637 insertions(+) create mode 100644 apps/viewer/public/embed/v1/loader.js create mode 100644 packages/embed-loader/README-embed.md create mode 100644 packages/embed-loader/dist/v1/loader.js create mode 100644 packages/embed-loader/package.json create mode 100644 packages/embed-loader/src/loader.js create mode 100644 packages/embed-loader/src/params.js create mode 100644 packages/embed-loader/src/params.test.js create mode 100644 packages/embed-loader/sync-to-viewer.js diff --git a/.gitignore b/.gitignore index d2aeedf..4e7e42c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ node_modules/ dist/ +# embed-loader 发布物即 dist 文件本身(viewer 同步副本也提交):白名单放行, +# 其余 dist/ 仍忽略(git 不允许重包含被排除目录的子路径,故逐层放行到 v1/)。 +!packages/embed-loader/dist/ +packages/embed-loader/dist/* +!packages/embed-loader/dist/v1/ +!packages/embed-loader/dist/v1/loader.js .turbo/ # @photo/db-ov Prisma client 工件(自定义 generator output;db-cn 走 node_modules 默认位) packages/db-ov/generated/ diff --git a/apps/viewer/public/embed/v1/loader.js b/apps/viewer/public/embed/v1/loader.js new file mode 100644 index 0000000..c2a04fb --- /dev/null +++ b/apps/viewer/public/embed/v1/loader.js @@ -0,0 +1 @@ +(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r +``` + +- `data-slot`(必填):展示位 publicToken;缺失时加载器静默不渲染任何内容。 +- `data-lang`(可选):`auto`(缺省,按相册 defaultLocale)| `zh-CN` | `en`,非法值回退 `auto`。 +- `data-position`(可选):`right`(缺省)| `left`,悬浮侧。 +- `data-interval`(可选):轮播间隔毫秒,夹取到 3000–30000,缺省 5000。 + +同一页面引入多个不同 `data-slot` 的标签即嵌入多个展示位;同一 `data-slot` 重复出现时 +后者静默忽略。位置/尺寸/语言等展示位级设置以后台配置为准,data-* 为宿主侧覆盖。 + +## CSP 要求(宿主页) + +| 指令 | 值 | 说明 | +| --- | --- | --- | +| `script-src` | `{EMBED_ORIGIN}` | 加载 loader.js | +| `frame-src` | `{EMBED_ORIGIN}` | 悬浮轮播 iframe(`{EMBED_ORIGIN}/embed/{slot}`) | + +组件自身不内联脚本、不加载其他域资源。`frame-ancestors` 由**我们**的 iframe 页在 P5 +配置(限制可嵌入站点),不是宿主的关注点。 + +## 窄屏行为 + +viewport 宽度 <480px 时:组件强制降为小尺寸(280px 宽)并默认收起为侧边圆形把手, +点击把手展开。会话内收起状态记忆在 `sessionStorage`(键 `pl-embed-collapsed:{slot}`), +刷新后保持收起,不反复弹出。 + +## 安全模型 + +- **iframe sandbox**:`allow-scripts allow-popups allow-popups-to-escape-sandbox + allow-same-origin`。必须含 `allow-same-origin`:否则 iframe 源为 opaque `null`, + 下方 origin 白名单会拒掉自己 iframe 的合法消息,且 iframe 内调公开 API 需要全站 + ACAO `*`。iframe 对宿主天然跨源(宿主域 ≠ 项目域),`allow-same-origin` 仅使 + iframe 保有**项目域自己的**源,不会获得宿主页源。 +- **postMessage 双向白名单**:loader 仅接受「`event.source` 等于已登记 iframe 的 + `contentWindow` 且 `event.origin` 严格等于该 iframe src 源」的消息,且载荷须为 + `{photoliveEmbed: true, type: resize|collapse|expand, ...}`;resize 高度夹取 + 60–640px。向 iframe 发消息(把手展开)同样以 iframe 源为目标,绝不使用 `*`。 +- **零信任宿主数据**:slot token 经 encodeURIComponent 进 URL;lang/position/interval + 白名单校验后拼入查询串。 +- **存储与 Cookie**:loader 不读写 Cookie、不写宿主 localStorage;仅上述 + sessionStorage 收起标记。无埋点、无对外数据上报。 +- **DOM 足迹**:仅向 body 追加 wrapper 与收起把手两个 div(全部 inline style, + 不注入样式表/类名,不影响宿主 CSS)。 + +## 产物与发布 + +- 发布物即文件本身(不走 npm):`packages/embed-loader/dist/v1/loader.js` + (esbuild bundle + minify,IIFE,target es2018,≤5KB 目标)。 +- **viewer 构建携带**:`pnpm --filter @photo/embed-loader run sync` 将产物拷贝至 + `apps/viewer/public/embed/v1/loader.js`(sha256 校验)。viewer 的 vite publicDir + 使 dev server 直接可访问、`vite build` 自动将其带入 dist/。两份产物均提交入库, + 修改 loader 后需重新 build + sync。 +- 路径 `/embed/v1/` 长期稳定;破坏性变更才会出现 `/embed/v2/`。 + +## 开发 + +```bash +pnpm --filter @photo/embed-loader run build # 打包 dist/v1/loader.js +pnpm --filter @photo/embed-loader run test # params 纯函数单测(vitest) +pnpm --filter @photo/embed-loader run sync # 拷贝至 viewer public/(含 hash 校验) +``` + +宿主页交互行为(iframe 出现/收起/记忆/窄屏)的端到端覆盖见 viewer Playwright +`e2e/embed.spec.ts`(P4b Task 4,宿主 fixture 页)。 diff --git a/packages/embed-loader/dist/v1/loader.js b/packages/embed-loader/dist/v1/loader.js new file mode 100644 index 0000000..c2a04fb --- /dev/null +++ b/packages/embed-loader/dist/v1/loader.js @@ -0,0 +1 @@ +(()=>{var N=["left","right"],H=["sm","md"],U=["auto","zh-CN","en"],p={position:"right",size:"md",intervalMs:5e3,lang:"auto",collapsedDefault:!1},I={sm:280,md:340},_=3e3,O=3e4,R=60,V=640;function h(n,l,s){return n.indexOf(l)>=0?l:s}function D(n){let l=parseInt(n,10);return isNaN(l)?p.intervalMs:Math.min(O,Math.max(_,l))}function S(n,l){let s=n||{},u=typeof s.slot=="string"?s.slot.replace(/^\s+|\s+$/g,""):"",c=!!(l&&l.narrow);return{slot:u||null,lang:h(U,s.lang,p.lang),position:h(N,s.position,p.position),intervalMs:D(s.interval),size:c?"sm":h(H,s.size,p.size),collapsedDefault:c?!0:p.collapsedDefault}}function E(n,l,s){return n+"/embed/"+encodeURIComponent(l)+"?lang="+encodeURIComponent(s.lang)+"&pos="+encodeURIComponent(s.position)+"&interval="+String(s.intervalMs)}function M(n){if(n==null)return null;let l=Number(n);return isNaN(l)||!isFinite(l)?null:Math.min(V,Math.max(R,Math.round(l)))}(function(){"use strict";try{let x=function(e){try{return n.sessionStorage.getItem(l+e)==="1"}catch(t){return!1}},b=function(e,t){try{t?n.sessionStorage.setItem(l+e,"1"):n.sessionStorage.removeItem(l+e)}catch(i){}},A=function(){if(document.currentScript&&document.currentScript.src)return document.currentScript;var e=document.querySelectorAll('script[src*="/embed/v1/loader.js"]');return e.length?e[e.length-1]:null},g=function(e){try{return new URL(e.src,n.location.href).origin}catch(t){return null}},z=function(e){return e==="zh-CN"?"\u5C55\u5F00\u7167\u7247\u8F6E\u64AD":"Show photo carousel"},C=function(e){var t=document.createElement("div"),i=e.config.position==="left"?"left":"right";t.setAttribute("role","button"),t.setAttribute("tabindex","0"),t.setAttribute("aria-label",z(e.config.lang));var o=t.style;o.position="fixed",o.bottom="24px",o[i]="16px",o.width="44px",o.height="44px",o.borderRadius="50%",o.background="rgba(15,23,42,0.88)",o.boxShadow="0 2px 10px rgba(0,0,0,0.3)",o.zIndex=s,o.cursor="pointer",o.display="flex",o.alignItems="center",o.justifyContent="center",t.innerHTML='';function r(a){if(a.type==="keydown"){var d=a.key;if(d!=="Enter"&&d!==" "&&d!=="Spacebar")return;a.preventDefault()}w(e);try{e.frame.contentWindow&&e.frame.contentWindow.postMessage({photoliveEmbed:!0,type:"expand"},e.frameOrigin)}catch(f){}}return t.addEventListener("click",r),t.addEventListener("keydown",r),t},v=function(e){e.collapsed||(e.collapsed=!0,e.wrapper.style.height="0px",e.wrapper.style.boxShadow="none",e.handle||(e.handle=C(e),document.body.appendChild(e.handle)),e.handle.style.display="flex",b(e.config.slot,!0))},w=function(e){e.collapsed&&(e.collapsed=!1,e.wrapper.style.height=e.height+"px",e.wrapper.style.boxShadow="0 4px 16px rgba(0,0,0,0.18)",e.handle&&(e.handle.style.display="none"),b(e.config.slot,!1))},L=function(e,t){var i=document.createElement("div"),o=t.position==="left"?"left":"right",r=i.style;r.position="fixed",r.bottom="24px",r[o]="16px",r.width=I[t.size]+"px",r.height=u+"px",r.zIndex=s,r.borderRadius="12px",r.boxShadow="0 4px 16px rgba(0,0,0,0.18)",r.overflow="hidden";var a=document.createElement("iframe");a.setAttribute("sandbox","allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin"),a.setAttribute("src",E(e,t.slot,t)),a.setAttribute("referrerpolicy","no-referrer-when-downgrade"),a.setAttribute("title","PhotoLive photos");var d=a.style;d.width="100%",d.height="100%",d.border="0",d.display="block",i.appendChild(a),document.body.appendChild(i);var f={config:t,wrapper:i,frame:a,frameOrigin:g(a)||e,height:u,collapsed:!1,handle:null};c.push(f),(x(t.slot)||t.collapsedDefault)&&v(f)},y=function(){var e=A();if(e){var t=g(e);if(t){var i=!1;try{i=!!n.matchMedia&&n.matchMedia("(max-width: 479px)").matches}catch(d){i=!1}for(var o=document.querySelectorAll('script[src*="/embed/v1/loader.js"]'),r=0;r 引入)。 +// 约束:零依赖、零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。 +// 安全模型(postMessage 双向白名单 / sandbox 取舍)见同包 README-embed.md。 +import { + parseEmbedConfig, + buildEmbedUrl, + clampHeight, + SIZE_WIDTH, +} from './params.js'; + +(function () { + 'use strict'; + try { + var w = window; + // 已初始化去重:同一页面重复引入 loader 只跑一次(不同 slot 的多个标签在首次运行时全部扫描)。 + if (w.__photoliveEmbedV1) return; + w.__photoliveEmbedV1 = '1'; + + var COLLAPSE_KEY = 'pl-embed-collapsed:'; + var Z_INDEX = '2147483000'; + var DEFAULT_HEIGHT = 96; // 首条 resize 消息前的占位高度 + + function readCollapsed(slot) { + try { + return w.sessionStorage.getItem(COLLAPSE_KEY + slot) === '1'; + } catch { + return false; + } + } + function writeCollapsed(slot, collapsed) { + try { + if (collapsed) w.sessionStorage.setItem(COLLAPSE_KEY + slot, '1'); + else w.sessionStorage.removeItem(COLLAPSE_KEY + slot); + } catch { + /* Safari 隐私模式等 storage 异常:静默放弃记忆 */ + } + } + + // 自发现:currentScript(defer 下同步执行仍可用)→ 兜底最后一个匹配标签。 + function selfScript() { + if (document.currentScript && document.currentScript.src) return document.currentScript; + var all = document.querySelectorAll('script[src*="/embed/v1/loader.js"]'); + return all.length ? all[all.length - 1] : null; + } + function originOf(el) { + try { + return new URL(el.src, w.location.href).origin; + } catch { + return null; + } + } + + var instances = []; + var seenSlots = {}; + + function handleLabel(lang) { + if (lang === 'zh-CN') return '展开照片轮播'; + if (lang === 'en') return 'Show photo carousel'; + return 'Show photo carousel'; + } + + function createHandle(inst) { + var h = document.createElement('div'); + var side = inst.config.position === 'left' ? 'left' : 'right'; + h.setAttribute('role', 'button'); + h.setAttribute('tabindex', '0'); + h.setAttribute('aria-label', handleLabel(inst.config.lang)); + var s = h.style; + s.position = 'fixed'; + s.bottom = '24px'; + s[side] = '16px'; + s.width = '44px'; + s.height = '44px'; + s.borderRadius = '50%'; + s.background = 'rgba(15,23,42,0.88)'; + s.boxShadow = '0 2px 10px rgba(0,0,0,0.3)'; + s.zIndex = Z_INDEX; + s.cursor = 'pointer'; + s.display = 'flex'; + s.alignItems = 'center'; + s.justifyContent = 'center'; + h.innerHTML = + ''; + function activate(ev) { + if (ev.type === 'keydown') { + var k = ev.key; + if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return; + ev.preventDefault(); + } + expandInstance(inst); + // 通知 iframe 内部恢复轮播/可见行为;目标源严格限定 iframe 源(绝不 '*')。 + try { + if (inst.frame.contentWindow) { + inst.frame.contentWindow.postMessage( + { photoliveEmbed: true, type: 'expand' }, + inst.frameOrigin + ); + } + } catch { + /* 静默 */ + } + } + h.addEventListener('click', activate); + h.addEventListener('keydown', activate); + return h; + } + + function collapseInstance(inst) { + if (inst.collapsed) return; + inst.collapsed = true; + inst.wrapper.style.height = '0px'; + inst.wrapper.style.boxShadow = 'none'; + if (!inst.handle) { + inst.handle = createHandle(inst); + document.body.appendChild(inst.handle); + } + inst.handle.style.display = 'flex'; + writeCollapsed(inst.config.slot, true); + } + + function expandInstance(inst) { + if (!inst.collapsed) return; + inst.collapsed = false; + inst.wrapper.style.height = inst.height + 'px'; + inst.wrapper.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)'; + if (inst.handle) inst.handle.style.display = 'none'; + writeCollapsed(inst.config.slot, false); + } + + function createInstance(origin, config) { + var wrapper = document.createElement('div'); + var side = config.position === 'left' ? 'left' : 'right'; + var ws = wrapper.style; + ws.position = 'fixed'; + ws.bottom = '24px'; + ws[side] = '16px'; + ws.width = SIZE_WIDTH[config.size] + 'px'; + ws.height = DEFAULT_HEIGHT + 'px'; + ws.zIndex = Z_INDEX; + ws.borderRadius = '12px'; + ws.boxShadow = '0 4px 16px rgba(0,0,0,0.18)'; + ws.overflow = 'hidden'; + + var frame = document.createElement('iframe'); + // sandbox 必须含 allow-same-origin(评审修正):否则 iframe 为 opaque 源 "null", + // loader 的「消息 origin === iframe 源」白名单会拒掉自己的 resize/collapse, + // 且 iframe 内取公开 API 需 ACAO *。宿主↔iframe 天然跨源,same-origin 仅使 + // iframe 保有项目域源,白名单按源+source 双重严格校验兜底。 + frame.setAttribute( + 'sandbox', + 'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin' + ); + frame.setAttribute('src', buildEmbedUrl(origin, config.slot, config)); + frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); + frame.setAttribute('title', 'PhotoLive photos'); + var fs = frame.style; + fs.width = '100%'; + fs.height = '100%'; + fs.border = '0'; + fs.display = 'block'; + wrapper.appendChild(frame); + document.body.appendChild(wrapper); + + var inst = { + config: config, + wrapper: wrapper, + frame: frame, + // 白名单基准源:从 iframe src 推导(构建后即固定,等于 EMBED_ORIGIN)。 + frameOrigin: originOf(frame) || origin, + height: DEFAULT_HEIGHT, + collapsed: false, + handle: null, + }; + instances.push(inst); + // 收起记忆 / 窄屏默认收起:会话内不反复弹出(需求 9)。 + if (readCollapsed(config.slot) || config.collapsedDefault) collapseInstance(inst); + } + + // postMessage 白名单:仅 photoliveEmbed===true 且 type∈{resize,collapse,expand}, + // 且 event.source 命中已登记 iframe 且 event.origin 严格等于该 iframe 的源。 + w.addEventListener('message', function (event) { + try { + var data = event.data; + if (!data || data.photoliveEmbed !== true) return; + var type = data.type; + if (type !== 'resize' && type !== 'collapse' && type !== 'expand') return; + for (var i = 0; i < instances.length; i++) { + var inst = instances[i]; + if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue; + if (event.origin !== inst.frameOrigin) return; + if (type === 'resize') { + var h = clampHeight(data.height); + if (h !== null) { + inst.height = h; + if (!inst.collapsed) inst.wrapper.style.height = h + 'px'; + } + } else if (type === 'collapse') { + collapseInstance(inst); + } else { + expandInstance(inst); + } + return; + } + } catch { + /* 静默 */ + } + }); + + function init() { + var self = selfScript(); + if (!self) return; + var embedOrigin = originOf(self); + if (!embedOrigin) return; + var narrow = false; + try { + narrow = !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches; + } catch { + narrow = false; + } + // 扫描全部 loader 标签:单标签单槽为主路径,多标签(多槽/集中引入)一并覆盖; + // 同 data-slot 重复出现时后者静默忽略。 + var tags = document.querySelectorAll('script[src*="/embed/v1/loader.js"]'); + for (var i = 0; i < tags.length; i++) { + var config = parseEmbedConfig(tags[i].dataset, { narrow: narrow }); + if (!config.slot) continue; + if (seenSlots[config.slot]) continue; + seenSlots[config.slot] = true; + createInstance(embedOrigin, config); + } + } + + if (document.body) { + init(); + } else { + document.addEventListener('DOMContentLoaded', init); + } + } catch { + /* 静默:任何异常不得破坏宿主页 */ + } +})(); diff --git a/packages/embed-loader/src/params.js b/packages/embed-loader/src/params.js new file mode 100644 index 0000000..3e673b4 --- /dev/null +++ b/packages/embed-loader/src/params.js @@ -0,0 +1,79 @@ +// packages/embed-loader/src/params.js +// 纯函数参数模块:loader(esbuild 打包内联)与 vitest 单测共用的唯一参数源。 +// 取值域与缺省严格对齐契约 EmbedSlotSettings(packages/contracts/openapi.yaml), +// 非法/缺省一律静默回退——loader 对宿主零报错(A11「不破坏宿主」)。 + +export const POSITION_VALUES = ['left', 'right']; +export const SIZE_VALUES = ['sm', 'md']; +export const LANG_VALUES = ['auto', 'zh-CN', 'en']; + +export const DEFAULTS = { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, +}; + +/** size → 宿主侧 wrapper 宽度(px)。sm 为窄屏降级档。 */ +export const SIZE_WIDTH = { sm: 280, md: 340 }; + +export const INTERVAL_MIN = 3000; +export const INTERVAL_MAX = 30000; + +/** resize 消息高度夹取范围(px),越界/非数值返回 null 表示忽略。 */ +export const HEIGHT_MIN = 60; +export const HEIGHT_MAX = 640; + +function oneOf(allowed, value, fallback) { + return allowed.indexOf(value) >= 0 ? value : fallback; +} + +/** 轮播间隔(ms):解析失败取缺省,超界夹取到 [3000, 30000]。 */ +export function normalizeInterval(value) { + const n = parseInt(value, 10); + if (isNaN(n)) return DEFAULTS.intervalMs; + return Math.min(INTERVAL_MAX, Math.max(INTERVAL_MIN, n)); +} + +/** + * 解析宿主 script 标签 data-* 配置(dataset 形如 {slot, lang, position, interval})。 + * opts.narrow=true(viewport <480px)时强制 sm 尺寸 + 默认收起(计划评审缺口:移动端可用)。 + * data-slot 缺失/空白 → slot 为 null,调用方须静默 no-op。 + */ +export function parseEmbedConfig(dataset, opts) { + const ds = dataset || {}; + const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : ''; + const narrow = !!(opts && opts.narrow); + return { + slot: slot || null, + lang: oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang), + position: oneOf(POSITION_VALUES, ds.position, DEFAULTS.position), + intervalMs: normalizeInterval(ds.interval), + size: narrow ? 'sm' : oneOf(SIZE_VALUES, ds.size, DEFAULTS.size), + collapsedDefault: narrow ? true : DEFAULTS.collapsedDefault, + }; +} + +/** iframe src:{origin}/embed/{slot}?lang=&pos=&interval=(槽 token 编码防注入)。 */ +export function buildEmbedUrl(origin, slot, config) { + return ( + origin + + '/embed/' + + encodeURIComponent(slot) + + '?lang=' + + encodeURIComponent(config.lang) + + '&pos=' + + encodeURIComponent(config.position) + + '&interval=' + + String(config.intervalMs) + ); +} + +/** resize 高度夹取:非有限数值/缺失 → null(忽略该消息)。 */ +export function clampHeight(value) { + if (value === null || value === undefined) return null; + const n = Number(value); + if (isNaN(n) || !isFinite(n)) return null; + return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n))); +} diff --git a/packages/embed-loader/src/params.test.js b/packages/embed-loader/src/params.test.js new file mode 100644 index 0000000..8195718 --- /dev/null +++ b/packages/embed-loader/src/params.test.js @@ -0,0 +1,98 @@ +// packages/embed-loader/src/params.test.js +// 纯函数单测:data-* 解析/缺省回退/夹取、窄屏强制、URL 拼接、高度夹取。 +// DOM/宿主交互行为由 Task 4 Playwright 宿主页覆盖(计划注明 node 模拟 DOM 太薄)。 +import { describe, expect, it } from 'vitest'; +import { + buildEmbedUrl, + clampHeight, + normalizeInterval, + parseEmbedConfig, +} from './params.js'; + +describe('parseEmbedConfig', () => { + it('空 dataset 全缺省(slot null → 调用方 no-op)', () => { + const c = parseEmbedConfig({}); + expect(c).toEqual({ + slot: null, + lang: 'auto', + position: 'right', + intervalMs: 5000, + size: 'md', + collapsedDefault: false, + }); + }); + + it('data-slot 保留并去首尾空白;非字符串(缺失)为 null', () => { + expect(parseEmbedConfig({ slot: ' tok ' }).slot).toBe('tok'); + expect(parseEmbedConfig({}).slot).toBeNull(); + expect(parseEmbedConfig({ slot: ' ' }).slot).toBeNull(); + }); + + it('lang/position 白名单外静默回退缺省', () => { + expect(parseEmbedConfig({ slot: 'a', lang: 'zh-CN' }).lang).toBe('zh-CN'); + expect(parseEmbedConfig({ slot: 'a', lang: 'en' }).lang).toBe('en'); + expect(parseEmbedConfig({ slot: 'a', lang: 'fr' }).lang).toBe('auto'); + expect(parseEmbedConfig({ slot: 'a', position: 'left' }).position).toBe('left'); + expect(parseEmbedConfig({ slot: 'a', position: 'top' }).position).toBe('right'); + }); + + it('interval 解析失败取缺省、越界夹取到 [3000, 30000]', () => { + expect(normalizeInterval('8000')).toBe(8000); + expect(normalizeInterval('100')).toBe(3000); + expect(normalizeInterval('999999')).toBe(30000); + expect(normalizeInterval('abc')).toBe(5000); + expect(normalizeInterval(undefined)).toBe(5000); + expect(parseEmbedConfig({ slot: 'a', interval: '2500' }).intervalMs).toBe(3000); + }); + + it('窄屏(<480px):强制 sm 尺寸 + 默认收起,覆盖 dataset 尺寸语义', () => { + const c = parseEmbedConfig({ slot: 'a', size: 'md' }, { narrow: true }); + expect(c.size).toBe('sm'); + expect(c.collapsedDefault).toBe(true); + }); + + it('非窄屏时 collapsedDefault 恒 false(收起默认由 iframe 侧按服务端设置下发)', () => { + expect(parseEmbedConfig({ slot: 'a' }, { narrow: false }).collapsedDefault).toBe(false); + }); +}); + +describe('buildEmbedUrl', () => { + it('拼接 lang/pos/interval 查询参数', () => { + const url = buildEmbedUrl('https://ov.example.com', 'tok', { + lang: 'zh-CN', + position: 'left', + intervalMs: 7000, + }); + expect(url).toBe('https://ov.example.com/embed/tok?lang=zh-CN&pos=left&interval=7000'); + }); + + it('slot token 编码(防路径注入)', () => { + const url = buildEmbedUrl('https://ov.example.com', 'a/b?c=1', { + lang: 'auto', + position: 'right', + intervalMs: 5000, + }); + expect(url.startsWith('https://ov.example.com/embed/a%2Fb%3Fc%3D1?')).toBe(true); + expect(url).toContain('lang=auto&pos=right&interval=5000'); + }); +}); + +describe('clampHeight', () => { + it('正常值原样(取整)返回', () => { + expect(clampHeight(200)).toBe(200); + expect(clampHeight(200.6)).toBe(201); + }); + + it('夹取到 [60, 640]', () => { + expect(clampHeight(10)).toBe(60); + expect(clampHeight(9999)).toBe(640); + }); + + it('缺失/非数值返回 null(忽略该消息)', () => { + expect(clampHeight(null)).toBeNull(); + expect(clampHeight(undefined)).toBeNull(); + expect(clampHeight('abc')).toBeNull(); + expect(clampHeight(NaN)).toBeNull(); + expect(clampHeight(Infinity)).toBeNull(); + }); +}); diff --git a/packages/embed-loader/sync-to-viewer.js b/packages/embed-loader/sync-to-viewer.js new file mode 100644 index 0000000..e279787 --- /dev/null +++ b/packages/embed-loader/sync-to-viewer.js @@ -0,0 +1,31 @@ +// packages/embed-loader/sync-to-viewer.js +// 复制构建产物到 viewer 静态目录:vite publicDir(apps/viewer/public/)在 dev +// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建脚本 +// 级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader sync。 +import { createHash } from 'node:crypto'; +import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = resolve(here, 'dist/v1/loader.js'); +const dest = resolve(here, '../../apps/viewer/public/embed/v1/loader.js'); + +if (!existsSync(src)) { + console.error('[sync] missing dist/v1/loader.js — run `pnpm build` first'); + process.exit(1); +} + +mkdirSync(dirname(dest), { recursive: true }); +cpSync(src, dest); + +function sha256(p) { + return createHash('sha256').update(readFileSync(p)).digest('hex'); +} + +const srcHash = sha256(src); +if (srcHash !== sha256(dest)) { + console.error('[sync] hash mismatch after copy — viewer copy diverged'); + process.exit(1); +} +console.log(`[sync] dist/v1/loader.js -> apps/viewer/public/embed/v1/loader.js (sha256 ${srcHash.slice(0, 12)}…)`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 231429e..1666984 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,15 @@ importers: specifier: ^5.6.0 version: 5.6.3 + packages/embed-loader: + devDependencies: + esbuild: + specifier: ^0.21.5 + version: 0.21.5 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@24.13.5)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + packages/storage: dependencies: '@aws-sdk/client-s3': @@ -8058,6 +8067,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.20.3)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2))': dependencies: '@vitest/spy': 4.1.11 @@ -11734,6 +11751,24 @@ snapshots: - supports-color - terser + vite-node@2.1.9(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-plugin-compression@0.5.1(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)): dependencies: chalk: 4.1.2 @@ -11802,6 +11837,18 @@ snapshots: sass: 1.104.1 terser: 5.51.2 + vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.3 + optionalDependencies: + '@types/node': 24.13.5 + fsevents: 2.3.3 + lightningcss: 1.32.0 + sass: 1.104.1 + terser: 5.51.2 + vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2): dependencies: esbuild: 0.28.2 @@ -11854,6 +11901,42 @@ snapshots: - supports-color - terser + vitest@2.1.9(@types/node@24.13.5)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + vite-node: 2.1.9(@types/node@24.13.5)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.5 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.13.5)(jsdom@25.0.1)(vite@7.3.6(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.104.1)(terser@5.51.2)): dependencies: '@vitest/expect': 4.1.11