@scalemule/news-publication 0.0.79 → 0.0.80

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
@@ -338,6 +338,45 @@ The platform contract and a complete example live in
338
338
  `docs/news-platform/ARTICLE-WORKFLOW-V1.md` in the ScaleMule meta-repo.
339
339
  Do not use direct SQL, S3 paths, base64 bodies or invented media readiness.
340
340
 
341
+ ### Replace a published article photo from a local file
342
+
343
+ ```sh
344
+ npx --yes --package @scalemule/news-publication@0.0.80 scalemule-article replace-photo \
345
+ --url "$ARTICLE_URL" --file ./venue-exterior.jpg \
346
+ --metadata ./photo.json --operation-key venue-exterior-v1
347
+ ```
348
+
349
+ Use the normal authorized member's `SCALEMULE_API_KEY` and
350
+ `SCALEMULE_ACCESS_TOKEN`. The caller needs developer-level asset access and
351
+ publication approval rights. Supply caption, credit, factual alt, confirmed rights,
352
+ explicit `aiGenerated` and `label` (null for an ordinary photo) in `photo.json`.
353
+ The URL resolves the authorized publication and article; no UUID search or
354
+ publication build is needed. This does not find a photo or establish rights.
355
+
356
+ `uploadAndReplacePublishedCover` implements the same operation for applications.
357
+ It transfers bytes through managed Storage, preserves private capture evidence,
358
+ waits for a clean scan, registers Photo, prepares the publication's lead,
359
+ thumbnail and social renditions, reads measured delivery receipts and performs
360
+ one article-version-fenced media write. It verifies unchanged text/audio and,
361
+ when given `--url`, checks the selected lead and social URLs in the public HTML.
362
+ It requires the deployed content-first document and member Photo endpoints.
363
+
364
+ A private atomic checkpoint lives under `~/.scalemule/newsroom/operations`
365
+ (or `--state PATH`). Keep it: it contains a temporary upload credential until
366
+ completion. Reuse the same file, metadata and operation key after interruption.
367
+ A completed replay verifies the same attachment without uploading or writing
368
+ again. A concurrent article change stops instead of overwriting the editor.
369
+ The default per-run deadline is 30 seconds, adjustable with `--timeout-ms` up to
370
+ 120 seconds; timeouts name the blocked stage and preserve progress. Exit 2 means
371
+ API attachment succeeded but public HTML visibility is still pending. A timeout
372
+ is not a success receipt. This is local resumability, not a distributed
373
+ exactly-once service or a guaranteed image-processing SLA.
374
+
375
+ This published-cover convenience lane stops before upload for collaboration
376
+ articles. Their immutable revision, approval and release gates still apply.
377
+ Animated GIF/APNG/WebP and videos use the Video workflow; this lane accepts
378
+ still JPEG, PNG and WebP up to 25 MiB.
379
+
341
380
  ### Replace an existing, prepared cover
342
381
 
343
382
  For a legacy published article and an already public, scanned Photo asset,
@@ -1,16 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
2
+ import { readFile, mkdir, writeFile, rename, open, unlink } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, basename } from 'node:path';
5
+ import { createHash } from 'node:crypto';
3
6
  import { parseArgs } from 'node:util';
4
- import { PublicationArticleClient } from '../dist/article-workflow-client.js';
7
+ import { PublicationArticleClient, articlePhotoHtmlMatches } from '../dist/article-workflow-client.js';
5
8
 
6
9
  const help = `Usage:
7
10
  scalemule-article inspect --publication ID --article ID
8
11
  scalemule-article replace-cover --publication ID --article ID --file-id ID --expected-version N --metadata photo.json
12
+ scalemule-article replace-photo --url ARTICLE_URL --file PHOTO --metadata photo.json --operation-key KEY
9
13
 
10
14
  Credentials: SCALEMULE_API_KEY and SCALEMULE_ACCESS_TOKEN (normal authorized member).
11
15
  Platform admins may use their normal access token with --application-id ID.
12
16
  Optional --api-url URL defaults to https://api.scalemule.com.
13
17
 
18
+ replace-photo accepts a local file and uses normal member/API-key credentials.
19
+ It requires developer-level asset access and publication approval rights.
20
+ It resolves the publication from its authorized API inventory, saves a private
21
+ upload checkpoint, resumes the same multipart part, waits for scan and measured
22
+ publication renditions, then attaches with the original article version fence.
23
+ It verifies article invariants and, with --url, the public page. The default
24
+ deadline is 30 seconds per run; a failure names its stage and preserves progress.
25
+ Reuse the same --operation-key, metadata and file when resuming. Optional
26
+ --state PATH and --timeout-ms N (maximum 120000). Review-managed articles stop
27
+ before uploading and require their existing exact-revision editorial workflow.
28
+
14
29
  replace-cover selects an already public, scanned Photo asset. It reads measured
15
30
  hero and social renditions, preserves the article text, and changes media with
16
31
  the supplied version fence. It never uploads raw bytes, republishes the story,
@@ -29,13 +44,35 @@ that every website/CDN cache already displays the new image.
29
44
  try {
30
45
  const { values, positionals } = parseArgs({
31
46
  allowPositionals: true, strict: true,
32
- options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
47
+ options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url', 'url', 'file', 'operation-key', 'state', 'timeout-ms'].map(k => [k, { type: 'string' }]).concat([['help', { type: 'boolean' }]])),
33
48
  });
34
49
  if (values.help || !positionals.length) {
35
50
  console.log(help);
36
51
  } else {
37
52
  const [command, ...extra] = positionals;
38
- if (extra.length || !['inspect', 'replace-cover'].includes(command)) throw Error('Use inspect or replace-cover; see --help');
53
+ const commandStarted = performance.now();
54
+ const budget = Number(values['timeout-ms'] ?? 30000);
55
+ if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
56
+ const remaining = () => { const ms = Math.floor(budget - (performance.now()-commandStarted)); if (ms < 1) throw Error('Operation deadline reached; resume the same operation'); return ms; };
57
+ if (extra.length || !['inspect', 'replace-cover', 'replace-photo'].includes(command)) throw Error('Use inspect, replace-cover or replace-photo; see --help');
58
+ if (values.url) {
59
+ if (!process.env.SCALEMULE_API_KEY || !process.env.SCALEMULE_ACCESS_TOKEN || values['application-id']) throw Error('URL resolution requires normal publication member/API-key credentials');
60
+ const url = new URL(values.url);
61
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw Error('Use the public HTTPS article URL, without a private review key');
62
+ const api = values['api-url'] ?? 'https://api.scalemule.com';
63
+ const apiOrigin = new URL(api); if (apiOrigin.protocol !== 'https:' || apiOrigin.username || apiOrigin.password) throw Error('Use an HTTPS platform API endpoint');
64
+ const headers = { 'x-api-key': process.env.SCALEMULE_API_KEY, authorization: 'Bearer '+process.env.SCALEMULE_ACCESS_TOKEN };
65
+ const get = async route => { const r = await fetch(api.replace(/\/$/,'')+route,{headers,redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))}); const v = await r.json(); if (!r.ok || !v.success) throw Error(`Article resolution HTTP ${r.status}`); return v.data; };
66
+ const pubs = await get('/v1/blog/publications');
67
+ const pub = pubs.items?.find(p=>new URL(p.canonical_url).origin===url.origin);
68
+ if (!pub) throw Error('The caller has no publication grant for that article origin');
69
+ const match = /^\/news\/([^/]+)\/?$/.exec(url.pathname);
70
+ if (!match) throw Error('Use the publication article slug URL');
71
+ const resolved = await get(`/v1/blog/publications/${encodeURIComponent(pub.id)}/article-content/${encodeURIComponent(decodeURIComponent(match[1]))}`);
72
+ if (!resolved.article?.id) throw Error('No published article at this URL');
73
+ if ((values.publication && values.publication !== pub.id) || (values.article && values.article !== resolved.article.id)) throw Error('Article URL and supplied IDs disagree');
74
+ values.publication = pub.id; values.article = resolved.article.id;
75
+ }
39
76
  for (const key of ['publication', 'article']) if (!values[key]) throw Error(`Missing --${key}`);
40
77
  if (!process.env.SCALEMULE_ACCESS_TOKEN) throw Error('SCALEMULE_ACCESS_TOKEN is required; use normal member or platform admin authorization');
41
78
  if (!values['application-id'] && !process.env.SCALEMULE_API_KEY) throw Error('Member calls also require SCALEMULE_API_KEY');
@@ -62,6 +99,73 @@ try {
62
99
  capabilities: options.capabilities, narration: processing.narration,
63
100
  assets: processing.assets, collaboration, elapsed_ms: Math.round(performance.now() - started),
64
101
  }, null, 2));
102
+ } else if (command === 'replace-photo') {
103
+ for (const key of ['file','metadata','operation-key']) if (!values[key]) throw Error(`Missing --${key}`);
104
+ if (values['application-id']) throw Error('Local-photo intake uses normal publication member credentials');
105
+ const metadata = JSON.parse(await readFile(values.metadata,'utf8'));
106
+ for (const key of ['caption','credit','alt']) if (typeof metadata[key] !== 'string') throw Error(`photo.json requires a string ${key}`);
107
+ const key = createHash('sha256').update(JSON.stringify([values['api-url']??'https://api.scalemule.com',values.publication,values.article,values['operation-key']])).digest('hex');
108
+ const statePath = values.state ?? join(homedir(),'.scalemule','newsroom','operations',key+'.json');
109
+ await mkdir(dirname(statePath),{recursive:true,mode:0o700});
110
+ const lockPath = statePath+'.lock';
111
+ let lock;
112
+ try { lock = await open(lockPath,'wx',0o600); }
113
+ catch (error) {
114
+ if (error.code !== 'EEXIST') throw error;
115
+ const pid = Number(await readFile(lockPath,'utf8'));
116
+ if (!Number.isSafeInteger(pid) || pid < 1) throw Error('Operation lock is invalid; inspect '+lockPath);
117
+ let alive = true; try { process.kill(pid,0); } catch (e) { if (e.code === 'ESRCH') alive = false; else throw e; }
118
+ if (alive) throw Error('This operation is already running; use its existing result');
119
+ await unlink(lockPath); lock = await open(lockPath,'wx',0o600);
120
+ }
121
+ await lock.writeFile(String(process.pid)); await lock.close();
122
+ try {
123
+ let checkpoint;
124
+ try { checkpoint = JSON.parse(await readFile(statePath,'utf8')); } catch(error) { if (error.code !== 'ENOENT') throw error; }
125
+ const expectedVersion = Number(values['expected-version'] ?? checkpoint?.expectedVersion ?? (await client.processing(values.article,AbortSignal.timeout(remaining()))).version);
126
+ const bytes = await readFile(values.file);
127
+ const saveCheckpoint = async state => { const temp = statePath+'.'+process.pid+'.tmp'; await writeFile(temp,JSON.stringify(state),{mode:0o600}); await rename(temp,statePath); checkpoint = state; };
128
+ const result = await client.uploadAndReplacePublishedCover({
129
+ articleId:values.article, expectedVersion, operationKey:values['operation-key'],file:new Blob([bytes]),filename:basename(values.file),
130
+ caption:metadata.caption,credit:metadata.credit,alt:metadata.alt,rightsConfirmed:metadata.rightsConfirmed,aiGenerated:metadata.aiGenerated,label:metadata.label,focalPoint:metadata.focalPoint,
131
+ checkpoint,saveCheckpoint,timeoutMs:remaining(),
132
+ onProgress:event=>console.error(JSON.stringify(event)),
133
+ });
134
+ if (values.url) {
135
+ let visible = false;
136
+ let failure;
137
+ try {
138
+ const images = [checkpoint?.payload?.cover_image_url, checkpoint?.payload?.og_image_url];
139
+ const leads = new Set([images[0],...(checkpoint?.payload?.cover_image?.variants??[]).map(v=>v.url)].filter(Boolean));
140
+ while (!visible) {
141
+ const response = await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(10000,remaining()))});
142
+ const html = await response.text();
143
+ visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
144
+ if (!visible) {
145
+ // Sites may select another measured responsive rendition of the
146
+ // same Photo. Read that evidence; never infer a CDN path or ID.
147
+ await Promise.all(['image/webp','image/jpeg','image/avif'].map(async accept=>{
148
+ const api=(values['api-url']??'https://api.scalemule.com').replace(/\/$/,'');
149
+ const r=await fetch(api+'/v1/photos/public/'+encodeURIComponent(result.file_id)+'/manifest?preset=hero',{headers:{accept},redirect:'error',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
150
+ const value=await r.json();
151
+ if(!r.ok || !value.success || value.data?.file_id!==result.file_id) return;
152
+ for(const v of Object.values(value.data.variant_metadata??{})) {
153
+ if(!Number.isInteger(v.width)||v.width<1||!Number.isInteger(v.height)||v.height<1) continue;
154
+ const u=new URL(v.url);
155
+ if(u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash) leads.add(v.url);
156
+ }
157
+ }));
158
+ visible = response.ok && images.every(Boolean) && articlePhotoHtmlMatches(html,[...leads],images[1]);
159
+ }
160
+ if (!visible) await new Promise(resolve=>setTimeout(resolve,Math.min(500,remaining())));
161
+ }
162
+ } catch(error) { failure = error.message; }
163
+ if (!visible) {
164
+ console.error(JSON.stringify({...result,state:'attached_public_verification_pending',stage:'public_page',message:'The API attachment is verified; public HTML visibility remains pending. Resume this same operation.',reason:failure}));
165
+ process.exitCode = 2;
166
+ } else console.log(JSON.stringify({...result,public_page_verified:true,total_elapsed_ms:Math.round(performance.now()-commandStarted)},null,2));
167
+ } else console.log(JSON.stringify(result,null,2));
168
+ } finally { await unlink(lockPath); }
65
169
  } else {
66
170
  for (const key of ['file-id', 'expected-version', 'metadata']) if (!values[key]) throw Error(`Missing --${key}`);
67
171
  const metadata = JSON.parse(await readFile(values.metadata, 'utf8'));
@@ -78,6 +182,6 @@ try {
78
182
  }
79
183
  }
80
184
  } catch (error) {
81
- console.error(JSON.stringify({ error: error.message, status: error.status }));
185
+ console.error(JSON.stringify({ error: error.message, status: error.status, code:error.code, stage:error.stage, file_id:error.fileId }));
82
186
  process.exitCode = 1;
83
187
  }
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
18
18
  declare function articleHeroRatio(layout: ArticleLayout): number;
19
19
  declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
20
20
 
21
- export { type ArticleFormat as A, type ArticleLayout as a, type ArticlePresentation as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
21
+ export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
@@ -18,4 +18,4 @@ declare function articlePresentation(value?: ArticlePresentation | null): Articl
18
18
  declare function articleHeroRatio(layout: ArticleLayout): number;
19
19
  declare function articleFormatLabel(format?: ArticleFormat | null): string | undefined;
20
20
 
21
- export { type ArticleFormat as A, type ArticleLayout as a, type ArticlePresentation as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
21
+ export { type ArticleFormat as A, type ArticlePresentation as a, type ArticleLayout as b, articleFormatLabel as c, articleHeroRatio as d, articlePresentation as e };
@@ -1,5 +1,292 @@
1
1
  'use strict';
2
2
 
3
+ // src/article-photo-verification.ts
4
+ function articlePhotoHtmlMatches(html, lead, social) {
5
+ const contains = (tag, url) => tag.includes(url) || tag.includes(encodeURIComponent(url));
6
+ const leads = Array.isArray(lead) ? lead : [lead];
7
+ const image = [...html.matchAll(/<img\b[^>]*>/giu)].some(([tag]) => leads.some((url) => contains(tag, url)));
8
+ const openGraph = [...html.matchAll(/<meta\b[^>]*>/giu)].some(([tag]) => {
9
+ const attrs = Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)].map((m) => [m[1].toLowerCase(), m[2] ?? m[3]]));
10
+ return (attrs.property ?? attrs.name)?.toLowerCase() === "og:image" && attrs.content === social;
11
+ });
12
+ return image && openGraph;
13
+ }
14
+
15
+ // src/article-photo-operation.ts
16
+ var ArticlePhotoOperationError = class extends Error {
17
+ constructor(code, stage, message, fileId, status) {
18
+ super(message);
19
+ this.code = code;
20
+ this.stage = stage;
21
+ this.fileId = fileId;
22
+ this.status = status;
23
+ this.name = "ArticlePhotoOperationError";
24
+ }
25
+ code;
26
+ stage;
27
+ fileId;
28
+ status;
29
+ };
30
+ var digest = async (value) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map((v) => v.toString(16).padStart(2, "0")).join("");
31
+ var hash = (value) => digest(new TextEncoder().encode(JSON.stringify(value)));
32
+ var canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
33
+ var stableArticle = (p) => [
34
+ p.id,
35
+ p.slug,
36
+ p.title,
37
+ p.subtitle,
38
+ p.content_markdown,
39
+ p.content_html,
40
+ p.status,
41
+ p.visibility,
42
+ p.published_at,
43
+ p.author_user_id,
44
+ p.audio_file_id,
45
+ Object.fromEntries(Object.entries(p.metadata ?? {}).filter(([k]) => !["cover_image", "open_graph_image", "visual_desk_receipt"].includes(k)).sort(([a], [b]) => a.localeCompare(b)))
46
+ ];
47
+ function photoType(bytes) {
48
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
49
+ return "image/jpeg";
50
+ const ascii = (n, length) => String.fromCharCode(...bytes.subarray(n, n + length));
51
+ const png = ascii(0, 8) === "\x89PNG\r\n\n";
52
+ const webp = ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
53
+ if (!png && !webp)
54
+ throw Error("Use a still JPEG, PNG or WebP photo; animations use the Video workflow");
55
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56
+ for (let n = png ? 8 : 12, chunks = 0; n < bytes.length; chunks++) {
57
+ if (chunks >= 1e4 || n + 8 > bytes.length)
58
+ throw Error("Invalid or truncated photo container");
59
+ const kind = ascii(png ? n + 4 : n, 4);
60
+ if (["acTL", "ANIM", "ANMF"].includes(kind))
61
+ throw Error("Animated photos require the Video workflow");
62
+ const size = view.getUint32(png ? n : n + 4, webp);
63
+ n += size + (png ? 12 : 8 + size % 2);
64
+ if (n > bytes.length)
65
+ throw Error("Truncated photo container");
66
+ if (png && kind === "IEND")
67
+ break;
68
+ }
69
+ return png ? "image/png" : "image/webp";
70
+ }
71
+ function immutableUrl(raw) {
72
+ try {
73
+ const u = new URL(String(raw));
74
+ return u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ var measured = (m, file) => m?.ready === true && m.file_id === file && immutableUrl(m.url) && Number.isInteger(m.width) && m.width > 0 && m.width <= 12e3 && Number.isInteger(m.height) && m.height > 0 && m.height <= 12e3;
80
+ var mediaMatches = (post, payload) => post.cover_image_url === payload.cover_image_url && post.og_image_url === payload.og_image_url && JSON.stringify(canonical(post.metadata?.cover_image)) === JSON.stringify(canonical(payload.cover_image)) && JSON.stringify(canonical(post.metadata?.open_graph_image)) === JSON.stringify(canonical(payload.open_graph_image));
81
+ async function runArticlePhoto(t, input) {
82
+ const started = performance.now();
83
+ if (!input.operationKey || input.operationKey.length > 128 || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1 || input.rightsConfirmed !== true || !input.alt?.trim() || typeof input.aiGenerated !== "boolean" || input.aiGenerated && !input.label?.trim())
84
+ throw Error("Supply a stable operation key, current article version, confirmed rights, factual alt and explicit provenance");
85
+ if (typeof input.caption !== "string" || typeof input.credit !== "string" || !(input.label === null || typeof input.label === "string"))
86
+ throw Error("Supply explicit caption, credit and provenance label");
87
+ for (const [name, value, limit] of [["caption", input.caption, 2e3], ["credit", input.credit, 8e3], ["alt", input.alt, 1e3], ["label", input.label ?? "", 200]]) {
88
+ if ([...value].length > limit) throw Error(`Photo ${name} exceeds ${limit} characters`);
89
+ }
90
+ const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
91
+ if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
92
+ throw Error("Focal point must be within the image");
93
+ if (!input.file.size || input.file.size > 25 * 1024 * 1024)
94
+ throw Error("A photo must be between 1 byte and 25 MiB");
95
+ const bytes = new Uint8Array(await input.file.arrayBuffer());
96
+ const mime = photoType(bytes);
97
+ const identity = await hash([t.publicationId, input.articleId, input.operationKey, await digest(bytes), input.expectedVersion, input.caption, input.credit, input.alt, input.rightsConfirmed, input.aiGenerated, input.label, input.focalPoint ?? { x: 0.5, y: 0.5 }]);
98
+ if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity))
99
+ throw new ArticlePhotoOperationError("OPERATION_CONFLICT", "validation", "The saved operation belongs to different input; do not overwrite it");
100
+ let state = input.checkpoint ? structuredClone(input.checkpoint) : void 0;
101
+ let stage = "preflight";
102
+ const deadline = AbortSignal.timeout(Math.min(Math.max(input.timeoutMs ?? 3e4, 100), 12e4));
103
+ const request = (path, method, body) => t.request(path, method, body, void 0, deadline);
104
+ const progress = async (next) => {
105
+ stage = next;
106
+ if (state) {
107
+ state.stage = next;
108
+ await input.saveCheckpoint(structuredClone(state));
109
+ }
110
+ input.onProgress?.({ stage, elapsed_ms: Math.round(performance.now() - started), file_id: state?.fileId });
111
+ };
112
+ const sleep = () => new Promise((resolve, reject) => {
113
+ deadline.throwIfAborted();
114
+ const id = setTimeout(() => {
115
+ deadline.removeEventListener("abort", abort);
116
+ resolve();
117
+ }, 300);
118
+ const abort = () => {
119
+ clearTimeout(id);
120
+ reject(deadline.reason);
121
+ };
122
+ deadline.addEventListener("abort", abort, { once: true });
123
+ });
124
+ const postPath = `/articles/${encodeURIComponent(input.articleId)}/document`;
125
+ try {
126
+ const [post, options, collaboration, policy] = await Promise.all([
127
+ request(postPath),
128
+ request(`/article-workflow/options?article_id=${encodeURIComponent(input.articleId)}`),
129
+ request(`/articles/${encodeURIComponent(input.articleId)}/collaboration`).then(() => true).catch((error) => {
130
+ if (error.status === 404 && error.message === "Story collaboration is not enabled")
131
+ return false;
132
+ throw error;
133
+ }),
134
+ request(`/v1/photos/policies/article/editor?publication_id=${encodeURIComponent(t.publicationId)}`)
135
+ ]);
136
+ if (post.status !== "published" || post.visibility !== "public" || options.workflow_version !== 1)
137
+ throw new ArticlePhotoOperationError("ARTICLE_NOT_PUBLIC", stage, "Select a currently published public article");
138
+ if (collaboration)
139
+ throw new ArticlePhotoOperationError("REVISION_WORKFLOW_REQUIRED", stage, "This story requires an immutable media revision and editorial release; no upload or public mutation was made");
140
+ if (state?.payload && mediaMatches(post, state.payload)) {
141
+ if (await hash(stableArticle(post)) !== state.articleHash)
142
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The photo is attached, but other article content changed; inspect before continuing", state.fileId);
143
+ state.attachedVersion = post.version;
144
+ await progress("verified");
145
+ return { article_id: input.articleId, file_id: state.fileId, version: post.version, state: "already_applied", elapsed_ms: Math.round(performance.now() - started) };
146
+ }
147
+ if (post.version !== input.expectedVersion)
148
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article version changed; existing text and media were preserved", state?.fileId);
149
+ const articleHash = await hash(stableArticle(post));
150
+ if (state && state.articleHash !== articleHash)
151
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article changed while the operation was paused", state.fileId);
152
+ state ??= { version: 1, identity, stage, expectedVersion: input.expectedVersion, articleHash };
153
+ await progress("upload");
154
+ if (!state.etag) {
155
+ const filename = `article-${identity}.${mime === "image/jpeg" ? "jpg" : mime.split("/")[1]}`;
156
+ const reservation = await request("/v1/storage/signed-url/multipart/start", "POST", {
157
+ filename,
158
+ content_type: mime,
159
+ size_bytes: bytes.length,
160
+ visibility: "anonymous_visible",
161
+ chunk_size: 25 * 1024 * 1024,
162
+ client_upload_key: identity,
163
+ metadata: { purpose: "article-photo", publication_id: t.publicationId, article_id: input.articleId, operation_hash: identity, caption: input.caption, credit: input.credit, alt: input.alt, rights_confirmed: true, ai_generated: input.aiGenerated, label: input.label }
164
+ });
165
+ if (state.fileId && state.fileId !== reservation.file_id || reservation.total_parts !== 1)
166
+ throw new ArticlePhotoOperationError("UPLOAD_SESSION_CHANGED", stage, "The original upload session expired or changed; no duplicate attachment was attempted", state.fileId);
167
+ state.fileId = reservation.file_id;
168
+ state.sessionId = reservation.upload_session_id;
169
+ state.completionToken = reservation.completion_token;
170
+ await progress("upload");
171
+ const info = await request(`/v1/storage/files/${state.fileId}/info`);
172
+ if (info.filename !== filename)
173
+ throw new ArticlePhotoOperationError("UPLOAD_IDENTITY_MISMATCH", stage, "The upload session does not match these source bytes", state.fileId);
174
+ const part = reservation.part_urls?.find((p) => p.part_number === 1);
175
+ const u = new URL(part?.url);
176
+ if (u.protocol !== "https:" || u.username || u.password)
177
+ throw Error("Invalid managed upload destination");
178
+ const response = await t.fetch(u, { method: "PUT", body: input.file, redirect: "error", signal: deadline });
179
+ const etag = response.headers.get("etag");
180
+ await response.body?.cancel();
181
+ if (!response.ok || !etag)
182
+ throw Error("Managed upload did not acknowledge its part; resume this operation");
183
+ state.etag = etag;
184
+ await progress("complete");
185
+ }
186
+ if (!state.completed) {
187
+ await request("/v1/storage/signed-url/multipart/complete", "POST", { file_id: state.fileId, upload_session_id: state.sessionId, completion_token: state.completionToken, parts: [{ part_number: 1, etag: state.etag }] });
188
+ state.completed = true;
189
+ delete state.completionToken;
190
+ await progress("scan");
191
+ }
192
+ for (; ; ) {
193
+ const status = await request(`/v1/storage/files/${state.fileId}/status`);
194
+ if (["threat", "quarantined", "error"].includes(status.scan?.status))
195
+ throw new ArticlePhotoOperationError("SCAN_REJECTED", stage, "Storage did not clear this image; the previous cover remains", state.fileId);
196
+ if (status.scan?.status === "clean" && status.urls?.cdn_url)
197
+ break;
198
+ await sleep();
199
+ }
200
+ await progress("photo");
201
+ const batched = policy.capabilities?.batched_article_renditions === true;
202
+ let prepared;
203
+ if (!state.registered || batched) {
204
+ if (batched) await progress("renditions");
205
+ const registered = await request("/v1/photos/register", "POST", { file_id: state.fileId, content_kind: "article", role: "lead", publication_id: t.publicationId, prepare_article_renditions: batched });
206
+ if (batched && registered.prepared_article_renditions !== true) throw Error("Photo did not confirm the measured rendition batch; resume the same upload");
207
+ prepared = batched ? registered : void 0;
208
+ state.registered = true;
209
+ await progress("renditions");
210
+ }
211
+ const source = prepared?.source ?? await request(`/v1/photos/public/${state.fileId}/manifest?preset=hero`);
212
+ if (source.file_id !== state.fileId || !Number.isInteger(source.width) || source.width <= 0)
213
+ throw Error("Photo source identity or measured dimensions are missing");
214
+ const requirements = ["lead", "thumbnail", "social"].flatMap((role) => {
215
+ const specs = policy.roles?.[role]?.renditions;
216
+ if (!Array.isArray(specs) || !specs.length)
217
+ throw Error("Publication Photo policy is incomplete");
218
+ return specs.filter((r) => r.fit !== "scaledown" || r.width <= source.width).map((spec) => ({ role, spec }));
219
+ });
220
+ if (!requirements.length || requirements.length > 48)
221
+ throw Error("Publication Photo policy exceeds operation limits");
222
+ const outputs = [];
223
+ const preparations = /* @__PURE__ */ new Map();
224
+ let next = 0;
225
+ await Promise.all(Array.from({ length: Math.min(4, requirements.length) }, async () => {
226
+ while (next < requirements.length) {
227
+ const { role, spec: r } = requirements[next++];
228
+ if (!Number.isInteger(r.width) || r.width < 1 || r.width > 12e3 || !["cover", "contain", "scaledown"].includes(r.fit) || !["jpeg", "webp", "avif"].includes(r.format) || !Number.isInteger(r.quality) || r.quality < 1 || r.quality > 100 || r.height != null && (!Number.isInteger(r.height) || r.height < 1 || r.height > 12e3))
229
+ throw Error("Invalid publication Photo policy");
230
+ const key = `w${r.width}_h${r.height ?? 0}_${r.fit}_q${r.quality}_${r.format}`;
231
+ let preparation = preparations.get(key);
232
+ if (!preparation) {
233
+ preparation = (async () => {
234
+ const path = `/v1/photos/public/${state.fileId}/manifest?rendition=${key}`;
235
+ let receipt2 = batched ? prepared?.article_renditions?.find((v) => v.key === key && v.role === role && v.format === r.format) : await request(path);
236
+ if (batched && !measured(receipt2, state.fileId)) throw Error("Photo batch is missing a required measured receipt; resume the same uploaded file");
237
+ if (!measured(receipt2, state.fileId)) {
238
+ const q = new URLSearchParams({ width: String(r.width), fit: r.fit, quality: String(r.quality), format: r.format });
239
+ if (r.height)
240
+ q.set("height", String(r.height));
241
+ const response = await t.fetch(new URL(t.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${state.fileId}/transform?${q}`), { method: "GET", redirect: "manual", signal: deadline });
242
+ const ok = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
243
+ await response.body?.cancel();
244
+ if (!ok)
245
+ throw Error("Photo could not prepare a required rendition");
246
+ while (!measured(receipt2, state.fileId)) {
247
+ receipt2 = await request(path);
248
+ if (!measured(receipt2, state.fileId))
249
+ await sleep();
250
+ }
251
+ }
252
+ return receipt2;
253
+ })();
254
+ preparations.set(key, preparation);
255
+ }
256
+ const receipt = await preparation;
257
+ outputs.push({ role, file_id: state.fileId, url: receipt.url, width: receipt.width, height: receipt.height, format: r.format });
258
+ }
259
+ }));
260
+ const leads = outputs.filter((o) => o.role === "lead").sort((a, b) => a.width - b.width || a.format.localeCompare(b.format));
261
+ const master = [...leads].reverse().find((o) => o.format === "webp") ?? leads.at(-1);
262
+ const social = outputs.find((o) => o.role === "social" && o.format === "jpeg") ?? outputs.find((o) => o.role === "social");
263
+ if (!master || !social || leads.length > 20)
264
+ throw Error("Photo policy has no usable lead or social output");
265
+ state.payload = {
266
+ expected_version: state.expectedVersion,
267
+ cover_image_url: master.url,
268
+ og_image_url: social.url,
269
+ cover_image: { file_id: state.fileId, master_url: master.url, width: master.width, height: master.height, aspect_ratio: `${master.width}:${master.height}`, alt: input.alt, caption: input.caption, attribution: input.credit, ai_generated: input.aiGenerated, label: input.label, focal_point: input.focalPoint ?? { x: 0.5, y: 0.5 }, variants: leads.map(({ url, width, height, format }) => ({ url, width, height, format })) },
270
+ open_graph_image: { file_id: state.fileId, url: social.url, width: social.width, height: social.height, aspect_ratio: `${social.width}:${social.height}` }
271
+ };
272
+ await progress("attach");
273
+ const result = await request(`/articles/${encodeURIComponent(input.articleId)}/media`, "PUT", state.payload);
274
+ state.attachedVersion = result.version;
275
+ await progress("verify");
276
+ const after = await request(postPath);
277
+ if (!mediaMatches(after, state.payload) || await hash(stableArticle(after)) !== state.articleHash)
278
+ throw new ArticlePhotoOperationError("VERIFICATION_CONFLICT", stage, "The current article differs from this operation; inspect it before making another change", state.fileId);
279
+ await progress("verified");
280
+ return { article_id: input.articleId, file_id: state.fileId, version: after.version, state: "verified", elapsed_ms: Math.round(performance.now() - started) };
281
+ } catch (error) {
282
+ if (error instanceof ArticlePhotoOperationError)
283
+ throw error;
284
+ const status = typeof error?.status === "number" ? error.status : void 0;
285
+ const code = deadline.aborted ? "DEADLINE_EXCEEDED" : status === 409 && stage === "attach" ? "ARTICLE_CHANGED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : "STEP_FAILED";
286
+ throw new ArticlePhotoOperationError(code, stage, deadline.aborted ? "Operation reached its time limit; resume the same operation and checkpoint. Success was not asserted." : error instanceof Error ? error.message : "Article photo operation failed", state?.fileId, status);
287
+ }
288
+ }
289
+
3
290
  // src/article-workflow-client.ts
4
291
  var ArticleWorkflowError = class extends Error {
5
292
  constructor(message, status, articleId) {
@@ -21,7 +308,7 @@ var PublicationArticleClient = class {
21
308
  }
22
309
  config;
23
310
  base;
24
- async request(path, method = "GET", body, extra) {
311
+ async request(path, method = "GET", body, extra, signal) {
25
312
  const headers = new Headers(extra);
26
313
  headers.set("accept", "application/json");
27
314
  if (this.config.apiKey) headers.set("x-api-key", this.config.apiKey);
@@ -39,7 +326,7 @@ var PublicationArticleClient = class {
39
326
  method,
40
327
  headers,
41
328
  body: body === void 0 ? void 0 : form ? body : JSON.stringify(body),
42
- signal: AbortSignal.timeout(form ? 9e4 : 3e4),
329
+ signal: signal ?? AbortSignal.timeout(form ? 9e4 : 3e4),
43
330
  cache: "no-store",
44
331
  redirect: "error"
45
332
  });
@@ -72,11 +359,18 @@ var PublicationArticleClient = class {
72
359
  `/articles/${encodeURIComponent(articleId)}/collaboration`
73
360
  );
74
361
  }
75
- processing(articleId) {
362
+ processing(articleId, signal) {
76
363
  return this.request(
77
- `/articles/${encodeURIComponent(articleId)}/processing`
364
+ `/articles/${encodeURIComponent(articleId)}/processing`,
365
+ void 0,
366
+ void 0,
367
+ void 0,
368
+ signal
78
369
  );
79
370
  }
371
+ uploadAndReplacePublishedCover(input) {
372
+ return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
373
+ }
80
374
  /** Replace a legacy publication cover using ready, measured public Photo renditions.
81
375
  * This never enrolls a story, publishes text, creates narration or retries a conflict.
82
376
  * Collaboration-managed stories retain their server-enforced immutable revision workflow.
@@ -262,7 +556,7 @@ var PublicationArticleClient = class {
262
556
  const selected = [];
263
557
  for (const asset of input.assets ?? []) {
264
558
  let current = await this.get(created.article_id);
265
- const digest = Array.from(
559
+ const digest2 = Array.from(
266
560
  new Uint8Array(
267
561
  await crypto.subtle.digest(
268
562
  "SHA-256",
@@ -271,7 +565,7 @@ var PublicationArticleClient = class {
271
565
  )
272
566
  ).map((v) => v.toString(16).padStart(2, "0")).join("");
273
567
  const existing = current.contributions.find(
274
- (c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest && Object.entries(asset.metadata).every(
568
+ (c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest2 && Object.entries(asset.metadata).every(
275
569
  ([k, v]) => c.payload[k] === v
276
570
  )
277
571
  );
@@ -352,7 +646,7 @@ var PublicationArticleClient = class {
352
646
  }
353
647
  };
354
648
  function sameDocument(a, b) {
355
- const canonical = (d) => [
649
+ const canonical2 = (d) => [
356
650
  d.title,
357
651
  d.subtitle || "",
358
652
  d.content_markdown,
@@ -370,8 +664,10 @@ function sameDocument(a, b) {
370
664
  m.position
371
665
  ])
372
666
  ];
373
- return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
667
+ return JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
374
668
  }
375
669
 
670
+ exports.ArticlePhotoOperationError = ArticlePhotoOperationError;
376
671
  exports.ArticleWorkflowError = ArticleWorkflowError;
377
672
  exports.PublicationArticleClient = PublicationArticleClient;
673
+ exports.articlePhotoHtmlMatches = articlePhotoHtmlMatches;
@@ -1,4 +1,45 @@
1
- import { A as ArticleFormat, a as ArticleLayout } from './article-presentation-BrNCcF-5.cjs';
1
+ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0d1FiDgA.cjs';
2
+
3
+ /** Check rendered image tags, not a URL mentioned only in serialized page data. */
4
+ declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
5
+
6
+ type PhotoOperationCheckpoint = {
7
+ version: 1;
8
+ identity: string;
9
+ stage: string;
10
+ expectedVersion: number;
11
+ articleHash: string;
12
+ fileId?: string;
13
+ sessionId?: string;
14
+ completionToken?: string;
15
+ etag?: string;
16
+ completed?: boolean;
17
+ registered?: boolean;
18
+ payload?: any;
19
+ attachedVersion?: number;
20
+ };
21
+ type ArticlePhotoInput = Omit<PublishedCoverReplacement, "fileId" | "expectedVersion" | "socialRenditionKey"> & {
22
+ operationKey: string;
23
+ expectedVersion: number;
24
+ file: Blob;
25
+ filename: string;
26
+ checkpoint?: PhotoOperationCheckpoint;
27
+ /** Persist atomically before the next side effect. Contains private upload tokens. */
28
+ saveCheckpoint: (state: PhotoOperationCheckpoint) => Promise<void>;
29
+ timeoutMs?: number;
30
+ onProgress?: (event: {
31
+ stage: string;
32
+ elapsed_ms: number;
33
+ file_id?: string;
34
+ }) => void;
35
+ };
36
+ declare class ArticlePhotoOperationError extends Error {
37
+ code: string;
38
+ stage: string;
39
+ fileId?: string | undefined;
40
+ status?: number | undefined;
41
+ constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
42
+ }
2
43
 
3
44
  type ArticleDocument = {
4
45
  title: string;
@@ -104,6 +145,7 @@ type ArticleWorkflowOptions = {
104
145
  inline_media_body?: boolean;
105
146
  automatic_public_narration?: boolean;
106
147
  prioritized_narration_processing?: boolean;
148
+ content_first_editor_document?: boolean;
107
149
  };
108
150
  };
109
151
  type ArticleOperation = {
@@ -162,7 +204,14 @@ declare class PublicationArticleClient {
162
204
  current_revision_id?: string;
163
205
  }>;
164
206
  get(articleId: string): Promise<Record>;
165
- processing(articleId: string): Promise<ArticleProcessing>;
207
+ processing(articleId: string, signal?: AbortSignal): Promise<ArticleProcessing>;
208
+ uploadAndReplacePublishedCover(input: ArticlePhotoInput): Promise<{
209
+ article_id: string;
210
+ file_id: string | undefined;
211
+ version: any;
212
+ state: string;
213
+ elapsed_ms: number;
214
+ }>;
166
215
  /** Replace a legacy publication cover using ready, measured public Photo renditions.
167
216
  * This never enrolls a story, publishes text, creates narration or retries a conflict.
168
217
  * Collaboration-managed stories retain their server-enforced immutable revision workflow.
@@ -213,4 +262,4 @@ declare class PublicationArticleClient {
213
262
  }): Promise<ArticleOperation>;
214
263
  }
215
264
 
216
- export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
265
+ export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
@@ -1,4 +1,45 @@
1
- import { A as ArticleFormat, a as ArticleLayout } from './article-presentation-BrNCcF-5.js';
1
+ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0d1FiDgA.js';
2
+
3
+ /** Check rendered image tags, not a URL mentioned only in serialized page data. */
4
+ declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
5
+
6
+ type PhotoOperationCheckpoint = {
7
+ version: 1;
8
+ identity: string;
9
+ stage: string;
10
+ expectedVersion: number;
11
+ articleHash: string;
12
+ fileId?: string;
13
+ sessionId?: string;
14
+ completionToken?: string;
15
+ etag?: string;
16
+ completed?: boolean;
17
+ registered?: boolean;
18
+ payload?: any;
19
+ attachedVersion?: number;
20
+ };
21
+ type ArticlePhotoInput = Omit<PublishedCoverReplacement, "fileId" | "expectedVersion" | "socialRenditionKey"> & {
22
+ operationKey: string;
23
+ expectedVersion: number;
24
+ file: Blob;
25
+ filename: string;
26
+ checkpoint?: PhotoOperationCheckpoint;
27
+ /** Persist atomically before the next side effect. Contains private upload tokens. */
28
+ saveCheckpoint: (state: PhotoOperationCheckpoint) => Promise<void>;
29
+ timeoutMs?: number;
30
+ onProgress?: (event: {
31
+ stage: string;
32
+ elapsed_ms: number;
33
+ file_id?: string;
34
+ }) => void;
35
+ };
36
+ declare class ArticlePhotoOperationError extends Error {
37
+ code: string;
38
+ stage: string;
39
+ fileId?: string | undefined;
40
+ status?: number | undefined;
41
+ constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
42
+ }
2
43
 
3
44
  type ArticleDocument = {
4
45
  title: string;
@@ -104,6 +145,7 @@ type ArticleWorkflowOptions = {
104
145
  inline_media_body?: boolean;
105
146
  automatic_public_narration?: boolean;
106
147
  prioritized_narration_processing?: boolean;
148
+ content_first_editor_document?: boolean;
107
149
  };
108
150
  };
109
151
  type ArticleOperation = {
@@ -162,7 +204,14 @@ declare class PublicationArticleClient {
162
204
  current_revision_id?: string;
163
205
  }>;
164
206
  get(articleId: string): Promise<Record>;
165
- processing(articleId: string): Promise<ArticleProcessing>;
207
+ processing(articleId: string, signal?: AbortSignal): Promise<ArticleProcessing>;
208
+ uploadAndReplacePublishedCover(input: ArticlePhotoInput): Promise<{
209
+ article_id: string;
210
+ file_id: string | undefined;
211
+ version: any;
212
+ state: string;
213
+ elapsed_ms: number;
214
+ }>;
166
215
  /** Replace a legacy publication cover using ready, measured public Photo renditions.
167
216
  * This never enrolls a story, publishes text, creates narration or retries a conflict.
168
217
  * Collaboration-managed stories retain their server-enforced immutable revision workflow.
@@ -213,4 +262,4 @@ declare class PublicationArticleClient {
213
262
  }): Promise<ArticleOperation>;
214
263
  }
215
264
 
216
- export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, PublicationArticleClient, type PublishedCoverReplacement };
265
+ export { type ArticleDocument, type ArticleMedia, type ArticleOperation, type ArticlePhotoInput, ArticlePhotoOperationError, type ArticleProcessing, ArticleWorkflowError, type ArticleWorkflowOptions, type AssetMetadata, type PhotoOperationCheckpoint, PublicationArticleClient, type PublishedCoverReplacement, articlePhotoHtmlMatches };
@@ -1,3 +1,290 @@
1
+ // src/article-photo-verification.ts
2
+ function articlePhotoHtmlMatches(html, lead, social) {
3
+ const contains = (tag, url) => tag.includes(url) || tag.includes(encodeURIComponent(url));
4
+ const leads = Array.isArray(lead) ? lead : [lead];
5
+ const image = [...html.matchAll(/<img\b[^>]*>/giu)].some(([tag]) => leads.some((url) => contains(tag, url)));
6
+ const openGraph = [...html.matchAll(/<meta\b[^>]*>/giu)].some(([tag]) => {
7
+ const attrs = Object.fromEntries([...tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gu)].map((m) => [m[1].toLowerCase(), m[2] ?? m[3]]));
8
+ return (attrs.property ?? attrs.name)?.toLowerCase() === "og:image" && attrs.content === social;
9
+ });
10
+ return image && openGraph;
11
+ }
12
+
13
+ // src/article-photo-operation.ts
14
+ var ArticlePhotoOperationError = class extends Error {
15
+ constructor(code, stage, message, fileId, status) {
16
+ super(message);
17
+ this.code = code;
18
+ this.stage = stage;
19
+ this.fileId = fileId;
20
+ this.status = status;
21
+ this.name = "ArticlePhotoOperationError";
22
+ }
23
+ code;
24
+ stage;
25
+ fileId;
26
+ status;
27
+ };
28
+ var digest = async (value) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", value))].map((v) => v.toString(16).padStart(2, "0")).join("");
29
+ var hash = (value) => digest(new TextEncoder().encode(JSON.stringify(value)));
30
+ var canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v;
31
+ var stableArticle = (p) => [
32
+ p.id,
33
+ p.slug,
34
+ p.title,
35
+ p.subtitle,
36
+ p.content_markdown,
37
+ p.content_html,
38
+ p.status,
39
+ p.visibility,
40
+ p.published_at,
41
+ p.author_user_id,
42
+ p.audio_file_id,
43
+ Object.fromEntries(Object.entries(p.metadata ?? {}).filter(([k]) => !["cover_image", "open_graph_image", "visual_desk_receipt"].includes(k)).sort(([a], [b]) => a.localeCompare(b)))
44
+ ];
45
+ function photoType(bytes) {
46
+ if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255)
47
+ return "image/jpeg";
48
+ const ascii = (n, length) => String.fromCharCode(...bytes.subarray(n, n + length));
49
+ const png = ascii(0, 8) === "\x89PNG\r\n\n";
50
+ const webp = ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP";
51
+ if (!png && !webp)
52
+ throw Error("Use a still JPEG, PNG or WebP photo; animations use the Video workflow");
53
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
+ for (let n = png ? 8 : 12, chunks = 0; n < bytes.length; chunks++) {
55
+ if (chunks >= 1e4 || n + 8 > bytes.length)
56
+ throw Error("Invalid or truncated photo container");
57
+ const kind = ascii(png ? n + 4 : n, 4);
58
+ if (["acTL", "ANIM", "ANMF"].includes(kind))
59
+ throw Error("Animated photos require the Video workflow");
60
+ const size = view.getUint32(png ? n : n + 4, webp);
61
+ n += size + (png ? 12 : 8 + size % 2);
62
+ if (n > bytes.length)
63
+ throw Error("Truncated photo container");
64
+ if (png && kind === "IEND")
65
+ break;
66
+ }
67
+ return png ? "image/png" : "image/webp";
68
+ }
69
+ function immutableUrl(raw) {
70
+ try {
71
+ const u = new URL(String(raw));
72
+ return u.protocol === "https:" && !u.username && !u.password && !u.search && !u.hash;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ var measured = (m, file) => m?.ready === true && m.file_id === file && immutableUrl(m.url) && Number.isInteger(m.width) && m.width > 0 && m.width <= 12e3 && Number.isInteger(m.height) && m.height > 0 && m.height <= 12e3;
78
+ var mediaMatches = (post, payload) => post.cover_image_url === payload.cover_image_url && post.og_image_url === payload.og_image_url && JSON.stringify(canonical(post.metadata?.cover_image)) === JSON.stringify(canonical(payload.cover_image)) && JSON.stringify(canonical(post.metadata?.open_graph_image)) === JSON.stringify(canonical(payload.open_graph_image));
79
+ async function runArticlePhoto(t, input) {
80
+ const started = performance.now();
81
+ if (!input.operationKey || input.operationKey.length > 128 || !Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 1 || input.rightsConfirmed !== true || !input.alt?.trim() || typeof input.aiGenerated !== "boolean" || input.aiGenerated && !input.label?.trim())
82
+ throw Error("Supply a stable operation key, current article version, confirmed rights, factual alt and explicit provenance");
83
+ if (typeof input.caption !== "string" || typeof input.credit !== "string" || !(input.label === null || typeof input.label === "string"))
84
+ throw Error("Supply explicit caption, credit and provenance label");
85
+ for (const [name, value, limit] of [["caption", input.caption, 2e3], ["credit", input.credit, 8e3], ["alt", input.alt, 1e3], ["label", input.label ?? "", 200]]) {
86
+ if ([...value].length > limit) throw Error(`Photo ${name} exceeds ${limit} characters`);
87
+ }
88
+ const focal = input.focalPoint ?? { x: 0.5, y: 0.5 };
89
+ if (![focal.x, focal.y].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
90
+ throw Error("Focal point must be within the image");
91
+ if (!input.file.size || input.file.size > 25 * 1024 * 1024)
92
+ throw Error("A photo must be between 1 byte and 25 MiB");
93
+ const bytes = new Uint8Array(await input.file.arrayBuffer());
94
+ const mime = photoType(bytes);
95
+ const identity = await hash([t.publicationId, input.articleId, input.operationKey, await digest(bytes), input.expectedVersion, input.caption, input.credit, input.alt, input.rightsConfirmed, input.aiGenerated, input.label, input.focalPoint ?? { x: 0.5, y: 0.5 }]);
96
+ if (input.checkpoint && (input.checkpoint.version !== 1 || input.checkpoint.identity !== identity))
97
+ throw new ArticlePhotoOperationError("OPERATION_CONFLICT", "validation", "The saved operation belongs to different input; do not overwrite it");
98
+ let state = input.checkpoint ? structuredClone(input.checkpoint) : void 0;
99
+ let stage = "preflight";
100
+ const deadline = AbortSignal.timeout(Math.min(Math.max(input.timeoutMs ?? 3e4, 100), 12e4));
101
+ const request = (path, method, body) => t.request(path, method, body, void 0, deadline);
102
+ const progress = async (next) => {
103
+ stage = next;
104
+ if (state) {
105
+ state.stage = next;
106
+ await input.saveCheckpoint(structuredClone(state));
107
+ }
108
+ input.onProgress?.({ stage, elapsed_ms: Math.round(performance.now() - started), file_id: state?.fileId });
109
+ };
110
+ const sleep = () => new Promise((resolve, reject) => {
111
+ deadline.throwIfAborted();
112
+ const id = setTimeout(() => {
113
+ deadline.removeEventListener("abort", abort);
114
+ resolve();
115
+ }, 300);
116
+ const abort = () => {
117
+ clearTimeout(id);
118
+ reject(deadline.reason);
119
+ };
120
+ deadline.addEventListener("abort", abort, { once: true });
121
+ });
122
+ const postPath = `/articles/${encodeURIComponent(input.articleId)}/document`;
123
+ try {
124
+ const [post, options, collaboration, policy] = await Promise.all([
125
+ request(postPath),
126
+ request(`/article-workflow/options?article_id=${encodeURIComponent(input.articleId)}`),
127
+ request(`/articles/${encodeURIComponent(input.articleId)}/collaboration`).then(() => true).catch((error) => {
128
+ if (error.status === 404 && error.message === "Story collaboration is not enabled")
129
+ return false;
130
+ throw error;
131
+ }),
132
+ request(`/v1/photos/policies/article/editor?publication_id=${encodeURIComponent(t.publicationId)}`)
133
+ ]);
134
+ if (post.status !== "published" || post.visibility !== "public" || options.workflow_version !== 1)
135
+ throw new ArticlePhotoOperationError("ARTICLE_NOT_PUBLIC", stage, "Select a currently published public article");
136
+ if (collaboration)
137
+ throw new ArticlePhotoOperationError("REVISION_WORKFLOW_REQUIRED", stage, "This story requires an immutable media revision and editorial release; no upload or public mutation was made");
138
+ if (state?.payload && mediaMatches(post, state.payload)) {
139
+ if (await hash(stableArticle(post)) !== state.articleHash)
140
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The photo is attached, but other article content changed; inspect before continuing", state.fileId);
141
+ state.attachedVersion = post.version;
142
+ await progress("verified");
143
+ return { article_id: input.articleId, file_id: state.fileId, version: post.version, state: "already_applied", elapsed_ms: Math.round(performance.now() - started) };
144
+ }
145
+ if (post.version !== input.expectedVersion)
146
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article version changed; existing text and media were preserved", state?.fileId);
147
+ const articleHash = await hash(stableArticle(post));
148
+ if (state && state.articleHash !== articleHash)
149
+ throw new ArticlePhotoOperationError("ARTICLE_CHANGED", stage, "The article changed while the operation was paused", state.fileId);
150
+ state ??= { version: 1, identity, stage, expectedVersion: input.expectedVersion, articleHash };
151
+ await progress("upload");
152
+ if (!state.etag) {
153
+ const filename = `article-${identity}.${mime === "image/jpeg" ? "jpg" : mime.split("/")[1]}`;
154
+ const reservation = await request("/v1/storage/signed-url/multipart/start", "POST", {
155
+ filename,
156
+ content_type: mime,
157
+ size_bytes: bytes.length,
158
+ visibility: "anonymous_visible",
159
+ chunk_size: 25 * 1024 * 1024,
160
+ client_upload_key: identity,
161
+ metadata: { purpose: "article-photo", publication_id: t.publicationId, article_id: input.articleId, operation_hash: identity, caption: input.caption, credit: input.credit, alt: input.alt, rights_confirmed: true, ai_generated: input.aiGenerated, label: input.label }
162
+ });
163
+ if (state.fileId && state.fileId !== reservation.file_id || reservation.total_parts !== 1)
164
+ throw new ArticlePhotoOperationError("UPLOAD_SESSION_CHANGED", stage, "The original upload session expired or changed; no duplicate attachment was attempted", state.fileId);
165
+ state.fileId = reservation.file_id;
166
+ state.sessionId = reservation.upload_session_id;
167
+ state.completionToken = reservation.completion_token;
168
+ await progress("upload");
169
+ const info = await request(`/v1/storage/files/${state.fileId}/info`);
170
+ if (info.filename !== filename)
171
+ throw new ArticlePhotoOperationError("UPLOAD_IDENTITY_MISMATCH", stage, "The upload session does not match these source bytes", state.fileId);
172
+ const part = reservation.part_urls?.find((p) => p.part_number === 1);
173
+ const u = new URL(part?.url);
174
+ if (u.protocol !== "https:" || u.username || u.password)
175
+ throw Error("Invalid managed upload destination");
176
+ const response = await t.fetch(u, { method: "PUT", body: input.file, redirect: "error", signal: deadline });
177
+ const etag = response.headers.get("etag");
178
+ await response.body?.cancel();
179
+ if (!response.ok || !etag)
180
+ throw Error("Managed upload did not acknowledge its part; resume this operation");
181
+ state.etag = etag;
182
+ await progress("complete");
183
+ }
184
+ if (!state.completed) {
185
+ await request("/v1/storage/signed-url/multipart/complete", "POST", { file_id: state.fileId, upload_session_id: state.sessionId, completion_token: state.completionToken, parts: [{ part_number: 1, etag: state.etag }] });
186
+ state.completed = true;
187
+ delete state.completionToken;
188
+ await progress("scan");
189
+ }
190
+ for (; ; ) {
191
+ const status = await request(`/v1/storage/files/${state.fileId}/status`);
192
+ if (["threat", "quarantined", "error"].includes(status.scan?.status))
193
+ throw new ArticlePhotoOperationError("SCAN_REJECTED", stage, "Storage did not clear this image; the previous cover remains", state.fileId);
194
+ if (status.scan?.status === "clean" && status.urls?.cdn_url)
195
+ break;
196
+ await sleep();
197
+ }
198
+ await progress("photo");
199
+ const batched = policy.capabilities?.batched_article_renditions === true;
200
+ let prepared;
201
+ if (!state.registered || batched) {
202
+ if (batched) await progress("renditions");
203
+ const registered = await request("/v1/photos/register", "POST", { file_id: state.fileId, content_kind: "article", role: "lead", publication_id: t.publicationId, prepare_article_renditions: batched });
204
+ if (batched && registered.prepared_article_renditions !== true) throw Error("Photo did not confirm the measured rendition batch; resume the same upload");
205
+ prepared = batched ? registered : void 0;
206
+ state.registered = true;
207
+ await progress("renditions");
208
+ }
209
+ const source = prepared?.source ?? await request(`/v1/photos/public/${state.fileId}/manifest?preset=hero`);
210
+ if (source.file_id !== state.fileId || !Number.isInteger(source.width) || source.width <= 0)
211
+ throw Error("Photo source identity or measured dimensions are missing");
212
+ const requirements = ["lead", "thumbnail", "social"].flatMap((role) => {
213
+ const specs = policy.roles?.[role]?.renditions;
214
+ if (!Array.isArray(specs) || !specs.length)
215
+ throw Error("Publication Photo policy is incomplete");
216
+ return specs.filter((r) => r.fit !== "scaledown" || r.width <= source.width).map((spec) => ({ role, spec }));
217
+ });
218
+ if (!requirements.length || requirements.length > 48)
219
+ throw Error("Publication Photo policy exceeds operation limits");
220
+ const outputs = [];
221
+ const preparations = /* @__PURE__ */ new Map();
222
+ let next = 0;
223
+ await Promise.all(Array.from({ length: Math.min(4, requirements.length) }, async () => {
224
+ while (next < requirements.length) {
225
+ const { role, spec: r } = requirements[next++];
226
+ if (!Number.isInteger(r.width) || r.width < 1 || r.width > 12e3 || !["cover", "contain", "scaledown"].includes(r.fit) || !["jpeg", "webp", "avif"].includes(r.format) || !Number.isInteger(r.quality) || r.quality < 1 || r.quality > 100 || r.height != null && (!Number.isInteger(r.height) || r.height < 1 || r.height > 12e3))
227
+ throw Error("Invalid publication Photo policy");
228
+ const key = `w${r.width}_h${r.height ?? 0}_${r.fit}_q${r.quality}_${r.format}`;
229
+ let preparation = preparations.get(key);
230
+ if (!preparation) {
231
+ preparation = (async () => {
232
+ const path = `/v1/photos/public/${state.fileId}/manifest?rendition=${key}`;
233
+ let receipt2 = batched ? prepared?.article_renditions?.find((v) => v.key === key && v.role === role && v.format === r.format) : await request(path);
234
+ if (batched && !measured(receipt2, state.fileId)) throw Error("Photo batch is missing a required measured receipt; resume the same uploaded file");
235
+ if (!measured(receipt2, state.fileId)) {
236
+ const q = new URLSearchParams({ width: String(r.width), fit: r.fit, quality: String(r.quality), format: r.format });
237
+ if (r.height)
238
+ q.set("height", String(r.height));
239
+ const response = await t.fetch(new URL(t.apiUrl.replace(/\/$/, "") + `/v1/photos/public/${state.fileId}/transform?${q}`), { method: "GET", redirect: "manual", signal: deadline });
240
+ const ok = response.ok && response.headers.get("content-type")?.startsWith("image/") || [302, 303, 307, 308].includes(response.status);
241
+ await response.body?.cancel();
242
+ if (!ok)
243
+ throw Error("Photo could not prepare a required rendition");
244
+ while (!measured(receipt2, state.fileId)) {
245
+ receipt2 = await request(path);
246
+ if (!measured(receipt2, state.fileId))
247
+ await sleep();
248
+ }
249
+ }
250
+ return receipt2;
251
+ })();
252
+ preparations.set(key, preparation);
253
+ }
254
+ const receipt = await preparation;
255
+ outputs.push({ role, file_id: state.fileId, url: receipt.url, width: receipt.width, height: receipt.height, format: r.format });
256
+ }
257
+ }));
258
+ const leads = outputs.filter((o) => o.role === "lead").sort((a, b) => a.width - b.width || a.format.localeCompare(b.format));
259
+ const master = [...leads].reverse().find((o) => o.format === "webp") ?? leads.at(-1);
260
+ const social = outputs.find((o) => o.role === "social" && o.format === "jpeg") ?? outputs.find((o) => o.role === "social");
261
+ if (!master || !social || leads.length > 20)
262
+ throw Error("Photo policy has no usable lead or social output");
263
+ state.payload = {
264
+ expected_version: state.expectedVersion,
265
+ cover_image_url: master.url,
266
+ og_image_url: social.url,
267
+ cover_image: { file_id: state.fileId, master_url: master.url, width: master.width, height: master.height, aspect_ratio: `${master.width}:${master.height}`, alt: input.alt, caption: input.caption, attribution: input.credit, ai_generated: input.aiGenerated, label: input.label, focal_point: input.focalPoint ?? { x: 0.5, y: 0.5 }, variants: leads.map(({ url, width, height, format }) => ({ url, width, height, format })) },
268
+ open_graph_image: { file_id: state.fileId, url: social.url, width: social.width, height: social.height, aspect_ratio: `${social.width}:${social.height}` }
269
+ };
270
+ await progress("attach");
271
+ const result = await request(`/articles/${encodeURIComponent(input.articleId)}/media`, "PUT", state.payload);
272
+ state.attachedVersion = result.version;
273
+ await progress("verify");
274
+ const after = await request(postPath);
275
+ if (!mediaMatches(after, state.payload) || await hash(stableArticle(after)) !== state.articleHash)
276
+ throw new ArticlePhotoOperationError("VERIFICATION_CONFLICT", stage, "The current article differs from this operation; inspect it before making another change", state.fileId);
277
+ await progress("verified");
278
+ return { article_id: input.articleId, file_id: state.fileId, version: after.version, state: "verified", elapsed_ms: Math.round(performance.now() - started) };
279
+ } catch (error) {
280
+ if (error instanceof ArticlePhotoOperationError)
281
+ throw error;
282
+ const status = typeof error?.status === "number" ? error.status : void 0;
283
+ const code = deadline.aborted ? "DEADLINE_EXCEEDED" : status === 409 && stage === "attach" ? "ARTICLE_CHANGED" : status === 401 || status === 403 ? "PERMISSION_DENIED" : "STEP_FAILED";
284
+ throw new ArticlePhotoOperationError(code, stage, deadline.aborted ? "Operation reached its time limit; resume the same operation and checkpoint. Success was not asserted." : error instanceof Error ? error.message : "Article photo operation failed", state?.fileId, status);
285
+ }
286
+ }
287
+
1
288
  // src/article-workflow-client.ts
2
289
  var ArticleWorkflowError = class extends Error {
3
290
  constructor(message, status, articleId) {
@@ -19,7 +306,7 @@ var PublicationArticleClient = class {
19
306
  }
20
307
  config;
21
308
  base;
22
- async request(path, method = "GET", body, extra) {
309
+ async request(path, method = "GET", body, extra, signal) {
23
310
  const headers = new Headers(extra);
24
311
  headers.set("accept", "application/json");
25
312
  if (this.config.apiKey) headers.set("x-api-key", this.config.apiKey);
@@ -37,7 +324,7 @@ var PublicationArticleClient = class {
37
324
  method,
38
325
  headers,
39
326
  body: body === void 0 ? void 0 : form ? body : JSON.stringify(body),
40
- signal: AbortSignal.timeout(form ? 9e4 : 3e4),
327
+ signal: signal ?? AbortSignal.timeout(form ? 9e4 : 3e4),
41
328
  cache: "no-store",
42
329
  redirect: "error"
43
330
  });
@@ -70,11 +357,18 @@ var PublicationArticleClient = class {
70
357
  `/articles/${encodeURIComponent(articleId)}/collaboration`
71
358
  );
72
359
  }
73
- processing(articleId) {
360
+ processing(articleId, signal) {
74
361
  return this.request(
75
- `/articles/${encodeURIComponent(articleId)}/processing`
362
+ `/articles/${encodeURIComponent(articleId)}/processing`,
363
+ void 0,
364
+ void 0,
365
+ void 0,
366
+ signal
76
367
  );
77
368
  }
369
+ uploadAndReplacePublishedCover(input) {
370
+ return runArticlePhoto({ publicationId: this.config.publicationId, apiUrl: this.config.apiUrl, fetch: this.config.fetch ?? fetch, request: this.request.bind(this) }, input);
371
+ }
78
372
  /** Replace a legacy publication cover using ready, measured public Photo renditions.
79
373
  * This never enrolls a story, publishes text, creates narration or retries a conflict.
80
374
  * Collaboration-managed stories retain their server-enforced immutable revision workflow.
@@ -260,7 +554,7 @@ var PublicationArticleClient = class {
260
554
  const selected = [];
261
555
  for (const asset of input.assets ?? []) {
262
556
  let current = await this.get(created.article_id);
263
- const digest = Array.from(
557
+ const digest2 = Array.from(
264
558
  new Uint8Array(
265
559
  await crypto.subtle.digest(
266
560
  "SHA-256",
@@ -269,7 +563,7 @@ var PublicationArticleClient = class {
269
563
  )
270
564
  ).map((v) => v.toString(16).padStart(2, "0")).join("");
271
565
  const existing = current.contributions.find(
272
- (c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest && Object.entries(asset.metadata).every(
566
+ (c) => c.kind === "MEDIA" && c.state !== "REJECTED" && c.state !== "WITHDRAWN" && c.payload.sha256 === digest2 && Object.entries(asset.metadata).every(
273
567
  ([k, v]) => c.payload[k] === v
274
568
  )
275
569
  );
@@ -350,7 +644,7 @@ var PublicationArticleClient = class {
350
644
  }
351
645
  };
352
646
  function sameDocument(a, b) {
353
- const canonical = (d) => [
647
+ const canonical2 = (d) => [
354
648
  d.title,
355
649
  d.subtitle || "",
356
650
  d.content_markdown,
@@ -368,7 +662,7 @@ function sameDocument(a, b) {
368
662
  m.position
369
663
  ])
370
664
  ];
371
- return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
665
+ return JSON.stringify(canonical2(a)) === JSON.stringify(canonical2(b));
372
666
  }
373
667
 
374
- export { ArticleWorkflowError, PublicationArticleClient };
668
+ export { ArticlePhotoOperationError, ArticleWorkflowError, PublicationArticleClient, articlePhotoHtmlMatches };
package/dist/index.d.cts CHANGED
@@ -18,7 +18,7 @@ export { C as ContentBlock, S as StructuredDocument, a as StructuredMedia, f as
18
18
  import { M as Measurement } from './sponsorships-CtnBe3S8.cjs';
19
19
  export { a as Campaign, b as CampaignStatus, c as CampaignTarget, C as Creative, d as Placement, e as PlacementType, P as PublicAcknowledgment, S as SPONSORSHIP_OFFERINGS, f as SPONSORSHIP_POLICY, g as SponsorParty, p as placementAvailable, s as safeSponsorUrl } from './sponsorships-CtnBe3S8.cjs';
20
20
  export { R as READER_SSO_EVENTS, a as READER_SSO_PROPERTY_KEYS, b as ReaderSsoEventDefinition, c as ReaderSsoEventName } from './reader-sso-events-DBw8pGSM.cjs';
21
- export { A as ArticleFormat, a as ArticleLayout, b as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-BrNCcF-5.cjs';
21
+ export { A as ArticleFormat, b as ArticleLayout, a as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-0d1FiDgA.cjs';
22
22
  import './pets.cjs';
23
23
 
24
24
  /** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export { C as ContentBlock, S as StructuredDocument, a as StructuredMedia, f as
18
18
  import { M as Measurement } from './sponsorships-CtnBe3S8.js';
19
19
  export { a as Campaign, b as CampaignStatus, c as CampaignTarget, C as Creative, d as Placement, e as PlacementType, P as PublicAcknowledgment, S as SPONSORSHIP_OFFERINGS, f as SPONSORSHIP_POLICY, g as SponsorParty, p as placementAvailable, s as safeSponsorUrl } from './sponsorships-CtnBe3S8.js';
20
20
  export { R as READER_SSO_EVENTS, a as READER_SSO_PROPERTY_KEYS, b as ReaderSsoEventDefinition, c as ReaderSsoEventName } from './reader-sso-events-DBw8pGSM.js';
21
- export { A as ArticleFormat, a as ArticleLayout, b as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-BrNCcF-5.js';
21
+ export { A as ArticleFormat, b as ArticleLayout, a as ArticlePresentation, c as articleFormatLabel, d as articleHeroRatio, e as articlePresentation } from './article-presentation-0d1FiDgA.js';
22
22
  import './pets.js';
23
23
 
24
24
  /** Walnut Creek Times broadsheet colors. A paper overrides only what it changes. */
@@ -1,4 +1,4 @@
1
- import { A as ArticleFormat, b as ArticlePresentation } from './article-presentation-BrNCcF-5.cjs';
1
+ import { A as ArticleFormat, a as ArticlePresentation } from './article-presentation-0d1FiDgA.cjs';
2
2
  import React__default from 'react';
3
3
  import { C as ContentBlock } from './article-blocks-DsabIDmH.cjs';
4
4
 
@@ -1,4 +1,4 @@
1
- import { A as ArticleFormat, b as ArticlePresentation } from './article-presentation-BrNCcF-5.js';
1
+ import { A as ArticleFormat, a as ArticlePresentation } from './article-presentation-0d1FiDgA.js';
2
2
  import React__default from 'react';
3
3
  import { C as ContentBlock } from './article-blocks-DsabIDmH.js';
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.79",
3
+ "version": "0.0.80",
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": {