@scalemule/news-publication 0.0.35 → 0.0.36
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 +25 -0
- package/dist/chunk-IMYBSG7J.js +23 -0
- package/dist/community-maps-DKcYhQYN.d.cts +10 -0
- package/dist/community-maps-DKcYhQYN.d.ts +10 -0
- package/dist/community-maps-server.cjs +250 -0
- package/dist/community-maps-server.d.cts +16 -0
- package/dist/community-maps-server.d.ts +16 -0
- package/dist/community-maps-server.js +248 -0
- package/dist/community-maps-view.cjs +155 -0
- package/dist/community-maps-view.d.cts +22 -0
- package/dist/community-maps-view.d.ts +22 -0
- package/dist/community-maps-view.js +132 -0
- package/dist/index.cjs +43 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25 -5
- package/package.json +21 -5
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.0.36",
|
|
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.71"
|
|
150
166
|
}
|
|
151
167
|
}
|