@scalemule/news-publication 0.0.35 → 0.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -128,3 +128,28 @@ Optional questions can be browsed and copied before identity verification.
128
128
  reference and optional answers, never the invitation credential. Configure a
129
129
  monitored mailbox; received email must be recorded by the newsroom. Online
130
130
  answers retain their existing autosave and explicit-send behavior.
131
+
132
+ ## Community Maps
133
+
134
+ The `community-maps-server` subpath exports `createPublicationCommunityMapsHandler`.
135
+ Supply a resolver that verifies the publication host using your existing site lookup,
136
+ then returns its server API key, gateway origin, canonical public origin, and trusted
137
+ reader context. Forward only the public allowlisted catch-all route. The handler
138
+ checks POST origins, bounds uploads, signs an HttpOnly reader cookie, and applies
139
+ no-store responses. It never proxies editorial routes or accepts a caller-selected
140
+ application ID. Use a Node server runtime; this module imports node:crypto.
141
+
142
+ The `community-maps-view` subpath exports `PublicationCommunityMapPage` and
143
+ `CommunityMapArticleEmbeds`. Supply the publication chrome and import the React map CSS.
144
+ The optional React peer is required only when consuming these views. Use the map
145
+ worker asset CLI from `@scalemule/react` before building the site.
146
+
147
+ Article integration uses the existing Blog metadata extension:
148
+
149
+ ```json
150
+ {"community_maps":[{"collection_slug":"halloween-2026","mode":"map"}]}
151
+ ```
152
+
153
+ Other modes are `cards` and `features`; optional `area_id` / `feature_ids` select live
154
+ records. No copied feature snapshot is embedded in a story, so withdrawal takes effect
155
+ on the next read. Publishers should apply their caption policy to public media copy.
@@ -0,0 +1,23 @@
1
+ // src/community-maps.ts
2
+ function communityMapEmbeds(metadata) {
3
+ if (!metadata || typeof metadata !== "object") return [];
4
+ const value = metadata.community_maps;
5
+ if (!Array.isArray(value)) return [];
6
+ return value.slice(0, 5).flatMap((entry) => {
7
+ if (!entry || typeof entry !== "object" || typeof entry.collection_slug !== "string" || !/^[a-z0-9-]{1,191}$/.test(entry.collection_slug) || !["map", "cards", "features"].includes(entry.mode))
8
+ return [];
9
+ const ids = Array.isArray(entry.feature_ids) ? entry.feature_ids.filter(
10
+ (id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
11
+ ).slice(0, 20) : void 0;
12
+ return [
13
+ {
14
+ collection_slug: entry.collection_slug,
15
+ mode: entry.mode,
16
+ feature_ids: ids,
17
+ area_id: typeof entry.area_id === "string" && /^[0-9a-f-]{36}$/i.test(entry.area_id) ? entry.area_id : void 0
18
+ }
19
+ ];
20
+ });
21
+ }
22
+
23
+ export { communityMapEmbeds };
@@ -0,0 +1,10 @@
1
+ interface CommunityMapArticleEmbed {
2
+ collection_slug: string;
3
+ feature_ids?: string[];
4
+ area_id?: string;
5
+ mode: "map" | "cards" | "features";
6
+ }
7
+ /** Article metadata extension: references only; records remain in Community Maps. */
8
+ declare function communityMapEmbeds(metadata: unknown): CommunityMapArticleEmbed[];
9
+
10
+ export { type CommunityMapArticleEmbed as C, communityMapEmbeds as c };
@@ -0,0 +1,10 @@
1
+ interface CommunityMapArticleEmbed {
2
+ collection_slug: string;
3
+ feature_ids?: string[];
4
+ area_id?: string;
5
+ mode: "map" | "cards" | "features";
6
+ }
7
+ /** Article metadata extension: references only; records remain in Community Maps. */
8
+ declare function communityMapEmbeds(metadata: unknown): CommunityMapArticleEmbed[];
9
+
10
+ export { type CommunityMapArticleEmbed as C, communityMapEmbeds as c };
@@ -0,0 +1,250 @@
1
+ 'use strict';
2
+
3
+ var crypto = require('crypto');
4
+ var sdk = require('@scalemule/sdk');
5
+
6
+ // src/community-maps-server.ts
7
+ var ROOT = "/v1/knowledge/community-maps";
8
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
9
+ var COOKIE = "sm_community_reader";
10
+ var json = (data, status = 200) => Response.json(data, {
11
+ status,
12
+ headers: {
13
+ "cache-control": "private, no-store",
14
+ "x-content-type-options": "nosniff"
15
+ }
16
+ });
17
+ function identity(request, key, origin) {
18
+ const sign = (id2) => crypto.createHmac("sha256", key).update("community-maps-reader:" + id2).digest("base64url");
19
+ const raw = request.headers.get("cookie")?.split(";").map((v) => v.trim()).find((v) => v.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);
20
+ if (raw) {
21
+ const [id2, signature] = raw.split(".");
22
+ if (id2 && signature && UUID.test(id2) && /^[A-Za-z0-9_-]{43}$/.test(signature)) {
23
+ const expected = sign(id2);
24
+ if (expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
25
+ return { id: id2, cookie: null };
26
+ }
27
+ }
28
+ const id = crypto.randomUUID();
29
+ return {
30
+ id,
31
+ cookie: `${COOKIE}=${id}.${sign(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(origin).protocol === "https:" ? "; Secure" : ""}`
32
+ };
33
+ }
34
+ async function boundedBody(request, max) {
35
+ if (Number(request.headers.get("content-length") || 0) > max)
36
+ throw new ProxyProblem(413, "The submission is too large.");
37
+ const reader = request.body?.getReader();
38
+ if (!reader) return new Uint8Array();
39
+ const chunks = [];
40
+ let length = 0;
41
+ try {
42
+ while (true) {
43
+ const { value, done } = await reader.read();
44
+ if (done) break;
45
+ length += value.length;
46
+ if (length > max) {
47
+ await reader.cancel();
48
+ throw new ProxyProblem(413, "The submission is too large.");
49
+ }
50
+ chunks.push(value);
51
+ }
52
+ } finally {
53
+ reader.releaseLock();
54
+ }
55
+ return Buffer.concat(chunks);
56
+ }
57
+ var ProxyProblem = class extends Error {
58
+ constructor(status, message) {
59
+ super(message);
60
+ this.status = status;
61
+ }
62
+ status;
63
+ };
64
+ function createPublicationCommunityMapsHandler(options) {
65
+ return async (request, path) => {
66
+ try {
67
+ if (path[0] !== "collections" || !path[1] || !/^[a-z0-9-]{1,191}$/.test(path[1]))
68
+ return json({ error: { message: "Guide not found" } }, 404);
69
+ const slug = path[1];
70
+ const rest = path.slice(2);
71
+ const method = request.method;
72
+ const read = method === "GET" && (rest.length === 0 || rest[0] === "features" && (rest.length === 1 || rest.length === 2 && UUID.test(rest[1])) || rest[0] === "media" && rest.length === 2 && UUID.test(rest[1]));
73
+ const upload = method === "POST" && rest.length === 3 && rest[0] === "submissions" && UUID.test(rest[1]) && rest[2] === "media";
74
+ const submission = method === "POST" && rest.length === 1 && rest[0] === "submissions";
75
+ const action = method === "POST" && rest.length === 3 && rest[0] === "features" && UUID.test(rest[1]) && ["reactions", "reports"].includes(rest[2]);
76
+ if (!read && !upload && !submission && !action)
77
+ return json({ error: { message: "Route not found" } }, 404);
78
+ const scope = await options.resolve(request);
79
+ if (method !== "GET" && request.headers.get("origin") !== new URL(scope.origin).origin)
80
+ return json(
81
+ { error: { message: "Submit from this publication\u2019s website." } },
82
+ 403
83
+ );
84
+ const gateway = new URL(scope.gatewayUrl);
85
+ if (gateway.protocol !== "https:" && !(gateway.protocol === "http:" && ["127.0.0.1", "localhost"].includes(gateway.hostname)))
86
+ throw new ProxyProblem(503, "The guide is unavailable.");
87
+ if (!scope.apiKey)
88
+ throw new ProxyProblem(503, "The guide is unavailable.");
89
+ const reader = identity(request, scope.apiKey, scope.origin);
90
+ const clientContext = { ...scope.clientContext, anonymousId: reader.id };
91
+ const sdk$1 = new sdk.ScaleMule({
92
+ apiKey: scope.apiKey,
93
+ baseUrl: scope.gatewayUrl
94
+ });
95
+ const callOptions = {
96
+ clientContext,
97
+ headers: scope.accessToken ? { authorization: `Bearer ${scope.accessToken}` } : {}
98
+ };
99
+ let response;
100
+ if (read && rest[0] === "media") {
101
+ const upstream = await fetch(
102
+ `${gateway.origin}${ROOT}/collections/${slug}/media/${rest[1]}`,
103
+ {
104
+ headers: {
105
+ ...sdk.buildClientContextHeaders(clientContext),
106
+ "x-api-key": scope.apiKey,
107
+ ...callOptions.headers
108
+ },
109
+ cache: "no-store",
110
+ redirect: "error",
111
+ signal: AbortSignal.timeout(3e4)
112
+ }
113
+ );
114
+ response = upstream.ok ? new Response(upstream.body, {
115
+ headers: {
116
+ "content-type": "image/png",
117
+ "cache-control": "private, no-store",
118
+ "x-content-type-options": "nosniff"
119
+ }
120
+ }) : json(
121
+ { error: { message: "Photo unavailable" } },
122
+ upstream.status >= 500 ? 503 : upstream.status
123
+ );
124
+ } else {
125
+ let result;
126
+ if (read) {
127
+ if (!rest.length)
128
+ result = await sdk$1.communityMaps.getCollection(slug, callOptions);
129
+ else if (rest.length === 2)
130
+ result = await sdk$1.communityMaps.getFeature(
131
+ slug,
132
+ rest[1],
133
+ callOptions
134
+ );
135
+ else {
136
+ const query = {};
137
+ const params = new URL(request.url).searchParams;
138
+ for (const key of [
139
+ "after",
140
+ "area_id",
141
+ "category",
142
+ "geometry_type",
143
+ "active_at",
144
+ "bbox"
145
+ ]) {
146
+ const value = params.get(key);
147
+ if (value) query[key] = value;
148
+ }
149
+ const limit = params.get("limit");
150
+ if (limit)
151
+ query.limit = Math.min(200, Math.max(1, Number(limit) || 50));
152
+ const filters = { ...query };
153
+ for (const key of ["featured", "verified"]) {
154
+ const value = params.get(key);
155
+ if (value === "true" || value === "false")
156
+ filters[key] = value === "true";
157
+ }
158
+ result = await sdk$1.communityMaps.listFeatures(
159
+ slug,
160
+ filters,
161
+ callOptions
162
+ );
163
+ }
164
+ } else if (upload) {
165
+ const contentType = request.headers.get("content-type") || "";
166
+ if (!contentType.startsWith("multipart/form-data;"))
167
+ throw new ProxyProblem(415, "Choose a photograph to upload.");
168
+ const bytes = await boundedBody(request, 9 * 1024 * 1024);
169
+ const form = await new Response(bytes, {
170
+ headers: { "content-type": contentType }
171
+ }).formData();
172
+ const file = form.get("file");
173
+ if (!(file instanceof Blob))
174
+ throw new ProxyProblem(400, "Photo is required.");
175
+ result = await sdk$1.communityMaps.uploadPhoto(
176
+ slug,
177
+ rest[1],
178
+ file,
179
+ {
180
+ caption: String(form.get("caption") || ""),
181
+ attribution: String(form.get("attribution") || ""),
182
+ rights_confirmed: form.get("rights_confirmed") === "true"
183
+ },
184
+ callOptions
185
+ );
186
+ } else {
187
+ if (!request.headers.get("content-type")?.startsWith("application/json"))
188
+ throw new ProxyProblem(415, "Use the community submission form.");
189
+ let body;
190
+ try {
191
+ body = JSON.parse(
192
+ new TextDecoder().decode(await boundedBody(request, 65536))
193
+ );
194
+ } catch (e) {
195
+ if (e instanceof ProxyProblem) throw e;
196
+ throw new ProxyProblem(400, "Invalid submission.");
197
+ }
198
+ if (!body || typeof body !== "object" || Array.isArray(body))
199
+ throw new ProxyProblem(400, "Invalid submission.");
200
+ if (submission)
201
+ result = await sdk$1.communityMaps.createSubmission(
202
+ slug,
203
+ body,
204
+ callOptions
205
+ );
206
+ else if (rest[2] === "reactions")
207
+ result = await sdk$1.communityMaps.react(
208
+ slug,
209
+ rest[1],
210
+ body.reaction,
211
+ body.active !== false,
212
+ callOptions
213
+ );
214
+ else
215
+ result = await sdk$1.communityMaps.report(
216
+ slug,
217
+ rest[1],
218
+ body,
219
+ callOptions
220
+ );
221
+ }
222
+ if (result.error) {
223
+ const error = result.error;
224
+ const status = typeof error.status === "number" ? error.status : 400;
225
+ response = json(
226
+ {
227
+ error: {
228
+ message: status >= 500 ? "The guide is temporarily unavailable. Please try again." : error.message
229
+ }
230
+ },
231
+ status
232
+ );
233
+ } else response = json({ data: result.data });
234
+ }
235
+ if (reader.cookie) response.headers.set("set-cookie", reader.cookie);
236
+ return response;
237
+ } catch (error) {
238
+ return json(
239
+ {
240
+ error: {
241
+ message: error instanceof ProxyProblem ? error.message : "The guide is temporarily unavailable. Please try again."
242
+ }
243
+ },
244
+ error instanceof ProxyProblem ? error.status : 503
245
+ );
246
+ }
247
+ };
248
+ }
249
+
250
+ exports.createPublicationCommunityMapsHandler = createPublicationCommunityMapsHandler;
@@ -0,0 +1,16 @@
1
+ import { ClientContext } from '@scalemule/sdk';
2
+
3
+ interface PublicationCommunityMapScope {
4
+ apiKey: string;
5
+ gatewayUrl: string;
6
+ origin: string;
7
+ clientContext: ClientContext;
8
+ accessToken?: string;
9
+ }
10
+ interface PublicationCommunityMapServerOptions {
11
+ resolve: (request: Request) => Promise<PublicationCommunityMapScope>;
12
+ }
13
+ /** Only public collection routes are accepted. The URL never selects tenant credentials. */
14
+ declare function createPublicationCommunityMapsHandler(options: PublicationCommunityMapServerOptions): (request: Request, path: string[]) => Promise<Response>;
15
+
16
+ export { type PublicationCommunityMapScope, type PublicationCommunityMapServerOptions, createPublicationCommunityMapsHandler };
@@ -0,0 +1,16 @@
1
+ import { ClientContext } from '@scalemule/sdk';
2
+
3
+ interface PublicationCommunityMapScope {
4
+ apiKey: string;
5
+ gatewayUrl: string;
6
+ origin: string;
7
+ clientContext: ClientContext;
8
+ accessToken?: string;
9
+ }
10
+ interface PublicationCommunityMapServerOptions {
11
+ resolve: (request: Request) => Promise<PublicationCommunityMapScope>;
12
+ }
13
+ /** Only public collection routes are accepted. The URL never selects tenant credentials. */
14
+ declare function createPublicationCommunityMapsHandler(options: PublicationCommunityMapServerOptions): (request: Request, path: string[]) => Promise<Response>;
15
+
16
+ export { type PublicationCommunityMapScope, type PublicationCommunityMapServerOptions, createPublicationCommunityMapsHandler };
@@ -0,0 +1,248 @@
1
+ import { timingSafeEqual, randomUUID, createHmac } from 'crypto';
2
+ import { ScaleMule, buildClientContextHeaders } from '@scalemule/sdk';
3
+
4
+ // src/community-maps-server.ts
5
+ var ROOT = "/v1/knowledge/community-maps";
6
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ var COOKIE = "sm_community_reader";
8
+ var json = (data, status = 200) => Response.json(data, {
9
+ status,
10
+ headers: {
11
+ "cache-control": "private, no-store",
12
+ "x-content-type-options": "nosniff"
13
+ }
14
+ });
15
+ function identity(request, key, origin) {
16
+ const sign = (id2) => createHmac("sha256", key).update("community-maps-reader:" + id2).digest("base64url");
17
+ const raw = request.headers.get("cookie")?.split(";").map((v) => v.trim()).find((v) => v.startsWith(COOKIE + "="))?.slice(COOKIE.length + 1);
18
+ if (raw) {
19
+ const [id2, signature] = raw.split(".");
20
+ if (id2 && signature && UUID.test(id2) && /^[A-Za-z0-9_-]{43}$/.test(signature)) {
21
+ const expected = sign(id2);
22
+ if (expected.length === signature.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)))
23
+ return { id: id2, cookie: null };
24
+ }
25
+ }
26
+ const id = randomUUID();
27
+ return {
28
+ id,
29
+ cookie: `${COOKIE}=${id}.${sign(id)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(origin).protocol === "https:" ? "; Secure" : ""}`
30
+ };
31
+ }
32
+ async function boundedBody(request, max) {
33
+ if (Number(request.headers.get("content-length") || 0) > max)
34
+ throw new ProxyProblem(413, "The submission is too large.");
35
+ const reader = request.body?.getReader();
36
+ if (!reader) return new Uint8Array();
37
+ const chunks = [];
38
+ let length = 0;
39
+ try {
40
+ while (true) {
41
+ const { value, done } = await reader.read();
42
+ if (done) break;
43
+ length += value.length;
44
+ if (length > max) {
45
+ await reader.cancel();
46
+ throw new ProxyProblem(413, "The submission is too large.");
47
+ }
48
+ chunks.push(value);
49
+ }
50
+ } finally {
51
+ reader.releaseLock();
52
+ }
53
+ return Buffer.concat(chunks);
54
+ }
55
+ var ProxyProblem = class extends Error {
56
+ constructor(status, message) {
57
+ super(message);
58
+ this.status = status;
59
+ }
60
+ status;
61
+ };
62
+ function createPublicationCommunityMapsHandler(options) {
63
+ return async (request, path) => {
64
+ try {
65
+ if (path[0] !== "collections" || !path[1] || !/^[a-z0-9-]{1,191}$/.test(path[1]))
66
+ return json({ error: { message: "Guide not found" } }, 404);
67
+ const slug = path[1];
68
+ const rest = path.slice(2);
69
+ const method = request.method;
70
+ const read = method === "GET" && (rest.length === 0 || rest[0] === "features" && (rest.length === 1 || rest.length === 2 && UUID.test(rest[1])) || rest[0] === "media" && rest.length === 2 && UUID.test(rest[1]));
71
+ const upload = method === "POST" && rest.length === 3 && rest[0] === "submissions" && UUID.test(rest[1]) && rest[2] === "media";
72
+ const submission = method === "POST" && rest.length === 1 && rest[0] === "submissions";
73
+ const action = method === "POST" && rest.length === 3 && rest[0] === "features" && UUID.test(rest[1]) && ["reactions", "reports"].includes(rest[2]);
74
+ if (!read && !upload && !submission && !action)
75
+ return json({ error: { message: "Route not found" } }, 404);
76
+ const scope = await options.resolve(request);
77
+ if (method !== "GET" && request.headers.get("origin") !== new URL(scope.origin).origin)
78
+ return json(
79
+ { error: { message: "Submit from this publication\u2019s website." } },
80
+ 403
81
+ );
82
+ const gateway = new URL(scope.gatewayUrl);
83
+ if (gateway.protocol !== "https:" && !(gateway.protocol === "http:" && ["127.0.0.1", "localhost"].includes(gateway.hostname)))
84
+ throw new ProxyProblem(503, "The guide is unavailable.");
85
+ if (!scope.apiKey)
86
+ throw new ProxyProblem(503, "The guide is unavailable.");
87
+ const reader = identity(request, scope.apiKey, scope.origin);
88
+ const clientContext = { ...scope.clientContext, anonymousId: reader.id };
89
+ const sdk = new ScaleMule({
90
+ apiKey: scope.apiKey,
91
+ baseUrl: scope.gatewayUrl
92
+ });
93
+ const callOptions = {
94
+ clientContext,
95
+ headers: scope.accessToken ? { authorization: `Bearer ${scope.accessToken}` } : {}
96
+ };
97
+ let response;
98
+ if (read && rest[0] === "media") {
99
+ const upstream = await fetch(
100
+ `${gateway.origin}${ROOT}/collections/${slug}/media/${rest[1]}`,
101
+ {
102
+ headers: {
103
+ ...buildClientContextHeaders(clientContext),
104
+ "x-api-key": scope.apiKey,
105
+ ...callOptions.headers
106
+ },
107
+ cache: "no-store",
108
+ redirect: "error",
109
+ signal: AbortSignal.timeout(3e4)
110
+ }
111
+ );
112
+ response = upstream.ok ? new Response(upstream.body, {
113
+ headers: {
114
+ "content-type": "image/png",
115
+ "cache-control": "private, no-store",
116
+ "x-content-type-options": "nosniff"
117
+ }
118
+ }) : json(
119
+ { error: { message: "Photo unavailable" } },
120
+ upstream.status >= 500 ? 503 : upstream.status
121
+ );
122
+ } else {
123
+ let result;
124
+ if (read) {
125
+ if (!rest.length)
126
+ result = await sdk.communityMaps.getCollection(slug, callOptions);
127
+ else if (rest.length === 2)
128
+ result = await sdk.communityMaps.getFeature(
129
+ slug,
130
+ rest[1],
131
+ callOptions
132
+ );
133
+ else {
134
+ const query = {};
135
+ const params = new URL(request.url).searchParams;
136
+ for (const key of [
137
+ "after",
138
+ "area_id",
139
+ "category",
140
+ "geometry_type",
141
+ "active_at",
142
+ "bbox"
143
+ ]) {
144
+ const value = params.get(key);
145
+ if (value) query[key] = value;
146
+ }
147
+ const limit = params.get("limit");
148
+ if (limit)
149
+ query.limit = Math.min(200, Math.max(1, Number(limit) || 50));
150
+ const filters = { ...query };
151
+ for (const key of ["featured", "verified"]) {
152
+ const value = params.get(key);
153
+ if (value === "true" || value === "false")
154
+ filters[key] = value === "true";
155
+ }
156
+ result = await sdk.communityMaps.listFeatures(
157
+ slug,
158
+ filters,
159
+ callOptions
160
+ );
161
+ }
162
+ } else if (upload) {
163
+ const contentType = request.headers.get("content-type") || "";
164
+ if (!contentType.startsWith("multipart/form-data;"))
165
+ throw new ProxyProblem(415, "Choose a photograph to upload.");
166
+ const bytes = await boundedBody(request, 9 * 1024 * 1024);
167
+ const form = await new Response(bytes, {
168
+ headers: { "content-type": contentType }
169
+ }).formData();
170
+ const file = form.get("file");
171
+ if (!(file instanceof Blob))
172
+ throw new ProxyProblem(400, "Photo is required.");
173
+ result = await sdk.communityMaps.uploadPhoto(
174
+ slug,
175
+ rest[1],
176
+ file,
177
+ {
178
+ caption: String(form.get("caption") || ""),
179
+ attribution: String(form.get("attribution") || ""),
180
+ rights_confirmed: form.get("rights_confirmed") === "true"
181
+ },
182
+ callOptions
183
+ );
184
+ } else {
185
+ if (!request.headers.get("content-type")?.startsWith("application/json"))
186
+ throw new ProxyProblem(415, "Use the community submission form.");
187
+ let body;
188
+ try {
189
+ body = JSON.parse(
190
+ new TextDecoder().decode(await boundedBody(request, 65536))
191
+ );
192
+ } catch (e) {
193
+ if (e instanceof ProxyProblem) throw e;
194
+ throw new ProxyProblem(400, "Invalid submission.");
195
+ }
196
+ if (!body || typeof body !== "object" || Array.isArray(body))
197
+ throw new ProxyProblem(400, "Invalid submission.");
198
+ if (submission)
199
+ result = await sdk.communityMaps.createSubmission(
200
+ slug,
201
+ body,
202
+ callOptions
203
+ );
204
+ else if (rest[2] === "reactions")
205
+ result = await sdk.communityMaps.react(
206
+ slug,
207
+ rest[1],
208
+ body.reaction,
209
+ body.active !== false,
210
+ callOptions
211
+ );
212
+ else
213
+ result = await sdk.communityMaps.report(
214
+ slug,
215
+ rest[1],
216
+ body,
217
+ callOptions
218
+ );
219
+ }
220
+ if (result.error) {
221
+ const error = result.error;
222
+ const status = typeof error.status === "number" ? error.status : 400;
223
+ response = json(
224
+ {
225
+ error: {
226
+ message: status >= 500 ? "The guide is temporarily unavailable. Please try again." : error.message
227
+ }
228
+ },
229
+ status
230
+ );
231
+ } else response = json({ data: result.data });
232
+ }
233
+ if (reader.cookie) response.headers.set("set-cookie", reader.cookie);
234
+ return response;
235
+ } catch (error) {
236
+ return json(
237
+ {
238
+ error: {
239
+ message: error instanceof ProxyProblem ? error.message : "The guide is temporarily unavailable. Please try again."
240
+ }
241
+ },
242
+ error instanceof ProxyProblem ? error.status : 503
243
+ );
244
+ }
245
+ };
246
+ }
247
+
248
+ export { createPublicationCommunityMapsHandler };
@@ -0,0 +1,155 @@
1
+ "use client";
2
+ 'use strict';
3
+
4
+ var react = require('react');
5
+ var communityMaps = require('@scalemule/react/community-maps');
6
+ var jsxRuntime = require('react/jsx-runtime');
7
+
8
+ // src/community-maps.ts
9
+ function communityMapEmbeds(metadata) {
10
+ if (!metadata || typeof metadata !== "object") return [];
11
+ const value = metadata.community_maps;
12
+ if (!Array.isArray(value)) return [];
13
+ return value.slice(0, 5).flatMap((entry) => {
14
+ if (!entry || typeof entry !== "object" || typeof entry.collection_slug !== "string" || !/^[a-z0-9-]{1,191}$/.test(entry.collection_slug) || !["map", "cards", "features"].includes(entry.mode))
15
+ return [];
16
+ const ids = Array.isArray(entry.feature_ids) ? entry.feature_ids.filter(
17
+ (id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
18
+ ).slice(0, 20) : void 0;
19
+ return [
20
+ {
21
+ collection_slug: entry.collection_slug,
22
+ mode: entry.mode,
23
+ feature_ids: ids,
24
+ area_id: typeof entry.area_id === "string" && /^[0-9a-f-]{36}$/i.test(entry.area_id) ? entry.area_id : void 0
25
+ }
26
+ ];
27
+ });
28
+ }
29
+ function PublicationCommunityMapPage({
30
+ collection,
31
+ feature,
32
+ mode = "guide",
33
+ areaId,
34
+ correctionFor,
35
+ basePath,
36
+ apiPath
37
+ }) {
38
+ const client = react.useMemo(
39
+ () => communityMaps.createCommunityMapsBrowserClient(apiPath),
40
+ [apiPath]
41
+ );
42
+ const featureHref = (f) => `${basePath}/places/${f.id}`;
43
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "smcm", children: [
44
+ mode !== "guide" && /* @__PURE__ */ jsxRuntime.jsx("p", { children: /* @__PURE__ */ jsxRuntime.jsxs("a", { href: basePath, children: [
45
+ "\u2190 ",
46
+ collection.config.title
47
+ ] }) }),
48
+ mode === "submit" ? /* @__PURE__ */ jsxRuntime.jsx(
49
+ communityMaps.CommunitySubmissionForm,
50
+ {
51
+ client,
52
+ collection,
53
+ initialAreaId: areaId,
54
+ correctionFor
55
+ }
56
+ ) : mode === "feature" && feature ? /* @__PURE__ */ jsxRuntime.jsx(
57
+ communityMaps.CommunityFeatureDetail,
58
+ {
59
+ client,
60
+ collection,
61
+ feature,
62
+ correctionHref: `${basePath}/submit?correction=${feature.id}&area=${feature.area_id}`
63
+ }
64
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
65
+ communityMaps.CommunityGuide,
66
+ {
67
+ client,
68
+ collection,
69
+ featureHref,
70
+ communityHref: (a) => `${basePath}/map?area=${a.id}`,
71
+ submitHref: `${basePath}/submit${areaId ? `?area=${areaId}` : ""}`,
72
+ initialAreaId: areaId,
73
+ showMap: mode === "map"
74
+ }
75
+ )
76
+ ] });
77
+ }
78
+ function CommunityMapArticleEmbeds({
79
+ metadata,
80
+ apiPath = "/api/community-maps"
81
+ }) {
82
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: communityMapEmbeds(metadata).map((embed, index) => /* @__PURE__ */ jsxRuntime.jsx(
83
+ ArticleMap,
84
+ {
85
+ embed,
86
+ apiPath
87
+ },
88
+ `${embed.collection_slug}-${index}`
89
+ )) });
90
+ }
91
+ function ArticleMap({
92
+ embed,
93
+ apiPath
94
+ }) {
95
+ const client = react.useMemo(
96
+ () => communityMaps.createCommunityMapsBrowserClient(apiPath),
97
+ [apiPath]
98
+ );
99
+ const [data, setData] = react.useState(null);
100
+ const ids = embed.feature_ids?.join(",");
101
+ react.useEffect(() => {
102
+ const controller = new AbortController();
103
+ setData(null);
104
+ async function load() {
105
+ const collection = await client.collection(
106
+ embed.collection_slug,
107
+ controller.signal
108
+ );
109
+ const features = ids ? await Promise.all(
110
+ ids.split(",").map(
111
+ (id) => client.feature(embed.collection_slug, id, controller.signal)
112
+ )
113
+ ) : (await client.features(
114
+ embed.collection_slug,
115
+ { area_id: embed.area_id, limit: 20 },
116
+ controller.signal
117
+ )).items;
118
+ if (!controller.signal.aborted) setData({ collection, features });
119
+ }
120
+ void load().catch(() => setData(null));
121
+ return () => controller.abort();
122
+ }, [client, embed.collection_slug, embed.area_id, ids]);
123
+ if (!data) return null;
124
+ const path = `/community-maps/${data.collection.slug}`;
125
+ return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: "smcm", "aria-label": data.collection.config.title, children: [
126
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: /* @__PURE__ */ jsxRuntime.jsx("a", { href: path, children: data.collection.config.title }) }),
127
+ embed.mode === "cards" ? /* @__PURE__ */ jsxRuntime.jsx(
128
+ communityMaps.CommunityCards,
129
+ {
130
+ areas: data.collection.areas,
131
+ href: (a) => `${path}/map?area=${a.id}`
132
+ }
133
+ ) : embed.mode === "map" ? /* @__PURE__ */ jsxRuntime.jsx(
134
+ communityMaps.CommunityMap,
135
+ {
136
+ features: data.features,
137
+ areas: data.collection.areas,
138
+ styleUrl: data.collection.config.presentation.map_style_url
139
+ }
140
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "smcm-feature-grid", children: data.features.map((f) => /* @__PURE__ */ jsxRuntime.jsx(
141
+ communityMaps.CommunityFeatureCard,
142
+ {
143
+ feature: f,
144
+ href: `${path}/places/${f.id}`,
145
+ photoUrl: f.media[0] ? client.mediaUrl(data.collection.slug, f.media[0].id) : void 0
146
+ },
147
+ f.id
148
+ )) }),
149
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: /* @__PURE__ */ jsxRuntime.jsx("a", { href: `${path}/map`, children: "Explore the full map \u2197" }) })
150
+ ] });
151
+ }
152
+
153
+ exports.CommunityMapArticleEmbeds = CommunityMapArticleEmbeds;
154
+ exports.PublicationCommunityMapPage = PublicationCommunityMapPage;
155
+ exports.communityMapEmbeds = communityMapEmbeds;
@@ -0,0 +1,22 @@
1
+ import * as react from 'react';
2
+ import { CommunityMapCollection, CommunityMapFeature } from '@scalemule/react/community-maps';
3
+ export { c as communityMapEmbeds } from './community-maps-DKcYhQYN.cjs';
4
+
5
+ interface PublicationCommunityMapPageProps {
6
+ collection: CommunityMapCollection;
7
+ feature?: CommunityMapFeature;
8
+ mode?: "guide" | "map" | "submit" | "feature";
9
+ areaId?: string;
10
+ correctionFor?: string;
11
+ basePath: string;
12
+ apiPath?: string;
13
+ }
14
+ /** The newspaper supplies its chrome and inherited type. The platform owns map behavior. */
15
+ declare function PublicationCommunityMapPage({ collection, feature, mode, areaId, correctionFor, basePath, apiPath, }: PublicationCommunityMapPageProps): react.JSX.Element;
16
+
17
+ declare function CommunityMapArticleEmbeds({ metadata, apiPath, }: {
18
+ metadata: unknown;
19
+ apiPath?: string;
20
+ }): react.JSX.Element;
21
+
22
+ export { CommunityMapArticleEmbeds, PublicationCommunityMapPage, type PublicationCommunityMapPageProps };
@@ -0,0 +1,22 @@
1
+ import * as react from 'react';
2
+ import { CommunityMapCollection, CommunityMapFeature } from '@scalemule/react/community-maps';
3
+ export { c as communityMapEmbeds } from './community-maps-DKcYhQYN.js';
4
+
5
+ interface PublicationCommunityMapPageProps {
6
+ collection: CommunityMapCollection;
7
+ feature?: CommunityMapFeature;
8
+ mode?: "guide" | "map" | "submit" | "feature";
9
+ areaId?: string;
10
+ correctionFor?: string;
11
+ basePath: string;
12
+ apiPath?: string;
13
+ }
14
+ /** The newspaper supplies its chrome and inherited type. The platform owns map behavior. */
15
+ declare function PublicationCommunityMapPage({ collection, feature, mode, areaId, correctionFor, basePath, apiPath, }: PublicationCommunityMapPageProps): react.JSX.Element;
16
+
17
+ declare function CommunityMapArticleEmbeds({ metadata, apiPath, }: {
18
+ metadata: unknown;
19
+ apiPath?: string;
20
+ }): react.JSX.Element;
21
+
22
+ export { CommunityMapArticleEmbeds, PublicationCommunityMapPage, type PublicationCommunityMapPageProps };
@@ -0,0 +1,132 @@
1
+ "use client";
2
+ import { communityMapEmbeds } from './chunk-IMYBSG7J.js';
3
+ export { communityMapEmbeds } from './chunk-IMYBSG7J.js';
4
+ import { useMemo, useState, useEffect } from 'react';
5
+ import { createCommunityMapsBrowserClient, CommunitySubmissionForm, CommunityFeatureDetail, CommunityGuide, CommunityCards, CommunityMap, CommunityFeatureCard } from '@scalemule/react/community-maps';
6
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
7
+
8
+ function PublicationCommunityMapPage({
9
+ collection,
10
+ feature,
11
+ mode = "guide",
12
+ areaId,
13
+ correctionFor,
14
+ basePath,
15
+ apiPath
16
+ }) {
17
+ const client = useMemo(
18
+ () => createCommunityMapsBrowserClient(apiPath),
19
+ [apiPath]
20
+ );
21
+ const featureHref = (f) => `${basePath}/places/${f.id}`;
22
+ return /* @__PURE__ */ jsxs("div", { className: "smcm", children: [
23
+ mode !== "guide" && /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsxs("a", { href: basePath, children: [
24
+ "\u2190 ",
25
+ collection.config.title
26
+ ] }) }),
27
+ mode === "submit" ? /* @__PURE__ */ jsx(
28
+ CommunitySubmissionForm,
29
+ {
30
+ client,
31
+ collection,
32
+ initialAreaId: areaId,
33
+ correctionFor
34
+ }
35
+ ) : mode === "feature" && feature ? /* @__PURE__ */ jsx(
36
+ CommunityFeatureDetail,
37
+ {
38
+ client,
39
+ collection,
40
+ feature,
41
+ correctionHref: `${basePath}/submit?correction=${feature.id}&area=${feature.area_id}`
42
+ }
43
+ ) : /* @__PURE__ */ jsx(
44
+ CommunityGuide,
45
+ {
46
+ client,
47
+ collection,
48
+ featureHref,
49
+ communityHref: (a) => `${basePath}/map?area=${a.id}`,
50
+ submitHref: `${basePath}/submit${areaId ? `?area=${areaId}` : ""}`,
51
+ initialAreaId: areaId,
52
+ showMap: mode === "map"
53
+ }
54
+ )
55
+ ] });
56
+ }
57
+ function CommunityMapArticleEmbeds({
58
+ metadata,
59
+ apiPath = "/api/community-maps"
60
+ }) {
61
+ return /* @__PURE__ */ jsx(Fragment, { children: communityMapEmbeds(metadata).map((embed, index) => /* @__PURE__ */ jsx(
62
+ ArticleMap,
63
+ {
64
+ embed,
65
+ apiPath
66
+ },
67
+ `${embed.collection_slug}-${index}`
68
+ )) });
69
+ }
70
+ function ArticleMap({
71
+ embed,
72
+ apiPath
73
+ }) {
74
+ const client = useMemo(
75
+ () => createCommunityMapsBrowserClient(apiPath),
76
+ [apiPath]
77
+ );
78
+ const [data, setData] = useState(null);
79
+ const ids = embed.feature_ids?.join(",");
80
+ useEffect(() => {
81
+ const controller = new AbortController();
82
+ setData(null);
83
+ async function load() {
84
+ const collection = await client.collection(
85
+ embed.collection_slug,
86
+ controller.signal
87
+ );
88
+ const features = ids ? await Promise.all(
89
+ ids.split(",").map(
90
+ (id) => client.feature(embed.collection_slug, id, controller.signal)
91
+ )
92
+ ) : (await client.features(
93
+ embed.collection_slug,
94
+ { area_id: embed.area_id, limit: 20 },
95
+ controller.signal
96
+ )).items;
97
+ if (!controller.signal.aborted) setData({ collection, features });
98
+ }
99
+ void load().catch(() => setData(null));
100
+ return () => controller.abort();
101
+ }, [client, embed.collection_slug, embed.area_id, ids]);
102
+ if (!data) return null;
103
+ const path = `/community-maps/${data.collection.slug}`;
104
+ return /* @__PURE__ */ jsxs("aside", { className: "smcm", "aria-label": data.collection.config.title, children: [
105
+ /* @__PURE__ */ jsx("h2", { children: /* @__PURE__ */ jsx("a", { href: path, children: data.collection.config.title }) }),
106
+ embed.mode === "cards" ? /* @__PURE__ */ jsx(
107
+ CommunityCards,
108
+ {
109
+ areas: data.collection.areas,
110
+ href: (a) => `${path}/map?area=${a.id}`
111
+ }
112
+ ) : embed.mode === "map" ? /* @__PURE__ */ jsx(
113
+ CommunityMap,
114
+ {
115
+ features: data.features,
116
+ areas: data.collection.areas,
117
+ styleUrl: data.collection.config.presentation.map_style_url
118
+ }
119
+ ) : /* @__PURE__ */ jsx("div", { className: "smcm-feature-grid", children: data.features.map((f) => /* @__PURE__ */ jsx(
120
+ CommunityFeatureCard,
121
+ {
122
+ feature: f,
123
+ href: `${path}/places/${f.id}`,
124
+ photoUrl: f.media[0] ? client.mediaUrl(data.collection.slug, f.media[0].id) : void 0
125
+ },
126
+ f.id
127
+ )) }),
128
+ /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx("a", { href: `${path}/map`, children: "Explore the full map \u2197" }) })
129
+ ] });
130
+ }
131
+
132
+ export { CommunityMapArticleEmbeds, PublicationCommunityMapPage };
package/dist/index.cjs CHANGED
@@ -230,7 +230,26 @@ function composeEdition(items, selection = {}, now = Date.now()) {
230
230
  ...(pins.secondary_slugs ?? []).flatMap((slug) => remaining.find((item) => item.slug === slug) ?? []),
231
231
  ...remaining.filter((item) => !requested.has(item.slug))
232
232
  ];
233
- const secondary = Array.from(new Map(ordered.map((item) => [item.id, item])).values()).slice(0, 3);
233
+ const uniqueOrdered = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
234
+ const seenImages = /* @__PURE__ */ new Set();
235
+ if (lead?.cover_image_url?.trim()) {
236
+ seenImages.add(lead.cover_image_url.trim());
237
+ }
238
+ const secondary = [];
239
+ const deferredWithDuplicateImage = [];
240
+ for (const item of uniqueOrdered) {
241
+ if (secondary.length >= 3) break;
242
+ const img = item.cover_image_url?.trim();
243
+ if (img && seenImages.has(img)) {
244
+ deferredWithDuplicateImage.push(item);
245
+ } else {
246
+ secondary.push(item);
247
+ if (img) seenImages.add(img);
248
+ }
249
+ }
250
+ while (secondary.length < 3 && deferredWithDuplicateImage.length > 0) {
251
+ secondary.push(deferredWithDuplicateImage.shift());
252
+ }
234
253
  for (const item of secondary) remaining.splice(remaining.indexOf(item), 1);
235
254
  const eligible = remaining.filter((item) => {
236
255
  const age = now - Date.parse(item.published_at);
@@ -2678,6 +2697,28 @@ function VerifiedCardSection({
2678
2697
  );
2679
2698
  }
2680
2699
 
2700
+ // src/community-maps.ts
2701
+ function communityMapEmbeds(metadata) {
2702
+ if (!metadata || typeof metadata !== "object") return [];
2703
+ const value = metadata.community_maps;
2704
+ if (!Array.isArray(value)) return [];
2705
+ return value.slice(0, 5).flatMap((entry) => {
2706
+ if (!entry || typeof entry !== "object" || typeof entry.collection_slug !== "string" || !/^[a-z0-9-]{1,191}$/.test(entry.collection_slug) || !["map", "cards", "features"].includes(entry.mode))
2707
+ return [];
2708
+ const ids = Array.isArray(entry.feature_ids) ? entry.feature_ids.filter(
2709
+ (id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
2710
+ ).slice(0, 20) : void 0;
2711
+ return [
2712
+ {
2713
+ collection_slug: entry.collection_slug,
2714
+ mode: entry.mode,
2715
+ feature_ids: ids,
2716
+ area_id: typeof entry.area_id === "string" && /^[0-9a-f-]{36}$/i.test(entry.area_id) ? entry.area_id : void 0
2717
+ }
2718
+ ];
2719
+ });
2720
+ }
2721
+
2681
2722
  Object.defineProperty(exports, "COOKIE_CONSENT_BACKUP_KEY", {
2682
2723
  enumerable: true,
2683
2724
  get: function () { return compliance.COOKIE_CONSENT_BACKUP_KEY; }
@@ -2809,6 +2850,7 @@ exports.classifiedPath = classifiedPath;
2809
2850
  exports.classifiedSearchPath = classifiedSearchPath;
2810
2851
  exports.clearTheme = clearTheme;
2811
2852
  exports.clipChars = clipChars;
2853
+ exports.communityMapEmbeds = communityMapEmbeds;
2812
2854
  exports.communityPublications = communityPublications;
2813
2855
  exports.composeEdition = composeEdition;
2814
2856
  exports.dealDollars = dealDollars;
package/dist/index.d.cts CHANGED
@@ -13,6 +13,7 @@ export { LocalNetworkBar, LocalNetworkBarProps } from './network-bar.cjs';
13
13
  export { NetworkCopyrightNotice, NetworkCopyrightNoticeProps } from './network-copyright.cjs';
14
14
  import * as react from 'react';
15
15
  export { VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl } from './verified-card.cjs';
16
+ export { C as CommunityMapArticleEmbed, c as communityMapEmbeds } from './community-maps-DKcYhQYN.cjs';
16
17
 
17
18
  /** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
18
19
  declare const BROADSHEET_THEME: PublicationTheme;
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export { LocalNetworkBar, LocalNetworkBarProps } from './network-bar.js';
13
13
  export { NetworkCopyrightNotice, NetworkCopyrightNoticeProps } from './network-copyright.js';
14
14
  import * as react from 'react';
15
15
  export { VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl } from './verified-card.js';
16
+ export { C as CommunityMapArticleEmbed, c as communityMapEmbeds } from './community-maps-DKcYhQYN.js';
16
17
 
17
18
  /** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
18
19
  declare const BROADSHEET_THEME: PublicationTheme;
package/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
+ export { CURATED_COMMUNITY_CARDS, VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl } from './chunk-FMCHQI7K.js';
2
+ export { CookieConsentBanner } from './chunk-DC6RMIYL.js';
1
3
  export { CookieSettingsView } from './chunk-XDGBMMLD.js';
2
4
  export { THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, ThemeToggle, applyTheme, clearTheme, getStoredTheme, getSystemTheme, resolveEffectiveTheme, setTheme, subscribeTheme, themeInitScript } from './chunk-NQD3M7ZY.js';
3
5
  export { LocalNetworkBar } from './chunk-EVAGI7C5.js';
4
6
  export { NetworkCopyrightNotice } from './chunk-VRDUSIVG.js';
5
- export { CURATED_COMMUNITY_CARDS, VerifiedCardComponent, VerifiedCardSection, getArticleCards, isSafeActionUrl } from './chunk-FMCHQI7K.js';
7
+ import { localPublications } from './chunk-56TU57OO.js';
8
+ export { COOKIE_CATEGORIES, COOKIE_CONSENT_BACKUP_KEY, COOKIE_CONSENT_EVENT, COOKIE_CONSENT_KEY, COOKIE_CONSENT_MAX_AGE_SECONDS, DEFAULT_CONSENT_HUB_URL, DEFAULT_PREFERENCES_ACCEPTED, DEFAULT_PREFERENCES_REJECTED, EU_EEA_UK_COUNTRIES, NETWORK_CONSENT_STORAGE_KEY, NETWORK_PARENT, acceptAllCookies, checkUrlConsentBridge, clearCookieConsent, communityPublications, decorateNetworkUrl, findPublicationByHost, findPublicationBySlug, getCookieConsent, getNetworkPublicationUrl, hasConsented, isCategoryAllowed, isGeoConsentRequired, listedPublications, localPublications, queryNetworkConsentHub, recordConsentToCompliance, rejectOptionalCookies, setCookieConsent, setNetworkConsentHub } from './chunk-56TU57OO.js';
9
+ export { communityMapEmbeds } from './chunk-IMYBSG7J.js';
6
10
  import { definePublication } from './chunk-C7JTEHTH.js';
7
11
  export { BROADSHEET_THEME, FEATURE_NAMES, LAYOUT_COOKIE, acceptRemoteRecord, acceptSnapshot, definePublication, featureEnabled, parseSnapshot, publicationFromSnapshot, publicationRecord, resolveLayout, snapshotFromRecord, syncPublication } from './chunk-C7JTEHTH.js';
8
12
  export { CLASSIFIED_CATEGORIES, JOB_EMPLOYMENT_TYPES, categoryLabel, classifiedPath, classifiedSearchPath, clipChars, dealDollars, dealGoPath, dealsFeedPath, employmentLabel, isClassifiedCategory, isEmploymentType, jobEmployerBody, jobOpeningBody, jobPostingBody, prepareClassified, prepareJobPost, publicJobPath, publicJobsPath } from './chunk-C7ZPLH2K.js';
9
13
  export { MEET_ACTIVITIES, activityLabel, canHostMeetup, isAdult, prepareMeetup, screenMessage } from './chunk-GB36YM56.js';
10
- export { CookieConsentBanner } from './chunk-DC6RMIYL.js';
11
- import { localPublications } from './chunk-56TU57OO.js';
12
- export { COOKIE_CATEGORIES, COOKIE_CONSENT_BACKUP_KEY, COOKIE_CONSENT_EVENT, COOKIE_CONSENT_KEY, COOKIE_CONSENT_MAX_AGE_SECONDS, DEFAULT_CONSENT_HUB_URL, DEFAULT_PREFERENCES_ACCEPTED, DEFAULT_PREFERENCES_REJECTED, EU_EEA_UK_COUNTRIES, NETWORK_CONSENT_STORAGE_KEY, NETWORK_PARENT, acceptAllCookies, checkUrlConsentBridge, clearCookieConsent, communityPublications, decorateNetworkUrl, findPublicationByHost, findPublicationBySlug, getCookieConsent, getNetworkPublicationUrl, hasConsented, isCategoryAllowed, isGeoConsentRequired, listedPublications, localPublications, queryNetworkConsentHub, recordConsentToCompliance, rejectOptionalCookies, setCookieConsent, setNetworkConsentHub } from './chunk-56TU57OO.js';
13
14
  import { jsxs, jsx } from 'react/jsx-runtime';
14
15
 
15
16
  // src/preset.ts
@@ -169,7 +170,26 @@ function composeEdition(items, selection = {}, now = Date.now()) {
169
170
  ...(pins.secondary_slugs ?? []).flatMap((slug) => remaining.find((item) => item.slug === slug) ?? []),
170
171
  ...remaining.filter((item) => !requested.has(item.slug))
171
172
  ];
172
- const secondary = Array.from(new Map(ordered.map((item) => [item.id, item])).values()).slice(0, 3);
173
+ const uniqueOrdered = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
174
+ const seenImages = /* @__PURE__ */ new Set();
175
+ if (lead?.cover_image_url?.trim()) {
176
+ seenImages.add(lead.cover_image_url.trim());
177
+ }
178
+ const secondary = [];
179
+ const deferredWithDuplicateImage = [];
180
+ for (const item of uniqueOrdered) {
181
+ if (secondary.length >= 3) break;
182
+ const img = item.cover_image_url?.trim();
183
+ if (img && seenImages.has(img)) {
184
+ deferredWithDuplicateImage.push(item);
185
+ } else {
186
+ secondary.push(item);
187
+ if (img) seenImages.add(img);
188
+ }
189
+ }
190
+ while (secondary.length < 3 && deferredWithDuplicateImage.length > 0) {
191
+ secondary.push(deferredWithDuplicateImage.shift());
192
+ }
173
193
  for (const item of secondary) remaining.splice(remaining.indexOf(item), 1);
174
194
  const eligible = remaining.filter((item) => {
175
195
  const age = now - Date.parse(item.published_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -86,7 +86,17 @@
86
86
  "import": "./dist/story-review.js",
87
87
  "require": "./dist/story-review.cjs"
88
88
  },
89
- "./story-review.css": "./story-review.css"
89
+ "./story-review.css": "./story-review.css",
90
+ "./community-maps-server": {
91
+ "types": "./dist/community-maps-server.d.ts",
92
+ "import": "./dist/community-maps-server.js",
93
+ "require": "./dist/community-maps-server.cjs"
94
+ },
95
+ "./community-maps-view": {
96
+ "types": "./dist/community-maps-view.d.ts",
97
+ "import": "./dist/community-maps-view.js",
98
+ "require": "./dist/community-maps-view.cjs"
99
+ }
90
100
  },
91
101
  "files": [
92
102
  "dist",
@@ -99,11 +109,15 @@
99
109
  "story-review.css"
100
110
  ],
101
111
  "peerDependencies": {
102
- "react": ">=18.0.0"
112
+ "react": ">=18.0.0",
113
+ "@scalemule/react": ">=0.0.13 <0.1.0"
103
114
  },
104
115
  "peerDependenciesMeta": {
105
116
  "react": {
106
117
  "optional": true
118
+ },
119
+ "@scalemule/react": {
120
+ "optional": true
107
121
  }
108
122
  },
109
123
  "scripts": {
@@ -124,7 +138,8 @@
124
138
  "react-dom": "^19.3.0",
125
139
  "tsup": "^8.5.0",
126
140
  "typescript": "^5.8.3",
127
- "vitest": "^3.2.4"
141
+ "vitest": "^3.2.4",
142
+ "@scalemule/react": "^0.0.13"
128
143
  },
129
144
  "keywords": [
130
145
  "scalemule",
@@ -146,6 +161,7 @@
146
161
  "registry": "https://registry.npmjs.org/"
147
162
  },
148
163
  "dependencies": {
149
- "@scalemule/compliance": "^0.0.5"
164
+ "@scalemule/compliance": "^0.0.5",
165
+ "@scalemule/sdk": "^0.0.72"
150
166
  }
151
167
  }