domma-cms 0.14.10 → 0.16.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.
Files changed (37) hide show
  1. package/admin/js/lib/effect-defs.js +1 -0
  2. package/admin/js/lib/effects-builder.js +3 -0
  3. package/admin/js/lib/markdown-toolbar.js +17 -46
  4. package/admin/js/templates/effects.html +83 -0
  5. package/admin/js/views/page-editor.js +12 -12
  6. package/package.json +2 -2
  7. package/plugins/analytics/admin/templates/analytics.html +52 -1
  8. package/plugins/analytics/admin/views/analytics.js +157 -32
  9. package/plugins/analytics/config.js +10 -2
  10. package/plugins/analytics/plugin.js +214 -25
  11. package/plugins/analytics/plugin.json +9 -5
  12. package/plugins/analytics/public/inject-body.html +25 -7
  13. package/plugins/blog/admin/templates/blog.html +25 -2
  14. package/plugins/blog/admin/views/blog.js +72 -56
  15. package/plugins/blog/admin/views/post-editor.js +98 -79
  16. package/plugins/blog/plugin.js +133 -0
  17. package/plugins/blog/plugin.json +3 -3
  18. package/plugins/blog/templates/post.html +2 -1
  19. package/plugins/invoice/admin/templates/editor.html +129 -0
  20. package/plugins/invoice/admin/templates/index.html +43 -0
  21. package/plugins/invoice/admin/templates/issuers.html +5 -0
  22. package/plugins/invoice/admin/templates/receivers.html +5 -0
  23. package/plugins/invoice/admin/views/editor.js +267 -0
  24. package/plugins/invoice/admin/views/index.js +155 -0
  25. package/plugins/invoice/admin/views/issuers.js +23 -0
  26. package/plugins/invoice/admin/views/party-view.js +148 -0
  27. package/plugins/invoice/admin/views/receivers.js +22 -0
  28. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  29. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  30. package/plugins/invoice/collections/invoices/schema.json +27 -0
  31. package/plugins/invoice/config.js +16 -0
  32. package/plugins/invoice/plugin.js +283 -0
  33. package/plugins/invoice/plugin.json +85 -0
  34. package/plugins/invoice/templates/invoice-print.html +213 -0
  35. package/public/js/effects.js +1 -1
  36. package/server/services/markdown.js +114 -25
  37. package/server/services/renderer.js +9 -3
@@ -0,0 +1,213 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Invoice {{number}}</title>
6
+ <style>
7
+ :root { color-scheme: light; }
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
11
+ color: #222;
12
+ background: #f3f3f3;
13
+ margin: 0;
14
+ padding: 24px;
15
+ font-size: 14px;
16
+ line-height: 1.5;
17
+ }
18
+ .invoice {
19
+ max-width: 800px;
20
+ margin: 0 auto;
21
+ background: #fff;
22
+ padding: 48px 56px;
23
+ box-shadow: 0 4px 20px rgba(0,0,0,.08);
24
+ }
25
+ .top {
26
+ display: flex;
27
+ justify-content: space-between;
28
+ align-items: flex-start;
29
+ gap: 24px;
30
+ border-bottom: 2px solid #e5e5e5;
31
+ padding-bottom: 24px;
32
+ margin-bottom: 32px;
33
+ }
34
+ .logo { max-height: 80px; max-width: 240px; display: block; margin-bottom: 12px; }
35
+ .meta { text-align: right; }
36
+ .meta h1 { margin: 0; font-size: 28px; letter-spacing: 1px; color: #000; }
37
+ .meta .number { font-size: 18px; color: #555; margin-top: 4px; }
38
+ .status {
39
+ display: inline-block;
40
+ padding: 4px 10px;
41
+ border-radius: 4px;
42
+ font-size: 11px;
43
+ font-weight: 600;
44
+ letter-spacing: .5px;
45
+ background: #eee;
46
+ color: #444;
47
+ margin-top: 8px;
48
+ }
49
+ .parties {
50
+ display: grid;
51
+ grid-template-columns: 1fr 1fr;
52
+ gap: 32px;
53
+ margin-bottom: 32px;
54
+ }
55
+ .party h3 {
56
+ margin: 0 0 8px;
57
+ font-size: 11px;
58
+ text-transform: uppercase;
59
+ letter-spacing: 1px;
60
+ color: #888;
61
+ }
62
+ .party .name { font-weight: 700; font-size: 15px; margin-bottom: 4px; }
63
+ .party .lines { color: #555; font-size: 13px; }
64
+ table.items {
65
+ width: 100%;
66
+ border-collapse: collapse;
67
+ margin-bottom: 24px;
68
+ }
69
+ table.items th {
70
+ text-align: left;
71
+ font-size: 11px;
72
+ text-transform: uppercase;
73
+ letter-spacing: 1px;
74
+ color: #888;
75
+ padding: 8px 10px;
76
+ border-bottom: 2px solid #e5e5e5;
77
+ }
78
+ table.items td {
79
+ padding: 10px;
80
+ border-bottom: 1px solid #f0f0f0;
81
+ }
82
+ table.items td.num, table.items th.num { text-align: right; }
83
+ .totals {
84
+ width: 320px;
85
+ margin-left: auto;
86
+ margin-bottom: 32px;
87
+ }
88
+ .totals table { width: 100%; border-collapse: collapse; }
89
+ .totals th {
90
+ text-align: left;
91
+ padding: 8px 10px;
92
+ font-weight: 500;
93
+ color: #555;
94
+ }
95
+ .totals td.num {
96
+ text-align: right;
97
+ padding: 8px 10px;
98
+ font-variant-numeric: tabular-nums;
99
+ }
100
+ .totals .grand th, .totals .grand td {
101
+ font-size: 17px;
102
+ font-weight: 700;
103
+ color: #000;
104
+ border-top: 2px solid #222;
105
+ padding-top: 12px;
106
+ }
107
+ .footer {
108
+ margin-top: 32px;
109
+ padding-top: 16px;
110
+ border-top: 1px solid #e5e5e5;
111
+ font-size: 12px;
112
+ color: #888;
113
+ }
114
+ .notes, .bank {
115
+ margin-top: 24px;
116
+ padding: 12px 16px;
117
+ background: #fafafa;
118
+ border-radius: 4px;
119
+ font-size: 13px;
120
+ }
121
+ .muted { color: #999; font-style: italic; text-align: center; }
122
+ .actions {
123
+ max-width: 800px;
124
+ margin: 0 auto 16px;
125
+ display: flex;
126
+ justify-content: flex-end;
127
+ gap: 8px;
128
+ }
129
+ .actions button {
130
+ background: #222;
131
+ color: #fff;
132
+ border: 0;
133
+ padding: 8px 16px;
134
+ font-size: 13px;
135
+ border-radius: 4px;
136
+ cursor: pointer;
137
+ }
138
+ @media print {
139
+ body { background: #fff; padding: 0; }
140
+ .invoice { box-shadow: none; padding: 24px; }
141
+ .actions { display: none; }
142
+ }
143
+ </style>
144
+ </head>
145
+ <body>
146
+
147
+ <div class="actions">
148
+ <button onclick="window.print()">Print / Save as PDF</button>
149
+ </div>
150
+
151
+ <div class="invoice">
152
+ <div class="top">
153
+ <div>
154
+ {{issuerLogo}}
155
+ <div class="party">
156
+ <div class="name">{{issuerName}}</div>
157
+ <div class="lines">{{issuerAddress}}</div>
158
+ <div class="lines">{{issuerEmail}}</div>
159
+ <div class="lines">{{issuerPhone}}</div>
160
+ {{issuerVat}}
161
+ </div>
162
+ </div>
163
+ <div class="meta">
164
+ <h1>INVOICE</h1>
165
+ <div class="number">{{number}}</div>
166
+ <div>{{issueDate}}</div>
167
+ {{dueDate}}
168
+ <div class="status">{{status}}</div>
169
+ </div>
170
+ </div>
171
+
172
+ <div class="parties">
173
+ <div class="party">
174
+ <h3>Billed To</h3>
175
+ <div class="name">{{receiverName}}</div>
176
+ <div class="lines">{{receiverAddress}}</div>
177
+ <div class="lines">{{receiverEmail}}</div>
178
+ {{receiverVat}}
179
+ </div>
180
+ </div>
181
+
182
+ <table class="items">
183
+ <thead>
184
+ <tr>
185
+ <th>Description</th>
186
+ <th class="num">Qty</th>
187
+ <th class="num">Unit</th>
188
+ <th class="num">Total</th>
189
+ </tr>
190
+ </thead>
191
+ <tbody>
192
+ {{items}}
193
+ </tbody>
194
+ </table>
195
+
196
+ <div class="totals">
197
+ <table>
198
+ <tr><th>Subtotal</th><td class="num">{{subtotal}}</td></tr>
199
+ {{vatRow}}
200
+ <tr class="grand"><th>Total</th><td class="num">{{total}}</td></tr>
201
+ </table>
202
+ </div>
203
+
204
+ {{notes}}
205
+ {{bankDetails}}
206
+
207
+ <div class="footer">{{footerNote}}</div>
208
+ </div>
209
+
210
+ {{autoPrint}}
211
+
212
+ </body>
213
+ </html>
@@ -1 +1 @@
1
- (function(){"use strict";fetch("/api/effects/settings").then(function(m){return m.json()}).then(function(m){var w=m.respectMotion!==!1,E=m.defaultDuration||600,q=m.defaultAnimation||"fade",z=m.defaultThreshold!=null?m.defaultThreshold:.1,v=w&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,c=document.querySelector(".page-body");if(!c)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function o(t,e,n){var a=r(t,e);return a!==null?parseFloat(a):n}function x(t,e,n){var a=r(t,e);return a===null?n:a==="true"||a==="1"}function g(t,e,n){var a=new IntersectionObserver(function(i){i[0].isIntersecting&&(a.unobserve(t),n())},{threshold:e});a.observe(t)}c.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||q,n=o(t,"duration",E),a=o(t,"delay",0),i=o(t,"threshold",z),f=x(t,"once",!0);v||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+a+"ms, transform "+n+"ms ease "+a+"ms";var s=new IntersectionObserver(function(u){u.forEach(function(l){l.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:i});s.observe(t)})}))}),c.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=o(t,"to",0),n=o(t,"from",0),a=o(t,"duration",2e3),i=r(t,"prefix")||"",f=r(t,"suffix")||"",s=o(t,"decimals",0),u=r(t,"separator")||"";function l(d){var y=d.toFixed(s);if(u){var p=y.split(".");p[0]=p[0].replace(/\B(?=(\d{3})+(?!\d))/g,u),y=p.join(".")}return i+y+f}if(v){t.textContent=l(e);return}g(t,.5,function(){var d=null;requestAnimationFrame(function y(p){d||(d=p);var A=Math.min((p-d)/a,1),S=1-Math.pow(1-A,3);t.textContent=l(n+(e-n)*S),A<1&&requestAnimationFrame(y)})})}),v||c.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=o(t,"speed",50),n=t.textContent,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",i=0;g(t,.3,function(){var f=setInterval(function(){for(var s="",u=0;u<n.length;u++)u<i||n[u]===" "?s+=n[u]:s+=a[Math.floor(Math.random()*a.length)];t.textContent=s,i<n.length?i++:clearInterval(f)},e)})}),v||c.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=o(t,"intensity",5),n=o(t,"duration",500),a=r(t,"direction")||"horizontal",i=a==="vertical"?"translateY":"translateX";t.animate([{transform:i+"(0)"},{transform:i+"(-"+e+"px)"},{transform:i+"("+e+"px)"},{transform:i+"(-"+e+"px)"},{transform:i+"("+e+"px)"},{transform:i+"(0)"}],{duration:n,easing:"ease-in-out"})}),c.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",a=o(t,"duration",600),i=t.getBoundingClientRect(),f=Math.max(i.width,i.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-i.left-f/2)+"px;top:"+(e.clientY-i.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+a+"ms ease,opacity "+a+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},a)})});var h=window.Domma&&window.Domma.effects;h&&(c.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!v){var e={};r(t,"amplitude")&&(e.amplitude=o(t,"amplitude",6)),r(t,"duration")&&(e.duration=o(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=o(t,"stagger",0)),h.breathe(t,e)}}),c.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!v){var e={};r(t,"scale")&&(e.scale=o(t,"scale",1.05)),r(t,"duration")&&(e.duration=o(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),h.pulse(t,e)}}),c.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=o(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=o(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=x(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=x(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=o(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=x(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var a=t.textContent.trim();if(!a)return;t.textContent="",e.actions=[{render:a}]}h.scribe(t,e)}),c.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=o(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=o(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=o(t,"max-size",3)),h.twinkle(t,e)}));var b=c.querySelectorAll(".dm-fx-celebrate");b.length&&!v&&import("/public/js/celebrations/index.js").then(function(t){var e=t.CelebrationsEffect;b.forEach(function(n){var a=n.getAttribute("data-fx-theme")||"auto",i=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(a==="auto"&&(a=e.getCurrentTheme(),!a))){var s={},u=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(d){d.name.startsWith("data-fx-")&&!u.includes(d.name)&&(s[d.name.slice(8)]=d.value)});var l=new e({theme:a,intensity:i,enabled:!0,zIndex:f,overrides:s});l.init().then(function(){l.start()})}})}).catch(function(){})}).catch(function(){})})();
1
+ (function(){"use strict";fetch("/api/effects/settings").then(function(h){return h.json()}).then(function(h){var A=h.respectMotion!==!1,E=h.defaultDuration||600,z=h.defaultAnimation||"fade",S=h.defaultThreshold!=null?h.defaultThreshold:.1,m=A&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,u=document.querySelector(".page-body");if(!u)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function a(t,e,n){var i=r(t,e);return i!==null?parseFloat(i):n}function y(t,e,n){var i=r(t,e);return i===null?n:i==="true"||i==="1"}function g(t,e,n){var i=new IntersectionObserver(function(o){o[0].isIntersecting&&(i.unobserve(t),n())},{threshold:e});i.observe(t)}u.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||z,n=a(t,"duration",E),i=a(t,"delay",0),o=a(t,"threshold",S),f=y(t,"once",!0);m||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+i+"ms, transform "+n+"ms ease "+i+"ms";var s=new IntersectionObserver(function(c){c.forEach(function(v){v.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:o});s.observe(t)})}))}),u.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=a(t,"to",0),n=a(t,"from",0),i=a(t,"duration",2e3),o=r(t,"prefix")||"",f=r(t,"suffix")||"",s=a(t,"decimals",0),c=r(t,"separator")||"";function v(d){var x=d.toFixed(s);if(c){var l=x.split(".");l[0]=l[0].replace(/\B(?=(\d{3})+(?!\d))/g,c),x=l.join(".")}return o+x+f}if(m){t.textContent=v(e);return}g(t,.5,function(){var d=null;requestAnimationFrame(function x(l){d||(d=l);var w=Math.min((l-d)/i,1),q=1-Math.pow(1-w,3);t.textContent=v(n+(e-n)*q),w<1&&requestAnimationFrame(x)})})}),m||u.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=a(t,"speed",50),n=t.textContent,i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",o=0;g(t,.3,function(){var f=setInterval(function(){for(var s="",c=0;c<n.length;c++)c<o||n[c]===" "?s+=n[c]:s+=i[Math.floor(Math.random()*i.length)];t.textContent=s,o<n.length?o++:clearInterval(f)},e)})}),m||u.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=a(t,"intensity",5),n=a(t,"duration",500),i=r(t,"direction")||"horizontal",o=i==="vertical"?"translateY":"translateX";t.animate([{transform:o+"(0)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(0)"}],{duration:n,easing:"ease-in-out"})}),u.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",i=a(t,"duration",600),o=t.getBoundingClientRect(),f=Math.max(o.width,o.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-o.left-f/2)+"px;top:"+(e.clientY-o.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+i+"ms ease,opacity "+i+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},i)})});var p=window.Domma&&window.Domma.effects;p&&(u.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!m){var e={};r(t,"amplitude")&&(e.amplitude=a(t,"amplitude",6)),r(t,"duration")&&(e.duration=a(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=a(t,"stagger",0)),p.breathe(t,e)}}),u.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!m){var e={};r(t,"scale")&&(e.scale=a(t,"scale",1.05)),r(t,"duration")&&(e.duration=a(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),p.pulse(t,e)}}),u.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=a(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=a(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=y(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=y(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=a(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=y(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var i=t.textContent.trim();if(!i)return;t.textContent="",e.actions=[{render:i}]}p.scribe(t,e)}),u.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=a(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=a(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=a(t,"max-size",3)),p.twinkle(t,e)}),typeof p.tickerTape=="function"&&u.querySelectorAll(".dm-fx-ticker-tape").forEach(function(t){if(!m){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=a(t,"density",50)),r(t,"speed")&&(e.speed=a(t,"speed",1)),r(t,"sway")&&(e.sway=a(t,"sway",60)),r(t,"rotation-speed")&&(e.rotationSpeed=a(t,"rotation-speed",6)),r(t,"min-width")&&(e.minWidth=a(t,"min-width",5)),r(t,"max-width")&&(e.maxWidth=a(t,"max-width",9)),r(t,"min-height")&&(e.minHeight=a(t,"min-height",12)),r(t,"max-height")&&(e.maxHeight=a(t,"max-height",22)),r(t,"fade-start")&&(e.fadeStart=a(t,"fade-start",.55)),r(t,"burst")&&(e.burst=y(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=a(t,"burst-count",150)),r(t,"z-index")&&(e.zIndex=a(t,"z-index",1));var n=r(t,"mode");n==="page"?p.tickerTape(null,e):p.tickerTape(t,e)}}));var b=u.querySelectorAll(".dm-fx-celebrate");b.length&&!m&&import("/public/js/celebrations/index.js").then(function(t){var e=t.CelebrationsEffect;b.forEach(function(n){var i=n.getAttribute("data-fx-theme")||"auto",o=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(i==="auto"&&(i=e.getCurrentTheme(),!i))){var s={},c=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(d){d.name.startsWith("data-fx-")&&!c.includes(d.name)&&(s[d.name.slice(8)]=d.value)});var v=new e({theme:i,intensity:o,enabled:!0,zIndex:f,overrides:s});v.init().then(function(){v.start()})}})}).catch(function(){})}).catch(function(){})})();
@@ -22,7 +22,7 @@ const BUILTIN_SHORTCODES = new Set([
22
22
  'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card',
23
23
  'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
24
24
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
25
- 'animate', 'ambient', 'list-group',
25
+ 'ticker-tape', 'animate', 'ambient', 'list-group',
26
26
  ]);
27
27
 
28
28
  // Configure marked for safe output
@@ -763,7 +763,7 @@ export function scrubCodeRegions(markdown) {
763
763
  * @param {string} markdown
764
764
  * @returns {string}
765
765
  */
766
- function processPluginShortcodes(markdown) {
766
+ async function processPluginShortcodes(markdown) {
767
767
  const processors = getShortcodeProcessors();
768
768
  if (!processors.length) return markdown;
769
769
 
@@ -771,14 +771,34 @@ function processPluginShortcodes(markdown) {
771
771
  let result = scrubbed;
772
772
  const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
773
773
 
774
+ async function replaceAsync(input, regex, mapMatch) {
775
+ const matches = [];
776
+ input.replace(regex, (...args) => {
777
+ const offset = args[args.length - 2];
778
+ matches.push({ args: args.slice(0, -2), full: args[0], offset });
779
+ return args[0];
780
+ });
781
+ if (!matches.length) return input;
782
+ const out = await Promise.all(matches.map((m) => Promise.resolve(mapMatch(...m.args))));
783
+ let res = '';
784
+ let lastEnd = 0;
785
+ matches.forEach((m, i) => {
786
+ res += input.slice(lastEnd, m.offset) + out[i];
787
+ lastEnd = m.offset + m.full.length;
788
+ });
789
+ return res + input.slice(lastEnd);
790
+ }
791
+
774
792
  for (const {name, handler} of processors) {
775
793
  // Self-closing: [name attrs /]
776
- result = result.replace(
794
+ result = await replaceAsync(
795
+ result,
777
796
  new RegExp(`\\[${name}([^\\]]*)\\s*\\/\\]`, 'gi'),
778
797
  (_, attrStr) => handler(attrStr, null, context)
779
798
  );
780
799
  // Wrapping: [name attrs]...[/name]
781
- result = result.replace(
800
+ result = await replaceAsync(
801
+ result,
782
802
  new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
783
803
  (_, attrStr, body) => handler(attrStr, body, context)
784
804
  );
@@ -790,7 +810,7 @@ function processPluginShortcodes(markdown) {
790
810
  * Process built-in Effects shortcodes natively.
791
811
  * Handles: [counter /], [celebrate /], [firework], [fireworks], [scribe],
792
812
  * [reveal], [breathe], [pulse], [shake], [scramble], [ripple],
793
- * [twinkle], [animate], [ambient]
813
+ * [twinkle], [ticker-tape], [animate], [ambient]
794
814
  *
795
815
  * @param {string} markdown
796
816
  * @returns {string}
@@ -902,6 +922,20 @@ function processEffectsBlocks(markdown) {
902
922
  });
903
923
  }
904
924
 
925
+ // [ticker-tape /] → full-page overlay (mode=page)
926
+ // [ticker-tape] body [/ticker-tape] → container-scoped (mode=container)
927
+ apply('ticker-tape', (attrStr, body) => {
928
+ const attrs = parseShortcodeAttrs(attrStr);
929
+ const dataAttrs = Object.entries(attrs)
930
+ .map(([k, v]) => ` data-fx-${k}="${escapeAttr(String(v))}"`)
931
+ .join('');
932
+ if (body === null) {
933
+ return `<div class="dm-fx-ticker-tape" data-fx-mode="page"${dataAttrs}></div>`;
934
+ }
935
+ const innerHtml = marked.parse(processCardBlocks(processGridBlocks(body.trim())));
936
+ return `<div class="dm-fx-ticker-tape" data-fx-mode="container"${dataAttrs}>${innerHtml}</div>\n`;
937
+ });
938
+
905
939
  apply('animate', (attrStr, body) => {
906
940
  if (body === null) return '';
907
941
  const attrs = parseShortcodeAttrs(attrStr);
@@ -975,14 +1009,18 @@ function processGridBlocks(markdown) {
975
1009
  /\[col([^\]]*)\]([\s\S]*?)\[\/col\]/gi,
976
1010
  (_, attrStr, body) => {
977
1011
  const attrs = parseShortcodeAttrs(attrStr);
978
- const cls = attrs.span ? ` class="col-span-${attrs.span}"` : ' class="col"';
1012
+ const COL_INJECTABLE = ['reveal', 'breathe', 'animate', 'ripple'];
1013
+ const injected = extractInjectedEffects(attrs, COL_INJECTABLE);
1014
+ const baseCls = attrs.span ? `col-span-${attrs.span}` : 'col';
1015
+ const allClasses = [baseCls, ...injected.effectClasses].join(' ');
1016
+ const cls = ` class="${allClasses}"`;
979
1017
  const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
980
1018
  // Restore the col body before passing it downstream — otherwise the
981
1019
  // scrub placeholders from this processor's store get carried into
982
1020
  // processCardBlocks, which creates its own empty store and fails to
983
1021
  // decode them, producing the literal string "undefined" in place of
984
1022
  // whatever was in <pre>, ``` fences, or `inline code`.
985
- return `<div${cls}${id}>${marked.parse(processCardBlocks(restore(body.trim())))}</div>`;
1023
+ return `<div${cls}${id}${injected.effectDataAttrs}>${marked.parse(processCardBlocks(restore(body.trim())))}</div>`;
986
1024
  }
987
1025
  );
988
1026
 
@@ -1102,6 +1140,47 @@ export function escapeAttr(str) {
1102
1140
  .replace(/>/g, '&gt;');
1103
1141
  }
1104
1142
 
1143
+ /**
1144
+ * Extract injected-effect flag attributes from a parsed shortcode attrs object.
1145
+ *
1146
+ * For each effect in `allowed` whose flag attribute is present in `attrs`,
1147
+ * emit a `dm-fx-<effect>` class and convert all `<effect>-<attr>` keys to
1148
+ * `data-fx-<attr>="<escaped-value>"` fragments. Returns the consumed keys
1149
+ * so the host processor can skip them when emitting its own attributes.
1150
+ *
1151
+ * The flag attribute is presence-only (parseShortcodeAttrs stores bare flags
1152
+ * as `true`); only prefixed attributes carry a meaningful value.
1153
+ *
1154
+ * @param {Object<string,string>} attrs - parsed shortcode attribute map
1155
+ * @param {string[]} allowed - effect names this host accepts (e.g. ['reveal','pulse'])
1156
+ * @returns {{effectClasses: string[], effectDataAttrs: string, consumedKeys: Set<string>}}
1157
+ */
1158
+ export function extractInjectedEffects(attrs, allowed) {
1159
+ const effectClasses = [];
1160
+ const dataAttrParts = [];
1161
+ const consumedKeys = new Set();
1162
+
1163
+ for (const effect of allowed) {
1164
+ if (!(effect in attrs)) continue;
1165
+ effectClasses.push(`dm-fx-${effect}`);
1166
+ consumedKeys.add(effect);
1167
+
1168
+ const prefix = `${effect}-`;
1169
+ for (const key of Object.keys(attrs)) {
1170
+ if (!key.startsWith(prefix)) continue;
1171
+ const param = key.slice(prefix.length);
1172
+ dataAttrParts.push(` data-fx-${param}="${escapeAttr(String(attrs[key]))}"`);
1173
+ consumedKeys.add(key);
1174
+ }
1175
+ }
1176
+
1177
+ return {
1178
+ effectClasses,
1179
+ effectDataAttrs: dataAttrParts.join(''),
1180
+ consumedKeys,
1181
+ };
1182
+ }
1183
+
1105
1184
  // ---------------------------------------------------------------------------
1106
1185
  // Card shared helpers
1107
1186
  // ---------------------------------------------------------------------------
@@ -1141,6 +1220,10 @@ function cardVariantClasses(attrs) {
1141
1220
  return classes;
1142
1221
  }
1143
1222
 
1223
+ // Effects that may be injected as flag-attributes onto [card] hosts.
1224
+ // Used by both cardRoot (LAYOUT_RENDERERS path) and renderLegacyCard (fallback).
1225
+ const CARD_INJECTABLE = ['reveal', 'breathe', 'pulse', 'shake', 'twinkle', 'ambient', 'animate', 'ripple'];
1226
+
1144
1227
  /**
1145
1228
  * Builds the opening `<div>` tag for a card root element.
1146
1229
  *
@@ -1149,10 +1232,11 @@ function cardVariantClasses(attrs) {
1149
1232
  * @returns {string}
1150
1233
  */
1151
1234
  function cardRoot(attrs, extraClasses = []) {
1152
- const classes = [...cardVariantClasses(attrs), ...extraClasses];
1235
+ const injected = extractInjectedEffects(attrs, CARD_INJECTABLE);
1236
+ const classes = [...cardVariantClasses(attrs), ...extraClasses, ...injected.effectClasses];
1153
1237
  const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1154
1238
  const coll = attrs.collapsible === 'true' ? ' data-collapsible="true"' : '';
1155
- return `<div class="${classes.join(' ')}"${id}${coll}>`;
1239
+ return `<div class="${classes.join(' ')}"${id}${coll}${injected.effectDataAttrs}>`;
1156
1240
  }
1157
1241
 
1158
1242
  /**
@@ -1630,12 +1714,15 @@ function renderLegacyCard(attrs, body, markedInstance, escAttr) {
1630
1714
  const collapsible = attrs.collapsible === 'true';
1631
1715
  const variant = strAttr('variant');
1632
1716
 
1717
+ const injected = extractInjectedEffects(attrs, CARD_INJECTABLE);
1718
+
1633
1719
  // Root class list — delegate to the shared helper so variant="gradient",
1634
1720
  // gradient="<name>", glass/accent/dark/glow, font, shadow, rounded, etc.
1635
1721
  // all work on legacy cards (cards without a `layout` attribute).
1636
1722
  const classes = cardVariantClasses(attrs);
1637
1723
  // Preserve legacy support for variant="primary" (not handled by cardVariantClasses).
1638
1724
  if (variant === 'primary') classes.push('card-primary');
1725
+ classes.push(...injected.effectClasses);
1639
1726
 
1640
1727
  const id = attrs.id ? ` id="${escAttr(attrs.id)}"` : '';
1641
1728
  const coll = collapsible ? ' data-collapsible="true"' : '';
@@ -1692,7 +1779,7 @@ function renderLegacyCard(attrs, body, markedInstance, escAttr) {
1692
1779
  ? `<div class="card-footer">${markedInstance.parse(footerContent)}</div>`
1693
1780
  : footer ? `<div class="card-footer">${footer}</div>` : '';
1694
1781
 
1695
- return `<div class="${classes.join(' ')}"${coll}${id}>${headerHtml}${bodyHtml}${footerHtml}</div>\n`;
1782
+ return `<div class="${classes.join(' ')}"${coll}${id}${injected.effectDataAttrs}>${headerHtml}${bodyHtml}${footerHtml}</div>\n`;
1696
1783
  }
1697
1784
 
1698
1785
  // ---------------------------------------------------------------------------
@@ -2234,6 +2321,8 @@ function processButtonBlocks(markdown) {
2234
2321
 
2235
2322
  function buildButton(attrStr, inner) {
2236
2323
  const attrs = parseShortcodeAttrs(attrStr || '');
2324
+ const BUTTON_INJECTABLE = ['breathe', 'pulse', 'shake', 'animate', 'ripple'];
2325
+ const injected = extractInjectedEffects(attrs, BUTTON_INJECTABLE);
2237
2326
  const href = attrs.href || '#';
2238
2327
  const label = inner !== null ? inner.trim() : (attrs.label || '');
2239
2328
  const variant = VALID_VARIANTS.has(attrs.variant) ? attrs.variant : 'primary';
@@ -2241,11 +2330,12 @@ function processButtonBlocks(markdown) {
2241
2330
  if (attrs.size === 'sm') classes.push('btn-sm');
2242
2331
  if (attrs.size === 'lg') classes.push('btn-lg');
2243
2332
  if (attrs.class) classes.push(escapeAttr(attrs.class));
2333
+ classes.push(...injected.effectClasses);
2244
2334
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
2245
2335
  const targetAttr = attrs.target ? ` target="${escapeAttr(attrs.target)}"` : '';
2246
2336
  const icon = attrs.icon ? `<span data-icon="${escapeAttr(attrs.icon)}"></span> ` : '';
2247
2337
  const iconAfter = attrs['icon-after'] ? ` <span data-icon="${escapeAttr(attrs['icon-after'])}"></span>` : '';
2248
- return `<a href="${escapeAttr(href)}" class="${classes.join(' ')}"${idAttr}${targetAttr}>${icon}${label}${iconAfter}</a>`;
2338
+ return `<a href="${escapeAttr(href)}" class="${classes.join(' ')}"${idAttr}${targetAttr}${injected.effectDataAttrs}>${icon}${label}${iconAfter}</a>`;
2249
2339
  }
2250
2340
 
2251
2341
  let result = scrubbed.replace(/\[button([^\]]*?)\/\]/gi, (_, attrStr) => buildButton(attrStr, null));
@@ -2351,8 +2441,7 @@ function processTableBlocks(markdown) {
2351
2441
  * fullwidth - "true" breaks out of the page container to span the full viewport width (adds .hero-breakout).
2352
2442
  * Must be the literal string "true" — the attribute is otherwise ignored.
2353
2443
  * twinkle - Flag attribute — adds particle overlay (requires Effects plugin)
2354
- * twinkle-count - Number of twinkle particles
2355
- * twinkle-colour - Particle colour (CSS value)
2444
+ * twinkle-* - Any prefixed attribute is forwarded as data-fx-* (see extractInjectedEffects)
2356
2445
  * blobs - Flag attribute — adds ambient blob background
2357
2446
  * blobs-type - Blob animation type (default: "float-blobs")
2358
2447
  * class - Extra classes appended to .hero
@@ -2589,9 +2678,11 @@ function processHeroBlocks(markdown) {
2589
2678
  const heroColor = attrs.color ? String(attrs.color) : '';
2590
2679
  const minHeight = attrs['min-height'] ? String(attrs['min-height']) : '';
2591
2680
 
2592
- const twinkle = 'twinkle' in attrs;
2593
- const twinkleCount = attrs['twinkle-count'] || '';
2594
- const twinkleColour = attrs['twinkle-colour'] || '';
2681
+ const HERO_INJECTABLE = ['reveal', 'breathe', 'pulse', 'twinkle', 'ticker-tape', 'ambient', 'animate'];
2682
+ const injected = extractInjectedEffects(attrs, HERO_INJECTABLE);
2683
+
2684
+ // Legacy ad-hoc 'blobs' flag — keep for backward compat. Different keyspace
2685
+ // (bg-ambient-* CSS class), not folded into the generic helper.
2595
2686
  const blobs = 'blobs' in attrs;
2596
2687
  const blobsType = attrs['blobs-type'] || 'float-blobs';
2597
2688
 
@@ -2603,7 +2694,7 @@ function processHeroBlocks(markdown) {
2603
2694
  if (overlay) classes.push(`hero-overlay-${overlay}`);
2604
2695
  if (fullwidth) classes.push('hero-breakout');
2605
2696
  if (cls) classes.push(cls);
2606
- if (twinkle) classes.push('dm-fx-twinkle');
2697
+ classes.push(...injected.effectClasses);
2607
2698
  if (blobs) classes.push(`bg-ambient-${blobsType}`);
2608
2699
 
2609
2700
  const styleParts = [];
@@ -2616,11 +2707,6 @@ function processHeroBlocks(markdown) {
2616
2707
  const style = styleParts.length ? ` style="${styleParts.join(';')}"` : '';
2617
2708
  const idAttr = id ? ` id="${escapeAttr(id)}"` : '';
2618
2709
 
2619
- const twinkleAttrs = twinkle
2620
- ? (twinkleCount ? ` data-fx-count="${escapeAttr(twinkleCount)}"` : '') +
2621
- (twinkleColour ? ` data-fx-colour="${escapeAttr(twinkleColour)}"` : '')
2622
- : '';
2623
-
2624
2710
  // Button/link/cta must run before marked.parse so their quoted attributes
2625
2711
  // aren't HTML-escaped to &quot; — that mangles parseShortcodeAttrs and
2626
2712
  // turns `href="/x"` into a bare flag (attrs.href = true → href="true").
@@ -2634,7 +2720,7 @@ function processHeroBlocks(markdown) {
2634
2720
  if (processedBody) inner += `<div class="hero-body">${marked.parse(processedBody)}</div>`;
2635
2721
  inner += '</div>';
2636
2722
 
2637
- return `<div class="${classes.join(' ')}"${idAttr}${style}${twinkleAttrs}>${inner}</div>\n`;
2723
+ return `<div class="${classes.join(' ')}"${idAttr}${style}${injected.effectDataAttrs}>${inner}</div>\n`;
2638
2724
  }
2639
2725
  );
2640
2726
  return restore(result);
@@ -2678,6 +2764,8 @@ function processBannerBlocks(markdown) {
2678
2764
  /\[banner([^\]]*)\]([\s\S]*?)\[\/banner\]/gi,
2679
2765
  (_, attrStr, body) => {
2680
2766
  const attrs = parseShortcodeAttrs(attrStr);
2767
+ const BANNER_INJECTABLE = ['reveal', 'pulse', 'shake', 'ambient', 'animate'];
2768
+ const injected = extractInjectedEffects(attrs, BANNER_INJECTABLE);
2681
2769
  const VALID_BANNER_TYPES = new Set(['info', 'success', 'warning', 'danger', 'neutral']);
2682
2770
  const type = VALID_BANNER_TYPES.has(attrs.type) ? attrs.type : 'info';
2683
2771
  const title = attrs.title || '';
@@ -2687,6 +2775,7 @@ function processBannerBlocks(markdown) {
2687
2775
 
2688
2776
  const classes = [`dm-banner`, `dm-banner--${type}`];
2689
2777
  if (extraClass) classes.push(extraClass);
2778
+ classes.push(...injected.effectClasses);
2690
2779
 
2691
2780
  const iconHtml = icon
2692
2781
  ? `<span class="dm-banner__icon" data-icon="${escapeAttr(icon)}"></span>\n `
@@ -2700,7 +2789,7 @@ function processBannerBlocks(markdown) {
2700
2789
  : '';
2701
2790
 
2702
2791
  return (
2703
- `<div class="${classes.join(' ')}">\n` +
2792
+ `<div class="${classes.join(' ')}"${injected.effectDataAttrs}>\n` +
2704
2793
  ` ${iconHtml}<div class="dm-banner__body">\n` +
2705
2794
  ` ${titleHtml}${bodyHtml}` +
2706
2795
  ` </div>${dismissHtml}\n` +
@@ -2832,7 +2921,7 @@ export async function parseMarkdown(raw) {
2832
2921
  const withStaticBlock = await processStaticBlocks(withView);
2833
2922
  const withDconfig = processDConfigBlocks(withStaticBlock);
2834
2923
  const withEffects = processEffectsBlocks(withDconfig);
2835
- const withPluginShortcodes = processPluginShortcodes(withEffects);
2924
+ const withPluginShortcodes = await processPluginShortcodes(withEffects);
2836
2925
  const withTabs = processTabsBlocks(withPluginShortcodes);
2837
2926
  const withAccordion = processAccordionBlocks(withTabs);
2838
2927
  const withCarousel = processCarouselBlocks(withAccordion);
@@ -312,11 +312,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
312
312
  const seoDescription = escapeHtml(seoMeta.description ?? site.seo?.defaultDescription ?? '');
313
313
  const ogImage = escapeHtml(seoMeta.ogImage ?? '');
314
314
 
315
+ const ogType = escapeHtml(seoMeta.ogType ?? 'website');
315
316
  const ogTags = [
316
317
  `<meta property="og:title" content="${seoTitle}">`,
317
318
  `<meta property="og:description" content="${seoDescription}">`,
318
- `<meta property="og:image" content="${ogImage}">`
319
- ].join('\n');
319
+ `<meta property="og:type" content="${ogType}">`,
320
+ ogImage ? `<meta property="og:image" content="${ogImage}">` : '',
321
+ `<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`,
322
+ `<meta name="twitter:title" content="${seoTitle}">`,
323
+ `<meta name="twitter:description" content="${seoDescription}">`,
324
+ ogImage ? `<meta name="twitter:image" content="${ogImage}">` : ''
325
+ ].filter(Boolean).join('\n');
320
326
 
321
327
  const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
322
328
  const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
@@ -363,7 +369,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
363
369
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
364
370
  bodyEndInject: [
365
371
  injection.bodyEnd,
366
- (page.usedComponents || [])
372
+ (seoMeta.usedComponents || [])
367
373
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
368
374
  .join('\n')
369
375
  ].filter(Boolean).join('\n'),