@scalemule/news-publication 0.0.7

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/cookie-consent.css +419 -0
  4. package/dist/chunk-C7JTEHTH.js +158 -0
  5. package/dist/chunk-GB36YM56.js +72 -0
  6. package/dist/chunk-PYCU2T5O.js +93 -0
  7. package/dist/chunk-R33MJWND.js +261 -0
  8. package/dist/chunk-TJ7RL3PV.js +252 -0
  9. package/dist/chunk-YQXS2KQI.js +112 -0
  10. package/dist/cookie-consent-banner.cjs +210 -0
  11. package/dist/cookie-consent-banner.d.cts +19 -0
  12. package/dist/cookie-consent-banner.d.ts +19 -0
  13. package/dist/cookie-consent-banner.js +3 -0
  14. package/dist/cookie-settings-view.cjs +470 -0
  15. package/dist/cookie-settings-view.d.cts +15 -0
  16. package/dist/cookie-settings-view.d.ts +15 -0
  17. package/dist/cookie-settings-view.js +3 -0
  18. package/dist/index.cjs +1460 -0
  19. package/dist/index.d.cts +317 -0
  20. package/dist/index.d.ts +317 -0
  21. package/dist/index.js +441 -0
  22. package/dist/marketplace-Ch7mdJZv.d.cts +123 -0
  23. package/dist/marketplace-Ch7mdJZv.d.ts +123 -0
  24. package/dist/marketplace-forms.cjs +134 -0
  25. package/dist/marketplace-forms.d.cts +10 -0
  26. package/dist/marketplace-forms.d.ts +10 -0
  27. package/dist/marketplace-forms.js +115 -0
  28. package/dist/marketplace-view.cjs +110 -0
  29. package/dist/marketplace-view.d.cts +21 -0
  30. package/dist/marketplace-view.d.ts +21 -0
  31. package/dist/marketplace-view.js +85 -0
  32. package/dist/meet-5Ym5bxfh.d.cts +79 -0
  33. package/dist/meet-5Ym5bxfh.d.ts +79 -0
  34. package/dist/meet-forms.cjs +188 -0
  35. package/dist/meet-forms.d.cts +13 -0
  36. package/dist/meet-forms.d.ts +13 -0
  37. package/dist/meet-forms.js +166 -0
  38. package/dist/meet-view.cjs +82 -0
  39. package/dist/meet-view.d.cts +23 -0
  40. package/dist/meet-view.d.ts +23 -0
  41. package/dist/meet-view.js +59 -0
  42. package/dist/snapshot-BS4hn1dk.d.cts +234 -0
  43. package/dist/snapshot-BS4hn1dk.d.ts +234 -0
  44. package/dist/snapshot-file.cjs +192 -0
  45. package/dist/snapshot-file.d.cts +21 -0
  46. package/dist/snapshot-file.d.ts +21 -0
  47. package/dist/snapshot-file.js +38 -0
  48. package/marketplace.css +61 -0
  49. package/package.json +103 -0
  50. package/tokens/broadsheet.css +61 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScaleMule Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @scalemule/news-publication
2
+
3
+ Server framework for ScaleMule local news sites. Walnut Creek Times is the reference: broadsheet layout, the 30s/60s news cache, on-demand revalidation, front-page composition, article structured data, and the analytics event contract.
4
+
5
+ Each paper stays its own site, domain, and ScaleMule application. The package holds the shared behavior. The paper passes its name, towns, theme, layout, and feature flags.
6
+
7
+ ```ts
8
+ import { definePublication, walnutCreekTimes } from "@scalemule/news-publication";
9
+
10
+ export const publication = walnutCreekTimes;
11
+
12
+ export const napaGate = definePublication({
13
+ slug: "napagate",
14
+ name: "Napa Gate",
15
+ masthead: ["Napa", "Gate"],
16
+ region: "Napa Valley",
17
+ regionLabel: "Napa Valley",
18
+ towns: ["Napa", "Yountville", "St. Helena"],
19
+ description: "Local stories from Napa Valley.",
20
+ layout: "napagate",
21
+ layouts: ["napagate", "broadsheet"],
22
+ theme: { accent: "#6b2d5b" },
23
+ features: { referrals: true, coupons: true },
24
+ });
25
+ ```
26
+
27
+ `resolveLayout(publication, cookie)` returns the paper default for crawlers and readers with no cookie. A reader preference is kept only when that paper listed the layout in `layouts`. The cookie name is `news_publication_layout`.
28
+
29
+ ## Config sync
30
+
31
+ The application record is the source of truth. The site stores the last accepted copy in `publication.snapshot.json` and ships that file with the deployment. `syncPublicationFile` from `@scalemule/news-publication/snapshot-file` writes a new file only after a complete record for that paper arrives. The main package entry stays free of Node file APIs so client components can import it. A timeout, a 500, a truncated body, or another paper's payload leaves the file untouched.
32
+
33
+ That snapshot does not expire. A paper whose application API is unreachable for several days, including across restarts, keeps serving the last synced name, towns, theme, and flags. The result reports `source: "retained"` and how old the snapshot is, so the outage can be logged. The only paper that cannot render is one that has never synced.
34
+
35
+ Code hooks such as `matchTag` are attached in the site after the snapshot loads. They are not part of the stored file.
36
+
37
+ ## What a paper turns on
38
+
39
+ | Flag | Default | Meaning |
40
+ |---|---|---|
41
+ | `smile` | off, on for Walnut Creek Times | Mount the paper's Daily Smile module |
42
+ | `comments` | off | Article discussion thread |
43
+ | `chat` | off | Town room, same discussions service |
44
+ | `jobs`, `classifieds` | off | Boards for that paper |
45
+ | `referrals` | off | Read a `ref` code (or `referrals.queryParam`) into analytics |
46
+ | `coupons` | off | Resolve `coupons.offers` or a paper-supplied `coupons.resolve` |
47
+
48
+ Referrals and coupons do not call Billing. `resolveCoupon` returns the offer the paper configured, or null when the flag is off. The checkout call stays in the site, using that paper's API key.
49
+
50
+ `matchTag` replaces the generic tag matcher when a paper has its own neighborhood rules. `modules.home` and `modules.articleEnd` name site-owned blocks the paper renders.
51
+
52
+ ## Cache
53
+
54
+ `NEWS_CACHE` is the policy verified on walnutcreektimes.com.
55
+
56
+ - Feeds revalidate every 30 seconds (`feedCacheOptions`).
57
+ - Articles revalidate every 60 seconds (`articleCacheOptions`).
58
+ - `edgeCacheControl()` is `public, s-maxage=60, stale-while-revalidate=300`.
59
+ - `withLastKnown` returns the last good feed or article when the gateway fails, and still throws on a 404.
60
+ - `revalidateDecision` is the authorization and response body for `POST /api/revalidate`. The route then purges `defaultRevalidateTargets()`.
61
+
62
+ ## Search
63
+
64
+ `newsArticleJsonLd`, `publicationJsonLd`, `publicationMeta`, and `articleMeta` are the fields the paper puts on its pages. `llmsText` is the body for `/llms.txt`. Preview hosts pass `indexable: false`.
65
+
66
+ ## Type
67
+
68
+ Import `@scalemule/news-publication/tokens.css` on the publication shell. Headlines use `--font-editorial` at every width, and navigation uses `--font-inter`. The site loads those faces. Override `--pub-accent` or `--pub-serif` on `.publication` for a paper that should not match the broadsheet palette.
69
+
70
+ The React front page still lives in the Walnut Creek Times repository. This package is the server contract those components move onto, so a font, cache, or schema fix ships once and each paper picks it up by version.
71
+
72
+ ## Versioning
73
+
74
+ Patch only (`0.0.x` or `0.1.x`) until a stable release is explicitly approved. Publishing is manual: `npm publish` from this directory.
@@ -0,0 +1,419 @@
1
+ /* ScaleMule News Publication - Tesla-Style Cookie Consent & Settings */
2
+
3
+ /* ==========================================================================
4
+ Cookie Banner (Floating Bottom Card)
5
+ ========================================================================== */
6
+
7
+ .sm-cookie-banner-wrapper {
8
+ position: fixed;
9
+ bottom: 1.25rem;
10
+ left: 1rem;
11
+ right: 1rem;
12
+ z-index: 9999;
13
+ display: flex;
14
+ justify-content: center;
15
+ pointer-events: none;
16
+ animation: sm-cookie-slide-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
17
+ }
18
+
19
+ @keyframes sm-cookie-slide-up {
20
+ from {
21
+ opacity: 0;
22
+ transform: translateY(1.5rem);
23
+ }
24
+ to {
25
+ opacity: 1;
26
+ transform: translateY(0);
27
+ }
28
+ }
29
+
30
+ .sm-cookie-banner-card {
31
+ pointer-events: auto;
32
+ max-width: 1100px;
33
+ width: 100%;
34
+ background: rgba(255, 255, 255, 0.96);
35
+ backdrop-filter: blur(16px);
36
+ -webkit-backdrop-filter: blur(16px);
37
+ border: 1px solid rgba(0, 0, 0, 0.1);
38
+ box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
39
+ border-radius: 14px;
40
+ padding: 1.5rem 1.75rem;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: space-between;
44
+ gap: 2rem;
45
+ box-sizing: border-box;
46
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
47
+ color: #171a20;
48
+ }
49
+
50
+ .sm-cookie-banner-content {
51
+ flex: 1 1 auto;
52
+ }
53
+
54
+ .sm-cookie-banner-title {
55
+ margin: 0 0 0.4rem 0;
56
+ font-size: 1.15rem;
57
+ font-weight: 600;
58
+ letter-spacing: -0.015em;
59
+ color: #11141a;
60
+ line-height: 1.3;
61
+ }
62
+
63
+ .sm-cookie-banner-text {
64
+ margin: 0;
65
+ font-size: 0.9rem;
66
+ line-height: 1.45;
67
+ color: #393c41;
68
+ }
69
+
70
+ .sm-cookie-banner-link {
71
+ color: #171a20;
72
+ text-decoration: underline;
73
+ text-underline-offset: 3px;
74
+ font-weight: 500;
75
+ transition: opacity 0.15s ease;
76
+ cursor: pointer;
77
+ }
78
+
79
+ .sm-cookie-banner-link:hover {
80
+ opacity: 0.75;
81
+ }
82
+
83
+ .sm-cookie-banner-actions {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 0.75rem;
87
+ flex-shrink: 0;
88
+ }
89
+
90
+ .sm-cookie-btn {
91
+ display: inline-flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ padding: 0.65rem 1.5rem;
95
+ font-size: 0.88rem;
96
+ font-weight: 600;
97
+ border-radius: 8px;
98
+ cursor: pointer;
99
+ transition: all 0.18s ease;
100
+ text-decoration: none;
101
+ border: 1px solid transparent;
102
+ white-space: nowrap;
103
+ user-select: none;
104
+ outline: none;
105
+ }
106
+
107
+ .sm-cookie-btn:focus-visible {
108
+ box-shadow: 0 0 0 3px rgba(30, 90, 220, 0.4);
109
+ }
110
+
111
+ .sm-cookie-btn-primary {
112
+ background: #171a20;
113
+ color: #ffffff;
114
+ border-color: #171a20;
115
+ }
116
+
117
+ .sm-cookie-btn-primary:hover {
118
+ background: #2b303a;
119
+ border-color: #2b303a;
120
+ }
121
+
122
+ .sm-cookie-btn-secondary {
123
+ background: transparent;
124
+ color: #171a20;
125
+ border-color: #d0d1d2;
126
+ }
127
+
128
+ .sm-cookie-btn-secondary:hover {
129
+ background: rgba(0, 0, 0, 0.04);
130
+ border-color: #171a20;
131
+ }
132
+
133
+ @media (max-width: 820px) {
134
+ .sm-cookie-banner-card {
135
+ flex-direction: column;
136
+ align-items: stretch;
137
+ gap: 1.25rem;
138
+ padding: 1.25rem;
139
+ }
140
+
141
+ .sm-cookie-banner-actions {
142
+ justify-content: flex-end;
143
+ }
144
+
145
+ .sm-cookie-btn {
146
+ flex: 1 1 auto;
147
+ }
148
+ }
149
+
150
+ /* ==========================================================================
151
+ Cookie Settings Page (Full Privacy Controls)
152
+ ========================================================================== */
153
+
154
+ .sm-cookie-settings-container {
155
+ max-width: 900px;
156
+ margin: 0 auto;
157
+ padding: 2.5rem 1.5rem 5rem 1.5rem;
158
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
159
+ color: #171a20;
160
+ }
161
+
162
+ .sm-cookie-settings-header {
163
+ margin-bottom: 2.5rem;
164
+ }
165
+
166
+ .sm-cookie-settings-eyebrow {
167
+ font-size: 0.85rem;
168
+ font-weight: 600;
169
+ letter-spacing: 0.08em;
170
+ text-transform: uppercase;
171
+ color: #5c5e62;
172
+ margin-bottom: 0.5rem;
173
+ }
174
+
175
+ .sm-cookie-settings-title {
176
+ font-size: 2.25rem;
177
+ font-weight: 700;
178
+ letter-spacing: -0.025em;
179
+ margin: 0 0 1rem 0;
180
+ line-height: 1.15;
181
+ color: #11141a;
182
+ }
183
+
184
+ .sm-cookie-settings-intro {
185
+ font-size: 1.05rem;
186
+ line-height: 1.6;
187
+ color: #393c41;
188
+ margin: 0;
189
+ }
190
+
191
+ /* Alert Notification Toast */
192
+ .sm-cookie-alert-success {
193
+ background: #eef8f1;
194
+ border: 1px solid #c2e9cd;
195
+ color: #166434;
196
+ padding: 1rem 1.25rem;
197
+ border-radius: 10px;
198
+ font-size: 0.95rem;
199
+ margin-bottom: 2rem;
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 0.75rem;
203
+ animation: sm-cookie-fade-in 0.25s ease-out;
204
+ }
205
+
206
+ @keyframes sm-cookie-fade-in {
207
+ from { opacity: 0; transform: translateY(-6px); }
208
+ to { opacity: 1; transform: translateY(0); }
209
+ }
210
+
211
+ /* Global Actions Bar (Top) */
212
+ .sm-cookie-global-actions {
213
+ display: flex;
214
+ flex-wrap: wrap;
215
+ align-items: center;
216
+ justify-content: flex-end;
217
+ gap: 0.75rem;
218
+ margin-bottom: 2rem;
219
+ padding-bottom: 1.25rem;
220
+ border-bottom: 1px solid #e2e4e6;
221
+ }
222
+
223
+ /* Category Cards */
224
+ .sm-cookie-category-list {
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 1.5rem;
228
+ margin-bottom: 3rem;
229
+ }
230
+
231
+ .sm-cookie-category-card {
232
+ background: #ffffff;
233
+ border: 1px solid #e2e4e6;
234
+ border-radius: 12px;
235
+ padding: 1.5rem;
236
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
237
+ }
238
+
239
+ .sm-cookie-category-card:hover {
240
+ border-color: #cbcfd4;
241
+ }
242
+
243
+ .sm-cookie-category-top {
244
+ display: flex;
245
+ align-items: flex-start;
246
+ justify-content: space-between;
247
+ gap: 1.5rem;
248
+ margin-bottom: 0.75rem;
249
+ }
250
+
251
+ .sm-cookie-category-heading {
252
+ margin: 0;
253
+ font-size: 1.25rem;
254
+ font-weight: 600;
255
+ color: #171a20;
256
+ display: flex;
257
+ align-items: center;
258
+ gap: 0.5rem;
259
+ }
260
+
261
+ .sm-cookie-category-desc {
262
+ font-size: 0.95rem;
263
+ line-height: 1.55;
264
+ color: #4b4f56;
265
+ margin: 0 0 1rem 0;
266
+ }
267
+
268
+ .sm-cookie-badge-required {
269
+ background: #f0f1f3;
270
+ color: #393c41;
271
+ font-size: 0.78rem;
272
+ font-weight: 600;
273
+ text-transform: uppercase;
274
+ letter-spacing: 0.05em;
275
+ padding: 0.35rem 0.65rem;
276
+ border-radius: 6px;
277
+ white-space: nowrap;
278
+ }
279
+
280
+ /* iOS / Tesla style Toggle Switch */
281
+ .sm-cookie-switch-label {
282
+ position: relative;
283
+ display: inline-block;
284
+ width: 48px;
285
+ height: 28px;
286
+ flex-shrink: 0;
287
+ cursor: pointer;
288
+ }
289
+
290
+ .sm-cookie-switch-input {
291
+ opacity: 0;
292
+ width: 0;
293
+ height: 0;
294
+ }
295
+
296
+ .sm-cookie-switch-slider {
297
+ position: absolute;
298
+ cursor: pointer;
299
+ top: 0;
300
+ left: 0;
301
+ right: 0;
302
+ bottom: 0;
303
+ background-color: #d1d5db;
304
+ transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
305
+ border-radius: 28px;
306
+ }
307
+
308
+ .sm-cookie-switch-slider:before {
309
+ position: absolute;
310
+ content: "";
311
+ height: 22px;
312
+ width: 22px;
313
+ left: 3px;
314
+ bottom: 3px;
315
+ background-color: white;
316
+ transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
317
+ border-radius: 50%;
318
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
319
+ }
320
+
321
+ .sm-cookie-switch-input:checked + .sm-cookie-switch-slider {
322
+ background-color: #171a20;
323
+ }
324
+
325
+ .sm-cookie-switch-input:focus-visible + .sm-cookie-switch-slider {
326
+ box-shadow: 0 0 0 3px rgba(30, 90, 220, 0.35);
327
+ }
328
+
329
+ .sm-cookie-switch-input:checked + .sm-cookie-switch-slider:before {
330
+ transform: translateX(20px);
331
+ }
332
+
333
+ .sm-cookie-switch-input:disabled + .sm-cookie-switch-slider {
334
+ opacity: 0.6;
335
+ cursor: not-allowed;
336
+ }
337
+
338
+ /* Disclosure Table */
339
+ .sm-cookie-details-toggle {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 0.35rem;
343
+ background: none;
344
+ border: none;
345
+ padding: 0;
346
+ font-size: 0.88rem;
347
+ font-weight: 500;
348
+ color: #1e5adc;
349
+ cursor: pointer;
350
+ text-decoration: none;
351
+ }
352
+
353
+ .sm-cookie-details-toggle:hover {
354
+ text-decoration: underline;
355
+ }
356
+
357
+ .sm-cookie-table-wrapper {
358
+ margin-top: 1rem;
359
+ overflow-x: auto;
360
+ border: 1px solid #eaedf0;
361
+ border-radius: 8px;
362
+ }
363
+
364
+ .sm-cookie-table {
365
+ width: 100%;
366
+ border-collapse: collapse;
367
+ font-size: 0.85rem;
368
+ text-align: left;
369
+ }
370
+
371
+ .sm-cookie-table th {
372
+ background: #f7f8f9;
373
+ padding: 0.65rem 0.9rem;
374
+ font-weight: 600;
375
+ color: #5c5e62;
376
+ border-bottom: 1px solid #eaedf0;
377
+ }
378
+
379
+ .sm-cookie-table td {
380
+ padding: 0.65rem 0.9rem;
381
+ border-bottom: 1px solid #eaedf0;
382
+ color: #2b303a;
383
+ vertical-align: top;
384
+ }
385
+
386
+ .sm-cookie-table tr:last-child td {
387
+ border-bottom: none;
388
+ }
389
+
390
+ .sm-cookie-code {
391
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
392
+ font-size: 0.8rem;
393
+ background: #f0f2f5;
394
+ padding: 0.15rem 0.35rem;
395
+ border-radius: 4px;
396
+ color: #11141a;
397
+ }
398
+
399
+ /* Bottom Save Sticky Bar */
400
+ .sm-cookie-save-bar {
401
+ display: flex;
402
+ align-items: center;
403
+ justify-content: space-between;
404
+ flex-wrap: wrap;
405
+ gap: 1rem;
406
+ padding: 1.5rem 0;
407
+ border-top: 1px solid #e2e4e6;
408
+ }
409
+
410
+ .sm-cookie-save-status {
411
+ font-size: 0.88rem;
412
+ color: #5c5e62;
413
+ }
414
+
415
+ .sm-cookie-save-buttons {
416
+ display: flex;
417
+ align-items: center;
418
+ gap: 0.75rem;
419
+ }
@@ -0,0 +1,158 @@
1
+ // src/types.ts
2
+ var FEATURE_NAMES = [
3
+ "smile",
4
+ "comments",
5
+ "chat",
6
+ "jobs",
7
+ "classifieds",
8
+ "referrals",
9
+ "coupons"
10
+ ];
11
+
12
+ // src/config.ts
13
+ var BROADSHEET_THEME = {
14
+ accent: "#bb171d",
15
+ ink: "#171917",
16
+ paper: "#ffffff",
17
+ sponsor: "#173d32"
18
+ };
19
+ var DISABLED_FEATURES = {
20
+ smile: false,
21
+ comments: false,
22
+ chat: false,
23
+ jobs: false,
24
+ classifieds: false,
25
+ referrals: false,
26
+ coupons: false
27
+ };
28
+ function featureEnabled(publication, feature) {
29
+ return publication.features[feature] === true;
30
+ }
31
+ function definePublication(input) {
32
+ if (!input.slug.trim() || !input.name.trim()) throw new Error("A publication needs a slug and a name");
33
+ if (!input.towns.length) throw new Error("A publication needs at least one covered town");
34
+ const layout = input.layout ?? "broadsheet";
35
+ const layouts = [.../* @__PURE__ */ new Set([layout, ...input.layouts ?? ["broadsheet"]])];
36
+ const referralsEnabled = input.features?.referrals === true || input.referrals?.enabled === true;
37
+ const couponsEnabled = input.features?.coupons === true || input.coupons?.enabled === true;
38
+ const features = { ...DISABLED_FEATURES, ...input.features, referrals: referralsEnabled, coupons: couponsEnabled };
39
+ for (const name of FEATURE_NAMES) features[name] = features[name] === true;
40
+ return {
41
+ slug: input.slug,
42
+ name: input.name,
43
+ masthead: input.masthead.length ? input.masthead : [input.name],
44
+ region: input.region,
45
+ regionLabel: input.regionLabel,
46
+ towns: input.towns,
47
+ nearbyTowns: input.nearbyTowns ?? [],
48
+ sections: input.sections ?? [],
49
+ description: input.description,
50
+ tagline: input.tagline ?? "Local stories. Close to home.",
51
+ briefTitle: input.briefTitle ?? `${input.regionLabel} in Brief`,
52
+ parent: input.parent === true,
53
+ layout,
54
+ layouts,
55
+ features,
56
+ theme: { ...BROADSHEET_THEME, ...input.theme },
57
+ referrals: { queryParam: input.referrals?.queryParam?.trim() || "ref", enabled: referralsEnabled },
58
+ coupons: { enabled: couponsEnabled, offers: input.coupons?.offers ?? [], resolve: input.coupons?.resolve },
59
+ modules: input.modules ?? {},
60
+ matchTag: input.matchTag
61
+ };
62
+ }
63
+ var LAYOUT_COOKIE = "news_publication_layout";
64
+ function resolveLayout(publication, cookieValue) {
65
+ const requested = cookieValue?.trim();
66
+ if (requested && publication.layouts.includes(requested)) return requested;
67
+ return publication.layout;
68
+ }
69
+
70
+ // src/snapshot.ts
71
+ var SNAPSHOT_VERSION = 1;
72
+ function publicationRecord(publication) {
73
+ return {
74
+ slug: publication.slug,
75
+ name: publication.name,
76
+ masthead: publication.masthead,
77
+ region: publication.region,
78
+ regionLabel: publication.regionLabel,
79
+ towns: publication.towns,
80
+ nearbyTowns: publication.nearbyTowns,
81
+ sections: publication.sections,
82
+ description: publication.description,
83
+ tagline: publication.tagline,
84
+ briefTitle: publication.briefTitle,
85
+ parent: publication.parent,
86
+ layout: publication.layout,
87
+ layouts: publication.layouts,
88
+ features: publication.features,
89
+ theme: publication.theme,
90
+ referrals: publication.referrals,
91
+ coupons: { enabled: publication.coupons.enabled, offers: publication.coupons.offers },
92
+ modules: publication.modules
93
+ };
94
+ }
95
+ function snapshotFromRecord(record, syncedAt = (/* @__PURE__ */ new Date()).toISOString()) {
96
+ const publication = definePublication(record);
97
+ return { version: SNAPSHOT_VERSION, syncedAt, record: publicationRecord(publication) };
98
+ }
99
+ function parseSnapshot(raw) {
100
+ try {
101
+ return acceptSnapshot(JSON.parse(raw));
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ function acceptSnapshot(value) {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
108
+ const snapshot = value;
109
+ if (snapshot.version !== SNAPSHOT_VERSION || typeof snapshot.syncedAt !== "string") return null;
110
+ if (!Number.isFinite(Date.parse(snapshot.syncedAt))) return null;
111
+ try {
112
+ return snapshotFromRecord(snapshot.record, snapshot.syncedAt);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ function acceptRemoteRecord(value, expectedSlug) {
118
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
119
+ const body = value;
120
+ const candidate = body.record ?? body.publication ?? value;
121
+ try {
122
+ const publication = definePublication(candidate);
123
+ if (expectedSlug && publication.slug !== expectedSlug) return null;
124
+ return publicationRecord(publication);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ function publicationFromSnapshot(snapshot, hooks = {}) {
130
+ return definePublication({
131
+ ...snapshot.record,
132
+ matchTag: hooks.matchTag,
133
+ coupons: { ...snapshot.record.coupons, resolve: hooks.resolveCoupon }
134
+ });
135
+ }
136
+ async function syncPublication(input) {
137
+ const now = input.now ?? Date.now();
138
+ try {
139
+ const remote = acceptRemoteRecord(await input.fetchRecord(), input.expectedSlug);
140
+ if (remote) {
141
+ const snapshot = snapshotFromRecord(remote, new Date(now).toISOString());
142
+ return { publication: publicationFromSnapshot(snapshot, input.hooks), snapshot, source: "fresh", ageMs: 0 };
143
+ }
144
+ } catch {
145
+ }
146
+ if (!input.previous) {
147
+ throw new Error("This paper has no synced publication config");
148
+ }
149
+ const ageMs = Math.max(0, now - Date.parse(input.previous.syncedAt));
150
+ return {
151
+ publication: publicationFromSnapshot(input.previous, input.hooks),
152
+ snapshot: input.previous,
153
+ source: "retained",
154
+ ageMs
155
+ };
156
+ }
157
+
158
+ export { BROADSHEET_THEME, FEATURE_NAMES, LAYOUT_COOKIE, acceptRemoteRecord, acceptSnapshot, definePublication, featureEnabled, parseSnapshot, publicationFromSnapshot, publicationRecord, resolveLayout, snapshotFromRecord, syncPublication };