@scalemule/news-publication 0.0.93 → 0.0.95

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.
@@ -26,7 +26,8 @@ Unspecified fields and media are retained. Default: save for review.
26
26
  APPROVE or changes a policy. Legacy onboarding requires an explicit
27
27
  --enable-review-policy value. Scheduled legacy articles stop before onboarding.
28
28
  Use --expected-generation when coordinating an edit with another editor.
29
- Default edit deadline: 10000 ms. Reuse the same patch/key/checkpoint on retry.
29
+ Default edit deadline: 10000 ms (--timeout-ms, API work). The public page check after --release
30
+ has its own --page-timeout-ms (default 60000). Reuse the same patch/key/checkpoint on retry.
30
31
  RELEASE_HELD means the revision is saved and existing review gates remain.
31
32
 
32
33
  replace-photo accepts a local file and uses normal member/API-key credentials.
@@ -58,7 +59,7 @@ that every website/CDN cache already displays the new image.
58
59
  try {
59
60
  const { values, positionals } = parseArgs({
60
61
  allowPositionals: true, strict: true,
61
- options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url', 'url', 'file', 'operation-key', 'state', 'timeout-ms','patch','expected-generation','enable-review-policy','manifest','submission'].map(k => [k, { type: 'string' }]).concat([['asset',{type:'string',multiple:true}],['help', { type: 'boolean' }],['release',{type:'boolean'}]])),
62
+ options: Object.fromEntries(['publication', 'article', 'file-id', 'expected-version', 'metadata', 'application-id', 'api-url', 'url', 'file', 'operation-key', 'state', 'timeout-ms','page-timeout-ms','patch','expected-generation','enable-review-policy','manifest','submission'].map(k => [k, { type: 'string' }]).concat([['asset',{type:'string',multiple:true}],['help', { type: 'boolean' }],['release',{type:'boolean'}]])),
62
63
  });
63
64
  if (values.help || !positionals.length) {
64
65
  console.log(help);
@@ -68,6 +69,10 @@ try {
68
69
  const budget = Number(values['timeout-ms'] ?? (command==='edit'?10000:30000));
69
70
  if (!Number.isFinite(budget) || budget < 100 || budget > 120000) throw Error('--timeout-ms must be between 100 and 120000');
70
71
  const remaining = () => { const ms = Math.floor(budget - (performance.now()-commandStarted)); if (ms < 1) throw Error('Operation deadline reached; resume the same operation'); return ms; };
72
+ // The public page check has its own deadline: caches and rolling pods make it slower than the API, and
73
+ // a slow page must never consume the API operation's budget or vice versa.
74
+ const pageBudget = Number(values['page-timeout-ms'] ?? 60000);
75
+ if (!Number.isFinite(pageBudget) || pageBudget < 1000 || pageBudget > 300000) throw Error('--page-timeout-ms must be between 1000 and 300000');
71
76
  if (extra.length || !['submit','status','inspect', 'replace-cover', 'replace-photo','edit'].includes(command)) throw Error('Use submit, status, inspect, edit, replace-cover or replace-photo; see --help');
72
77
  if (values.url) {
73
78
  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');
@@ -152,15 +157,17 @@ try {
152
157
  });
153
158
  if(values.url&&values.release&&['published','already_published'].includes(result.state)){
154
159
  let verified=false,reason;
160
+ const pageStarted=performance.now();
161
+ const pageRemaining=()=>{const ms=Math.floor(pageBudget-(performance.now()-pageStarted));if(ms<1)throw Error('Public page deadline reached; the page may still be refreshing. Resume the same operation or raise --page-timeout-ms');return ms;};
155
162
  try{
156
- const snapshot=await client.publicArticle(checkpoint.sourceIdentity.slug,AbortSignal.timeout(remaining()));
163
+ const snapshot=await client.publicArticle(checkpoint.sourceIdentity.slug,AbortSignal.timeout(pageRemaining()));
157
164
  const article=snapshot.article;
158
165
  if(!article||!article.content_html||article.id!==values.article)throw Error('Published reader snapshot is missing');
159
166
  while(!verified){
160
- const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
167
+ const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,pageRemaining()))});
161
168
  const html=await response.text();
162
- verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,headline:article.headline,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title),tags:checkpoint.document.tag_ids?article.tags:undefined});
163
- if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
169
+ verified=response.ok&&articleEditHtmlMatches(html,{title:article.title,headline:article.headline,seo_title:'seo_title' in patch?article.seo_title:undefined,content_html:article.content_html,card_titles:(article.metadata?.cards??[]).map(c=>c.title),tags:checkpoint.document.tag_ids?article.tags:undefined});
170
+ if(!verified)await new Promise(r=>setTimeout(r,Math.min(1000,pageRemaining())));
164
171
  }
165
172
  }catch(error){reason=error.message;}
166
173
  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;}
@@ -36,6 +36,11 @@ function articleEditHtmlMatches(html, expected) {
36
36
  (m) => text(m[1]) === shown
37
37
  );
38
38
  if (!headline) return false;
39
+ const seo = expected.seo_title?.trim();
40
+ if (seo) {
41
+ const pageTitle = text(visible.match(/<title\b[^>]*>([\s\S]*?)<\/title>/iu)?.[1] ?? "");
42
+ if (pageTitle !== plain(seo) && !pageTitle.startsWith(plain(seo) + " ")) return false;
43
+ }
39
44
  const paragraphs = [
40
45
  ...expected.content_html.matchAll(
41
46
  /<(p|li|h[2-6]|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/giu
@@ -55,13 +60,10 @@ function articleEditHtmlMatches(html, expected) {
55
60
  }
56
61
  }))) return false;
57
62
  }
58
- const titles = [
59
- ...visible.matchAll(
60
- /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
61
- )
62
- ].flatMap(
63
- (m) => [...m[1].matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/giu)].map((m2) => text(m2[1]))
64
- );
63
+ const titles = [...visible.matchAll(/<aside\b([^>]*)>([\s\S]*?)<\/aside>/giu)].filter((m) => /\bdata-entity-card=["'][^"']+["']/iu.test(m[1]) || /class=["'][^"']*\barticle-verified-card\b/iu.test(m[1])).flatMap((m) => {
64
+ const first = m[2].match(/<h2\b[^>]*>([\s\S]*?)<\/h2>/iu);
65
+ return first ? [text(first[1])] : [];
66
+ });
65
67
  return JSON.stringify(titles.sort()) === JSON.stringify(expected.card_titles.map(plain).sort());
66
68
  }
67
69
 
@@ -354,7 +356,12 @@ function headlineProblems(fields) {
354
356
  problems.push(`${name} must be a string`);
355
357
  return;
356
358
  }
359
+ if (!value.trim()) {
360
+ problems.push(`${name} is blank; omit it to fall back to the title`);
361
+ return;
362
+ }
357
363
  if (headlineLength(value.trim()) > max) problems.push(`${name} exceeds ${max} characters`);
364
+ if (/[\p{Cc}]/u.test(value)) problems.push(`${name} contains control characters`);
358
365
  };
359
366
  check("headline", fields.headline, HEADLINE_MAX_CHARS);
360
367
  check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
@@ -506,6 +513,13 @@ async function runArticleEdit(t, input) {
506
513
  throw error;
507
514
  })
508
515
  ]);
516
+ const headlineKeys = ["headline", "fly_title", "seo_title"].filter((key) => key in input.patch);
517
+ if (headlineKeys.length && options.capabilities?.headline_set !== true)
518
+ throw new ArticleEditOperationError(
519
+ "CAPABILITY_UNAVAILABLE",
520
+ "preflight",
521
+ `The server must expose headline_set before editing ${headlineKeys.join(", ")}; no article was changed`
522
+ );
509
523
  if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
510
524
  throw new ArticleEditOperationError(
511
525
  "CAPABILITY_UNAVAILABLE",
@@ -668,7 +682,7 @@ async function runArticleEdit(t, input) {
668
682
  409,
669
683
  state.revisionId
670
684
  );
671
- for (const key of ["title", "subtitle", "content_markdown", "excerpt"])
685
+ for (const key of ["title", "headline", "fly_title", "seo_title", "subtitle", "content_markdown", "excerpt"])
672
686
  if (state.document?.[key] !== void 0 && !equal(after[key] ?? "", state.document[key]))
673
687
  throw new ArticleEditOperationError(
674
688
  "VERIFICATION_CONFLICT",
@@ -3,10 +3,17 @@ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0
3
3
  /** Check rendered image tags, not a URL mentioned only in serialized page data. */
4
4
  declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
5
5
 
6
- /** Requires visible body and actual card asides; serialized page data is not delivery. */
6
+ /**
7
+ * Requires visible body and actual card asides; serialized page data is not delivery.
8
+ * Cards are recognised by either rendering the papers use: the package's `article-verified-card`
9
+ * aside, or the papers' entity cards (`<aside data-entity-card="venue|event|…">`), each titled by its h2.
10
+ * When the edit carried a search title, the document's `<title>` must show it (a site may append its
11
+ * own name after it).
12
+ */
7
13
  declare function articleEditHtmlMatches(html: string, expected: {
8
14
  title: string;
9
15
  headline?: string | null;
16
+ seo_title?: string | null;
10
17
  content_html: string;
11
18
  card_titles: string[];
12
19
  tags?: {
@@ -208,6 +215,8 @@ type ArticleWorkflowOptions = {
208
215
  prioritized_narration_processing?: boolean;
209
216
  content_first_editor_document?: boolean;
210
217
  versioned_editorial_fields?: boolean;
218
+ /** The server stores headline, fly_title and seo_title on the versioned document. */
219
+ headline_set?: boolean;
211
220
  versioned_editorial_tags?: boolean;
212
221
  managed_submission_version?: number | null;
213
222
  multipart_submission?: boolean;
@@ -3,10 +3,17 @@ import { A as ArticleFormat, b as ArticleLayout } from './article-presentation-0
3
3
  /** Check rendered image tags, not a URL mentioned only in serialized page data. */
4
4
  declare function articlePhotoHtmlMatches(html: string, lead: string | string[], social: string): boolean;
5
5
 
6
- /** Requires visible body and actual card asides; serialized page data is not delivery. */
6
+ /**
7
+ * Requires visible body and actual card asides; serialized page data is not delivery.
8
+ * Cards are recognised by either rendering the papers use: the package's `article-verified-card`
9
+ * aside, or the papers' entity cards (`<aside data-entity-card="venue|event|…">`), each titled by its h2.
10
+ * When the edit carried a search title, the document's `<title>` must show it (a site may append its
11
+ * own name after it).
12
+ */
7
13
  declare function articleEditHtmlMatches(html: string, expected: {
8
14
  title: string;
9
15
  headline?: string | null;
16
+ seo_title?: string | null;
10
17
  content_html: string;
11
18
  card_titles: string[];
12
19
  tags?: {
@@ -208,6 +215,8 @@ type ArticleWorkflowOptions = {
208
215
  prioritized_narration_processing?: boolean;
209
216
  content_first_editor_document?: boolean;
210
217
  versioned_editorial_fields?: boolean;
218
+ /** The server stores headline, fly_title and seo_title on the versioned document. */
219
+ headline_set?: boolean;
211
220
  versioned_editorial_tags?: boolean;
212
221
  managed_submission_version?: number | null;
213
222
  multipart_submission?: boolean;
@@ -1,4 +1,4 @@
1
- import { headlineProblems } from './chunk-SNQKSUWB.js';
1
+ import { headlineProblems } from './chunk-7SAFGLPH.js';
2
2
 
3
3
  // src/article-photo-verification.ts
4
4
  function articlePhotoHtmlMatches(html, lead, social) {
@@ -36,6 +36,11 @@ function articleEditHtmlMatches(html, expected) {
36
36
  (m) => text(m[1]) === shown
37
37
  );
38
38
  if (!headline) return false;
39
+ const seo = expected.seo_title?.trim();
40
+ if (seo) {
41
+ const pageTitle = text(visible.match(/<title\b[^>]*>([\s\S]*?)<\/title>/iu)?.[1] ?? "");
42
+ if (pageTitle !== plain(seo) && !pageTitle.startsWith(plain(seo) + " ")) return false;
43
+ }
39
44
  const paragraphs = [
40
45
  ...expected.content_html.matchAll(
41
46
  /<(p|li|h[2-6]|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/giu
@@ -55,13 +60,10 @@ function articleEditHtmlMatches(html, expected) {
55
60
  }
56
61
  }))) return false;
57
62
  }
58
- const titles = [
59
- ...visible.matchAll(
60
- /<aside\b[^>]*class=["'][^"']*\barticle-verified-card\b[^"']*["'][^>]*>([\s\S]*?)<\/aside>/giu
61
- )
62
- ].flatMap(
63
- (m) => [...m[1].matchAll(/<h2\b[^>]*>([\s\S]*?)<\/h2>/giu)].map((m2) => text(m2[1]))
64
- );
63
+ const titles = [...visible.matchAll(/<aside\b([^>]*)>([\s\S]*?)<\/aside>/giu)].filter((m) => /\bdata-entity-card=["'][^"']+["']/iu.test(m[1]) || /class=["'][^"']*\barticle-verified-card\b/iu.test(m[1])).flatMap((m) => {
64
+ const first = m[2].match(/<h2\b[^>]*>([\s\S]*?)<\/h2>/iu);
65
+ return first ? [text(first[1])] : [];
66
+ });
65
67
  return JSON.stringify(titles.sort()) === JSON.stringify(expected.card_titles.map(plain).sort());
66
68
  }
67
69
 
@@ -483,6 +485,13 @@ async function runArticleEdit(t, input) {
483
485
  throw error;
484
486
  })
485
487
  ]);
488
+ const headlineKeys = ["headline", "fly_title", "seo_title"].filter((key) => key in input.patch);
489
+ if (headlineKeys.length && options.capabilities?.headline_set !== true)
490
+ throw new ArticleEditOperationError(
491
+ "CAPABILITY_UNAVAILABLE",
492
+ "preflight",
493
+ `The server must expose headline_set before editing ${headlineKeys.join(", ")}; no article was changed`
494
+ );
486
495
  if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
487
496
  throw new ArticleEditOperationError(
488
497
  "CAPABILITY_UNAVAILABLE",
@@ -645,7 +654,7 @@ async function runArticleEdit(t, input) {
645
654
  409,
646
655
  state.revisionId
647
656
  );
648
- for (const key of ["title", "subtitle", "content_markdown", "excerpt"])
657
+ for (const key of ["title", "headline", "fly_title", "seo_title", "subtitle", "content_markdown", "excerpt"])
649
658
  if (state.document?.[key] !== void 0 && !equal(after[key] ?? "", state.document[key]))
650
659
  throw new ArticleEditOperationError(
651
660
  "VERIFICATION_CONFLICT",
@@ -15,7 +15,7 @@ function headlineSet(article, selection = {}) {
15
15
  const title = text(article.title) ?? "";
16
16
  const headline = text(display?.display_title) ?? text(article.headline) ?? title;
17
17
  return {
18
- flyTitle: text(article.fly_title) ?? text(article.section),
18
+ flyTitle: text(display?.fly_title) ?? text(article.fly_title) ?? text(article.section),
19
19
  headline,
20
20
  rubric: text(display?.deck) ?? text(article.subtitle),
21
21
  seoTitle: text(article.seo_title) ?? title,
@@ -30,7 +30,12 @@ function headlineProblems(fields) {
30
30
  problems.push(`${name} must be a string`);
31
31
  return;
32
32
  }
33
+ if (!value.trim()) {
34
+ problems.push(`${name} is blank; omit it to fall back to the title`);
35
+ return;
36
+ }
33
37
  if (headlineLength(value.trim()) > max) problems.push(`${name} exceeds ${max} characters`);
38
+ if (/[\p{Cc}]/u.test(value)) problems.push(`${name} contains control characters`);
34
39
  };
35
40
  check("headline", fields.headline, HEADLINE_MAX_CHARS);
36
41
  check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
package/dist/index.cjs CHANGED
@@ -825,7 +825,7 @@ function headlineSet(article, selection = {}) {
825
825
  const title = text(article.title) ?? "";
826
826
  const headline = text(display?.display_title) ?? text(article.headline) ?? title;
827
827
  return {
828
- flyTitle: text(article.fly_title) ?? text(article.section),
828
+ flyTitle: text(display?.fly_title) ?? text(article.fly_title) ?? text(article.section),
829
829
  headline,
830
830
  rubric: text(display?.deck) ?? text(article.subtitle),
831
831
  seoTitle: text(article.seo_title) ?? title,
@@ -840,7 +840,12 @@ function headlineProblems(fields) {
840
840
  problems.push(`${name} must be a string`);
841
841
  return;
842
842
  }
843
+ if (!value.trim()) {
844
+ problems.push(`${name} is blank; omit it to fall back to the title`);
845
+ return;
846
+ }
843
847
  if (headlineLength(value.trim()) > max) problems.push(`${name} exceeds ${max} characters`);
848
+ if (/[\p{Cc}]/u.test(value)) problems.push(`${name} contains control characters`);
844
849
  };
845
850
  check("headline", fields.headline, HEADLINE_MAX_CHARS);
846
851
  check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { CookieSettingsView } from './chunk-PBRDIXQI.js';
8
8
  import { localPublications } from './chunk-NNEY6VBX.js';
9
9
  export { COOKIE_CATEGORIES, COOKIE_CONSENT_BACKUP_KEY, COOKIE_CONSENT_EVENT, COOKIE_CONSENT_KEY, COOKIE_CONSENT_MAX_AGE_SECONDS, DEFAULT_CONSENT_HUB_URL, DEFAULT_PREFERENCES_ACCEPTED, DEFAULT_PREFERENCES_REJECTED, EDITORIAL_PRINCIPLES, EU_EEA_UK_COUNTRIES, NETWORK_CONSENT_STORAGE_KEY, NETWORK_PARENT, acceptAllCookies, checkUrlConsentBridge, clearCookieConsent, communityPublications, consentModel, decorateNetworkUrl, effectivePreferences, findPublicationByHost, findPublicationBySlug, getCookieConsent, getNetworkPublicationUrl, getNetworkReaderHandoffUrl, hasConsented, impliedPreferences, isCategoryAllowed, isGeoConsentRequired, listedPublications, localPublications, queryNetworkConsentHub, recordConsentToCompliance, rejectOptionalCookies, setCookieConsent, setNetworkConsentHub, topicalPublications } from './chunk-NNEY6VBX.js';
10
10
  export { THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, ThemeToggle, applyTheme, clearTheme, getStoredTheme, getSystemTheme, resolveEffectiveTheme, setTheme, subscribeTheme, themeInitScript } from './chunk-NQD3M7ZY.js';
11
- export { FLY_TITLE_MAX_CHARS, HEADLINE_MAX_CHARS, HEADLINE_RECOMMENDED_CHARS, RUBRIC_MAX_CHARS, SEO_TITLE_MAX_CHARS, headlineLength, headlineProblems, headlineSet } from './chunk-SNQKSUWB.js';
11
+ export { FLY_TITLE_MAX_CHARS, HEADLINE_MAX_CHARS, HEADLINE_RECOMMENDED_CHARS, RUBRIC_MAX_CHARS, SEO_TITLE_MAX_CHARS, headlineLength, headlineProblems, headlineSet } from './chunk-7SAFGLPH.js';
12
12
  export { communityMapEmbeds } from './chunk-IMYBSG7J.js';
13
13
  export { articleFormatLabel, articleHeroRatio, articlePresentation, formatArtworkCaption, parseDocumentBlocks, renderArticleBlocksToHtml, sanitizeInlineMarkdown } from './chunk-DN3TBZLK.js';
14
14
  import { definePublication } from './chunk-57YVL6BD.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.93",
3
+ "version": "0.0.95",
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": {