@scalemule/news-publication 0.0.83 → 0.0.85

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
@@ -324,7 +324,7 @@ The publisher-supplied `PawMark` preserves the approved SVG paths. Hosts may set
324
324
 
325
325
  ### Homepage image identity
326
326
 
327
- `NewsItem.visual_identity` is a versioned public identity from the Blog feed. `composeEdition` allocates covers across the entire bounded inventory, gives the selected lead priority, and holds repeated covers as `null` without dropping stories or mutating inputs. Never re-add duplicate images as fallback. Legacy Photo rendition URLs are normalized by tenant/asset ID; server-side Photo identity detects copies under different IDs and ordinary resize/re-encoding/crop variants. Do not fetch or decode images in page components. Preserve existing caption/credit policy. See platform `docs/HOMEPAGE-PHOTO-IDENTITY.md` for storage, rights, concurrency and rollout tests.
327
+ `NewsItem.visual_identity` is a versioned public identity from the Blog feed. `composeEdition` allocates covers across the entire bounded inventory and holds repeated covers as `null` without mutating inputs. Lead, secondary, compact and section image placements require a usable distinct cover, including pinned stories. A held lead yields to the first ready story; an entirely unready inventory has no visual lead. Intentional briefs retain their editorial selection rules. `pendingImages` and the complete `navigation` inventory retain unresolved stories for repair and discovery: excluding them from image placements does not mean their photos were repaired. Consumer planners must not refill image placements from raw inventory or reintroduce duplicate covers. Legacy Photo rendition URLs are normalized by tenant/asset ID; server-side Photo identity detects copies under different IDs and ordinary resize/re-encoding/crop variants. Do not fetch or decode images in page components. Preserve existing caption/credit policy. See platform `docs/HOMEPAGE-PHOTO-IDENTITY.md` for storage, rights, concurrency and rollout tests.
328
328
 
329
329
  ### Standard article submission
330
330
 
@@ -377,7 +377,7 @@ matches one declared slot. Credentials are normal project/member credentials.
377
377
  The operation key is retained for 30 days. A legacy article must first have an
378
378
  explicitly selected collaboration policy; this endpoint never downgrades review.
379
379
 
380
- ### Edit article prose, cards and citations
380
+ ### Edit article prose, cards, citations and tags
381
381
 
382
382
  ```sh
383
383
  npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
@@ -385,7 +385,10 @@ npx --yes --package @scalemule/news-publication@0.0.81 scalemule-article edit \
385
385
  ```
386
386
 
387
387
  The patch may contain title, subtitle, content_markdown, excerpt, section,
388
- community, format, layout, cards and citations. It preserves omitted fields and
388
+ community, format, layout, cards, citations and `tag_ids`. Tag edits require SDK
389
+ 0.0.85+ and the deployed `versioned_editorial_tags` capability. Supply up to 32
390
+ existing tenant-owned tag UUIDs; an empty array clears assignments. Create or
391
+ select tags using the ordinary Blog tags API first. It preserves omitted fields and
389
392
  media and saves one immutable revision. Default: save for review. Add
390
393
  `--release` only to request release through the existing policy; the command
391
394
  never issues APPROVE or downgrades review. On a legacy article, explicitly choose
@@ -402,7 +405,7 @@ Unknown save/release responses reconcile against the original revision; another
402
405
  current revision stops rather than being overwritten. Completed replay and no-op
403
406
  edits add no revision. `RELEASE_HELD`/exit 2 preserves the saved revision while
404
407
  review is pending. With `--url --release`, success requires the rendered
405
- headline, prose and actual card components; public visibility pending is also
408
+ headline, prose, actual card components and requested canonical tag links; public visibility pending is also
406
409
  exit 2. A saved revision and a rendered published correction are separate states.
407
410
 
408
411
  Use the same client method, `client.editArticle`, for a caller-supplied durable
@@ -18,7 +18,7 @@ Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized mem
18
18
  Platform admins may use their normal access token with --application-id ID.
19
19
  Optional --api-url URL defaults to https://api.scalemule.com.
20
20
 
21
- edit saves prose, excerpt, cards and citations in one immutable revision.
21
+ edit saves prose, excerpt, cards, citations and tenant-owned tag_ids in one immutable revision.
22
22
  Unspecified fields and media are retained. Default: save for review.
23
23
  --release explicitly requests release under the existing policy; it never issues
24
24
  APPROVE or changes a policy. Legacy onboarding requires an explicit
@@ -157,11 +157,11 @@ try {
157
157
  while(!verified){
158
158
  const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
159
159
  const html=await response.text();
160
- verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title)});
160
+ verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title),tags:checkpoint.document.tag_ids?article.tags:undefined});
161
161
  if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
162
162
  }
163
163
  }catch(error){reason=error.message;}
164
- if(!verified){console.error(JSON.stringify({...result,state:'published_public_verification_pending',stage:'public_page',message:'The exact revision is published; rendered prose/cards remain unverified. Resume the same edit.',reason}));process.exitCode=2;}
164
+ if(!verified){console.error(JSON.stringify({...result,state:'published_public_verification_pending',stage:'public_page',message:'The exact revision is published; rendered prose/cards/tags remain unverified. Resume the same edit.',reason}));process.exitCode=2;}
165
165
  else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
166
166
  }else{
167
167
  console.log(JSON.stringify({...result,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
@@ -43,6 +43,17 @@ function articleEditHtmlMatches(html, expected) {
43
43
  const all = text(visible);
44
44
  if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
45
45
  return false;
46
+ if (expected.tags?.length) {
47
+ const links = [...visible.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/giu)];
48
+ if (!expected.tags.every((tag) => links.some((link) => {
49
+ try {
50
+ const url = new URL(decode(link[1]), "https://publication.example");
51
+ return url.pathname === "/news" && url.searchParams.get("tag") === tag.slug && text(link[2]).replace(/^#\s*/u, "") === tag.name;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }))) return false;
56
+ }
46
57
  const titles = [
47
58
  ...visible.matchAll(
48
59
  /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
@@ -365,7 +376,8 @@ var patchKeys = /* @__PURE__ */ new Set([
365
376
  "format",
366
377
  "layout",
367
378
  "cards",
368
- "citations"
379
+ "citations",
380
+ "tag_ids"
369
381
  ]);
370
382
  function validate(input) {
371
383
  if (!input.operationKey || !/^[\w.:-]{1,128}$/u.test(input.operationKey) || !input.articleId || !input.patch || typeof input.patch !== "object" || Array.isArray(input.patch) || !Object.keys(input.patch).length)
@@ -374,7 +386,10 @@ function validate(input) {
374
386
  );
375
387
  for (const [key, value] of Object.entries(input.patch)) {
376
388
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
377
- if (["cards", "citations"].includes(key)) {
389
+ if (key === "tag_ids") {
390
+ if (!Array.isArray(value) || value.length > 32 || new Set(value).size !== value.length || value.some((id) => typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(id) || /^0{8}-0{4}-0{4}-0{4}-0{12}$/u.test(id)))
391
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
392
+ } else if (["cards", "citations"].includes(key)) {
378
393
  if (!Array.isArray(value)) throw Error(key + " must be an array");
379
394
  } else if (key === "layout") {
380
395
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -458,6 +473,8 @@ async function runArticleEdit(t, input) {
458
473
  stage,
459
474
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
460
475
  );
476
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
477
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
461
478
  if (!identityMatches(source))
462
479
  throw new ArticleEditOperationError(
463
480
  "ARTICLE_IDENTITY_CHANGED",
@@ -621,6 +638,8 @@ async function runArticleEdit(t, input) {
621
638
  409,
622
639
  state.revisionId
623
640
  );
641
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
642
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
624
643
  const normalizedCards = state.document?.cards;
625
644
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
626
645
  throw new ArticleEditOperationError(
@@ -8,6 +8,10 @@ declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
9
  content_html: string;
10
10
  card_titles: string[];
11
+ tags?: {
12
+ name: string;
13
+ slug: string;
14
+ }[];
11
15
  }): boolean;
12
16
 
13
17
  type PhotoOperationCheckpoint = {
@@ -48,7 +52,7 @@ declare class ArticlePhotoOperationError extends Error {
48
52
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
49
53
  }
50
54
 
51
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
55
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
52
56
  type ArticleEditCheckpoint = {
53
57
  version: 1;
54
58
  identity: string;
@@ -93,6 +97,7 @@ type ArticleDocument = {
93
97
  excerpt?: string;
94
98
  cards?: unknown[];
95
99
  citations?: unknown[];
100
+ tag_ids?: string[];
96
101
  section: string;
97
102
  community?: string;
98
103
  format?: ArticleFormat;
@@ -195,6 +200,7 @@ type ArticleWorkflowOptions = {
195
200
  prioritized_narration_processing?: boolean;
196
201
  content_first_editor_document?: boolean;
197
202
  versioned_editorial_fields?: boolean;
203
+ versioned_editorial_tags?: boolean;
198
204
  managed_submission_version?: number | null;
199
205
  multipart_submission?: boolean;
200
206
  };
@@ -8,6 +8,10 @@ declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
9
  content_html: string;
10
10
  card_titles: string[];
11
+ tags?: {
12
+ name: string;
13
+ slug: string;
14
+ }[];
11
15
  }): boolean;
12
16
 
13
17
  type PhotoOperationCheckpoint = {
@@ -48,7 +52,7 @@ declare class ArticlePhotoOperationError extends Error {
48
52
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
49
53
  }
50
54
 
51
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations">>;
55
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
52
56
  type ArticleEditCheckpoint = {
53
57
  version: 1;
54
58
  identity: string;
@@ -93,6 +97,7 @@ type ArticleDocument = {
93
97
  excerpt?: string;
94
98
  cards?: unknown[];
95
99
  citations?: unknown[];
100
+ tag_ids?: string[];
96
101
  section: string;
97
102
  community?: string;
98
103
  format?: ArticleFormat;
@@ -195,6 +200,7 @@ type ArticleWorkflowOptions = {
195
200
  prioritized_narration_processing?: boolean;
196
201
  content_first_editor_document?: boolean;
197
202
  versioned_editorial_fields?: boolean;
203
+ versioned_editorial_tags?: boolean;
198
204
  managed_submission_version?: number | null;
199
205
  multipart_submission?: boolean;
200
206
  };
@@ -41,6 +41,17 @@ function articleEditHtmlMatches(html, expected) {
41
41
  const all = text(visible);
42
42
  if (!paragraphs.length || !paragraphs.every((p) => all.includes(p)))
43
43
  return false;
44
+ if (expected.tags?.length) {
45
+ const links = [...visible.matchAll(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/giu)];
46
+ if (!expected.tags.every((tag) => links.some((link) => {
47
+ try {
48
+ const url = new URL(decode(link[1]), "https://publication.example");
49
+ return url.pathname === "/news" && url.searchParams.get("tag") === tag.slug && text(link[2]).replace(/^#\s*/u, "") === tag.name;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }))) return false;
54
+ }
44
55
  const titles = [
45
56
  ...visible.matchAll(
46
57
  /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
@@ -363,7 +374,8 @@ var patchKeys = /* @__PURE__ */ new Set([
363
374
  "format",
364
375
  "layout",
365
376
  "cards",
366
- "citations"
377
+ "citations",
378
+ "tag_ids"
367
379
  ]);
368
380
  function validate(input) {
369
381
  if (!input.operationKey || !/^[\w.:-]{1,128}$/u.test(input.operationKey) || !input.articleId || !input.patch || typeof input.patch !== "object" || Array.isArray(input.patch) || !Object.keys(input.patch).length)
@@ -372,7 +384,10 @@ function validate(input) {
372
384
  );
373
385
  for (const [key, value] of Object.entries(input.patch)) {
374
386
  if (!patchKeys.has(key)) throw Error("Unsupported edit field: " + key);
375
- if (["cards", "citations"].includes(key)) {
387
+ if (key === "tag_ids") {
388
+ if (!Array.isArray(value) || value.length > 32 || new Set(value).size !== value.length || value.some((id) => typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(id) || /^0{8}-0{4}-0{4}-0{4}-0{12}$/u.test(id)))
389
+ throw Error("tag_ids must contain at most 32 distinct, nonempty UUIDs");
390
+ } else if (["cards", "citations"].includes(key)) {
376
391
  if (!Array.isArray(value)) throw Error(key + " must be an array");
377
392
  } else if (key === "layout") {
378
393
  if (!value || typeof value !== "object" || typeof value.id !== "string" || !Number.isInteger(value.version))
@@ -456,6 +471,8 @@ async function runArticleEdit(t, input) {
456
471
  stage,
457
472
  "The server must expose versioned_editorial_fields before this edit; no article was changed"
458
473
  );
474
+ if (input.patch.tag_ids !== void 0 && options.capabilities?.versioned_editorial_tags !== true)
475
+ throw new ArticleEditOperationError("CAPABILITY_UNAVAILABLE", stage, "The server must expose versioned_editorial_tags before changing tags; no article was changed");
459
476
  if (!identityMatches(source))
460
477
  throw new ArticleEditOperationError(
461
478
  "ARTICLE_IDENTITY_CHANGED",
@@ -619,6 +636,8 @@ async function runArticleEdit(t, input) {
619
636
  409,
620
637
  state.revisionId
621
638
  );
639
+ if (state.document?.tag_ids && !equal([...state.document.tag_ids].sort(), (after.tags ?? []).map((tag) => tag.id).sort()))
640
+ throw new ArticleEditOperationError("VERIFICATION_CONFLICT", stage, "Published tags differ from the saved revision", 409, state.revisionId);
622
641
  const normalizedCards = state.document?.cards;
623
642
  if (normalizedCards && after.metadata?.cards?.length !== normalizedCards.length)
624
643
  throw new ArticleEditOperationError(
package/dist/index.cjs CHANGED
@@ -188,7 +188,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
188
188
  return true;
189
189
  });
190
190
  const pins = !selection.pinned_until || Date.parse(selection.pinned_until) > now ? selection : {};
191
- const selectedLead = inventory.find((item) => item.slug === pins.lead_slug) ?? inventory[0];
191
+ const selectedLead = inventory.find((item) => item.slug === pins.lead_slug && coverIdentity(item)) ?? inventory.find((item) => coverIdentity(item));
192
192
  const rest = inventory.filter((item) => item !== selectedLead);
193
193
  const requested = new Set(pins.secondary_slugs ?? []);
194
194
  const ordered = [
@@ -197,13 +197,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
197
197
  ];
198
198
  const originalOrder = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
199
199
  const seenImages = /* @__PURE__ */ new Set();
200
- const duplicateImages = /* @__PURE__ */ new Set();
201
200
  const display = /* @__PURE__ */ new Map();
202
201
  for (const item of [selectedLead, ...originalOrder]) {
203
202
  if (!item) continue;
204
203
  const key = coverIdentity(item);
205
204
  if (key && seenImages.has(key)) {
206
- duplicateImages.add(item.id);
207
205
  display.set(item.id, { ...item, cover_image_url: null });
208
206
  } else {
209
207
  display.set(item.id, item);
@@ -214,14 +212,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
214
212
  const lead = selectedLead ? display.get(selectedLead.id) : void 0;
215
213
  let remaining = rest.map((item) => display.get(item.id));
216
214
  const uniqueOrdered = originalOrder.map((item) => display.get(item.id));
217
- const secondary = [];
218
- const deferred = [];
219
- for (const item of uniqueOrdered) {
220
- if (secondary.length >= 3) break;
221
- if (duplicateImages.has(item.id)) deferred.push(item);
222
- else secondary.push(item);
223
- }
224
- while (secondary.length < 3 && deferred.length > 0) secondary.push(deferred.shift());
215
+ const secondary = uniqueOrdered.filter((item) => coverIdentity(item)).slice(0, 3);
225
216
  const secondaryIds = new Set(secondary.map((item) => item.id));
226
217
  remaining = remaining.filter((item) => !secondaryIds.has(item.id));
227
218
  const eligible2 = remaining.filter((item) => {
@@ -236,10 +227,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
236
227
  briefIds.add(item.id);
237
228
  return true;
238
229
  });
239
- const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].slice(0, 4);
230
+ const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].filter((item) => coverIdentity(item)).slice(0, 4);
240
231
  const compactIds = new Set(compact.map((item) => item.id));
241
- const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id)));
242
- return { lead, secondary, briefs, compact, sections: sections2, navigation: editorialSections(inventory) };
232
+ const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id) && coverIdentity(item)));
233
+ const pendingImages = inventory.filter((item) => !coverIdentity(item));
234
+ return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
243
235
  }
244
236
  function displayHeadline(article, selection = {}) {
245
237
  return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
package/dist/index.d.cts CHANGED
@@ -246,6 +246,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
246
246
  briefs: NewsItem[];
247
247
  compact: NewsItem[];
248
248
  sections: EditorialSection[];
249
+ pendingImages: NewsItem[];
249
250
  navigation: EditorialSection[];
250
251
  };
251
252
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
package/dist/index.d.ts CHANGED
@@ -246,6 +246,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
246
246
  briefs: NewsItem[];
247
247
  compact: NewsItem[];
248
248
  sections: EditorialSection[];
249
+ pendingImages: NewsItem[];
249
250
  navigation: EditorialSection[];
250
251
  };
251
252
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
package/dist/index.js CHANGED
@@ -132,7 +132,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
132
132
  return true;
133
133
  });
134
134
  const pins = !selection.pinned_until || Date.parse(selection.pinned_until) > now ? selection : {};
135
- const selectedLead = inventory.find((item) => item.slug === pins.lead_slug) ?? inventory[0];
135
+ const selectedLead = inventory.find((item) => item.slug === pins.lead_slug && coverIdentity(item)) ?? inventory.find((item) => coverIdentity(item));
136
136
  const rest = inventory.filter((item) => item !== selectedLead);
137
137
  const requested = new Set(pins.secondary_slugs ?? []);
138
138
  const ordered = [
@@ -141,13 +141,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
141
141
  ];
142
142
  const originalOrder = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
143
143
  const seenImages = /* @__PURE__ */ new Set();
144
- const duplicateImages = /* @__PURE__ */ new Set();
145
144
  const display = /* @__PURE__ */ new Map();
146
145
  for (const item of [selectedLead, ...originalOrder]) {
147
146
  if (!item) continue;
148
147
  const key = coverIdentity(item);
149
148
  if (key && seenImages.has(key)) {
150
- duplicateImages.add(item.id);
151
149
  display.set(item.id, { ...item, cover_image_url: null });
152
150
  } else {
153
151
  display.set(item.id, item);
@@ -158,14 +156,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
158
156
  const lead = selectedLead ? display.get(selectedLead.id) : void 0;
159
157
  let remaining = rest.map((item) => display.get(item.id));
160
158
  const uniqueOrdered = originalOrder.map((item) => display.get(item.id));
161
- const secondary = [];
162
- const deferred = [];
163
- for (const item of uniqueOrdered) {
164
- if (secondary.length >= 3) break;
165
- if (duplicateImages.has(item.id)) deferred.push(item);
166
- else secondary.push(item);
167
- }
168
- while (secondary.length < 3 && deferred.length > 0) secondary.push(deferred.shift());
159
+ const secondary = uniqueOrdered.filter((item) => coverIdentity(item)).slice(0, 3);
169
160
  const secondaryIds = new Set(secondary.map((item) => item.id));
170
161
  remaining = remaining.filter((item) => !secondaryIds.has(item.id));
171
162
  const eligible2 = remaining.filter((item) => {
@@ -180,10 +171,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
180
171
  briefIds.add(item.id);
181
172
  return true;
182
173
  });
183
- const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].slice(0, 4);
174
+ const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].filter((item) => coverIdentity(item)).slice(0, 4);
184
175
  const compactIds = new Set(compact.map((item) => item.id));
185
- const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id)));
186
- return { lead, secondary, briefs, compact, sections: sections2, navigation: editorialSections(inventory) };
176
+ const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id) && coverIdentity(item)));
177
+ const pendingImages = inventory.filter((item) => !coverIdentity(item));
178
+ return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
187
179
  }
188
180
  function displayHeadline(article, selection = {}) {
189
181
  return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.83",
3
+ "version": "0.0.85",
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
  "bin": {