@scalemule/news-publication 0.0.92 → 0.0.94

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.
@@ -18,7 +18,9 @@ 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, citations and tenant-owned tag_ids in one immutable revision.
21
+ edit saves prose, excerpt, cards, citations, tenant-owned tag_ids and the headline set (title,
22
+ headline <= 120 chars with 70 recommended, fly_title <= 40, subtitle as the rubric, seo_title) in
23
+ one immutable revision.
22
24
  Unspecified fields and media are retained. Default: save for review.
23
25
  --release explicitly requests release under the existing policy; it never issues
24
26
  APPROVE or changes a policy. Legacy onboarding requires an explicit
@@ -157,7 +159,7 @@ try {
157
159
  while(!verified){
158
160
  const response=await fetch(values.url,{redirect:'error',cache:'no-store',signal:AbortSignal.timeout(Math.min(5000,remaining()))});
159
161
  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),tags:checkpoint.document.tag_ids?article.tags:undefined});
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});
161
163
  if(!verified)await new Promise(r=>setTimeout(r,Math.min(300,remaining())));
162
164
  }
163
165
  }catch(error){reason=error.message;}
@@ -31,8 +31,9 @@ var text = (html) => decode(html.replace(/<[^>]*>/gu, " ")).replace(/\s+/gu, " "
31
31
  var plain = (value) => value.replace(/\s+/gu, " ").trim();
32
32
  function articleEditHtmlMatches(html, expected) {
33
33
  const visible = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
34
+ const shown = plain(expected.headline?.trim() || expected.title);
34
35
  const headline = [...visible.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu)].some(
35
- (m) => text(m[1]) === plain(expected.title)
36
+ (m) => text(m[1]) === shown
36
37
  );
37
38
  if (!headline) return false;
38
39
  const paragraphs = [
@@ -339,6 +340,34 @@ async function runArticlePhoto(t, input) {
339
340
  }
340
341
  }
341
342
 
343
+ // src/headline.ts
344
+ var HEADLINE_MAX_CHARS = 120;
345
+ var FLY_TITLE_MAX_CHARS = 40;
346
+ var RUBRIC_MAX_CHARS = 500;
347
+ var SEO_TITLE_MAX_CHARS = 200;
348
+ var headlineLength = (value) => Array.from(value).length;
349
+ function headlineProblems(fields) {
350
+ const problems = [];
351
+ const check = (name, value, max) => {
352
+ if (value === void 0 || value === null) return;
353
+ if (typeof value !== "string") {
354
+ problems.push(`${name} must be a string`);
355
+ return;
356
+ }
357
+ if (!value.trim()) {
358
+ problems.push(`${name} is blank; omit it to fall back to the title`);
359
+ return;
360
+ }
361
+ if (headlineLength(value.trim()) > max) problems.push(`${name} exceeds ${max} characters`);
362
+ if (/[\p{Cc}]/u.test(value)) problems.push(`${name} contains control characters`);
363
+ };
364
+ check("headline", fields.headline, HEADLINE_MAX_CHARS);
365
+ check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
366
+ check("subtitle", fields.subtitle, RUBRIC_MAX_CHARS);
367
+ check("seo_title", fields.seo_title, SEO_TITLE_MAX_CHARS);
368
+ return problems;
369
+ }
370
+
342
371
  // src/article-edit-operation.ts
343
372
  var ArticleEditOperationError = class extends Error {
344
373
  constructor(code, stage, message, status, revisionId) {
@@ -378,7 +407,10 @@ var digest2 = async (v) => [
378
407
  ].map((v2) => v2.toString(16).padStart(2, "0")).join("");
379
408
  var patchKeys = /* @__PURE__ */ new Set([
380
409
  "title",
410
+ "headline",
411
+ "fly_title",
381
412
  "subtitle",
413
+ "seo_title",
382
414
  "content_markdown",
383
415
  "excerpt",
384
416
  "section",
@@ -407,6 +439,8 @@ function validate(input) {
407
439
  } else if (typeof value !== "string")
408
440
  throw Error(key + " must be a string");
409
441
  }
442
+ const problems = headlineProblems(input.patch);
443
+ if (problems.length) throw Error(problems.join("; "));
410
444
  if (input.expectedGeneration !== void 0 && (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1))
411
445
  throw Error("Expected generation must be positive");
412
446
  if (input.enableReviewPolicy && ![
@@ -477,6 +511,13 @@ async function runArticleEdit(t, input) {
477
511
  throw error;
478
512
  })
479
513
  ]);
514
+ const headlineKeys = ["headline", "fly_title", "seo_title"].filter((key) => key in input.patch);
515
+ if (headlineKeys.length && options.capabilities?.headline_set !== true)
516
+ throw new ArticleEditOperationError(
517
+ "CAPABILITY_UNAVAILABLE",
518
+ "preflight",
519
+ `The server must expose headline_set before editing ${headlineKeys.join(", ")}; no article was changed`
520
+ );
480
521
  if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
481
522
  throw new ArticleEditOperationError(
482
523
  "CAPABILITY_UNAVAILABLE",
@@ -6,6 +6,7 @@ declare function articlePhotoHtmlMatches(html: string, lead: string | string[],
6
6
  /** Requires visible body and actual card asides; serialized page data is not delivery. */
7
7
  declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
+ headline?: string | null;
9
10
  content_html: string;
10
11
  card_titles: string[];
11
12
  tags?: {
@@ -52,7 +53,7 @@ declare class ArticlePhotoOperationError extends Error {
52
53
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
53
54
  }
54
55
 
55
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
56
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "headline" | "fly_title" | "subtitle" | "seo_title" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
56
57
  type ArticleEditCheckpoint = {
57
58
  version: 1;
58
59
  identity: string;
@@ -92,7 +93,14 @@ declare class ArticleEditOperationError extends Error {
92
93
 
93
94
  type ArticleDocument = {
94
95
  title: string;
96
+ /** Short display headline (<= 120 characters, 70 recommended); see `headlineSet`. */
97
+ headline?: string;
98
+ /** Kicker above the headline (<= 40 characters). */
99
+ fly_title?: string;
100
+ /** The rubric (deck). */
95
101
  subtitle?: string;
102
+ /** Search title for <title> and previews. */
103
+ seo_title?: string;
96
104
  content_markdown: string;
97
105
  excerpt?: string;
98
106
  cards?: unknown[];
@@ -200,6 +208,8 @@ type ArticleWorkflowOptions = {
200
208
  prioritized_narration_processing?: boolean;
201
209
  content_first_editor_document?: boolean;
202
210
  versioned_editorial_fields?: boolean;
211
+ /** The server stores headline, fly_title and seo_title on the versioned document. */
212
+ headline_set?: boolean;
203
213
  versioned_editorial_tags?: boolean;
204
214
  managed_submission_version?: number | null;
205
215
  multipart_submission?: boolean;
@@ -6,6 +6,7 @@ declare function articlePhotoHtmlMatches(html: string, lead: string | string[],
6
6
  /** Requires visible body and actual card asides; serialized page data is not delivery. */
7
7
  declare function articleEditHtmlMatches(html: string, expected: {
8
8
  title: string;
9
+ headline?: string | null;
9
10
  content_html: string;
10
11
  card_titles: string[];
11
12
  tags?: {
@@ -52,7 +53,7 @@ declare class ArticlePhotoOperationError extends Error {
52
53
  constructor(code: string, stage: string, message: string, fileId?: string | undefined, status?: number | undefined);
53
54
  }
54
55
 
55
- type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "subtitle" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
56
+ type ArticleEditPatch = Partial<Pick<ArticleDocument, "title" | "headline" | "fly_title" | "subtitle" | "seo_title" | "content_markdown" | "excerpt" | "section" | "community" | "format" | "layout" | "cards" | "citations" | "tag_ids">>;
56
57
  type ArticleEditCheckpoint = {
57
58
  version: 1;
58
59
  identity: string;
@@ -92,7 +93,14 @@ declare class ArticleEditOperationError extends Error {
92
93
 
93
94
  type ArticleDocument = {
94
95
  title: string;
96
+ /** Short display headline (<= 120 characters, 70 recommended); see `headlineSet`. */
97
+ headline?: string;
98
+ /** Kicker above the headline (<= 40 characters). */
99
+ fly_title?: string;
100
+ /** The rubric (deck). */
95
101
  subtitle?: string;
102
+ /** Search title for <title> and previews. */
103
+ seo_title?: string;
96
104
  content_markdown: string;
97
105
  excerpt?: string;
98
106
  cards?: unknown[];
@@ -200,6 +208,8 @@ type ArticleWorkflowOptions = {
200
208
  prioritized_narration_processing?: boolean;
201
209
  content_first_editor_document?: boolean;
202
210
  versioned_editorial_fields?: boolean;
211
+ /** The server stores headline, fly_title and seo_title on the versioned document. */
212
+ headline_set?: boolean;
203
213
  versioned_editorial_tags?: boolean;
204
214
  managed_submission_version?: number | null;
205
215
  multipart_submission?: boolean;
@@ -1,3 +1,5 @@
1
+ import { headlineProblems } from './chunk-7SAFGLPH.js';
2
+
1
3
  // src/article-photo-verification.ts
2
4
  function articlePhotoHtmlMatches(html, lead, social) {
3
5
  const contains = (tag, url) => tag.includes(url) || tag.includes(encodeURIComponent(url));
@@ -29,8 +31,9 @@ var text = (html) => decode(html.replace(/<[^>]*>/gu, " ")).replace(/\s+/gu, " "
29
31
  var plain = (value) => value.replace(/\s+/gu, " ").trim();
30
32
  function articleEditHtmlMatches(html, expected) {
31
33
  const visible = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/giu, " ");
34
+ const shown = plain(expected.headline?.trim() || expected.title);
32
35
  const headline = [...visible.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu)].some(
33
- (m) => text(m[1]) === plain(expected.title)
36
+ (m) => text(m[1]) === shown
34
37
  );
35
38
  if (!headline) return false;
36
39
  const paragraphs = [
@@ -376,7 +379,10 @@ var digest2 = async (v) => [
376
379
  ].map((v2) => v2.toString(16).padStart(2, "0")).join("");
377
380
  var patchKeys = /* @__PURE__ */ new Set([
378
381
  "title",
382
+ "headline",
383
+ "fly_title",
379
384
  "subtitle",
385
+ "seo_title",
380
386
  "content_markdown",
381
387
  "excerpt",
382
388
  "section",
@@ -405,6 +411,8 @@ function validate(input) {
405
411
  } else if (typeof value !== "string")
406
412
  throw Error(key + " must be a string");
407
413
  }
414
+ const problems = headlineProblems(input.patch);
415
+ if (problems.length) throw Error(problems.join("; "));
408
416
  if (input.expectedGeneration !== void 0 && (!Number.isSafeInteger(input.expectedGeneration) || input.expectedGeneration < 1))
409
417
  throw Error("Expected generation must be positive");
410
418
  if (input.enableReviewPolicy && ![
@@ -475,6 +483,13 @@ async function runArticleEdit(t, input) {
475
483
  throw error;
476
484
  })
477
485
  ]);
486
+ const headlineKeys = ["headline", "fly_title", "seo_title"].filter((key) => key in input.patch);
487
+ if (headlineKeys.length && options.capabilities?.headline_set !== true)
488
+ throw new ArticleEditOperationError(
489
+ "CAPABILITY_UNAVAILABLE",
490
+ "preflight",
491
+ `The server must expose headline_set before editing ${headlineKeys.join(", ")}; no article was changed`
492
+ );
478
493
  if (options.workflow_version !== 1 || options.capabilities?.versioned_editorial_fields !== true)
479
494
  throw new ArticleEditOperationError(
480
495
  "CAPABILITY_UNAVAILABLE",
@@ -0,0 +1,47 @@
1
+ // src/headline.ts
2
+ var HEADLINE_RECOMMENDED_CHARS = 70;
3
+ var HEADLINE_MAX_CHARS = 120;
4
+ var FLY_TITLE_MAX_CHARS = 40;
5
+ var RUBRIC_MAX_CHARS = 500;
6
+ var SEO_TITLE_MAX_CHARS = 200;
7
+ var text = (value) => {
8
+ if (typeof value !== "string") return void 0;
9
+ const trimmed = value.replace(/\s+/gu, " ").trim();
10
+ return trimmed || void 0;
11
+ };
12
+ var headlineLength = (value) => Array.from(value).length;
13
+ function headlineSet(article, selection = {}) {
14
+ const display = selection.stories?.[article.slug];
15
+ const title = text(article.title) ?? "";
16
+ const headline = text(display?.display_title) ?? text(article.headline) ?? title;
17
+ return {
18
+ flyTitle: text(display?.fly_title) ?? text(article.fly_title) ?? text(article.section),
19
+ headline,
20
+ rubric: text(display?.deck) ?? text(article.subtitle),
21
+ seoTitle: text(article.seo_title) ?? title,
22
+ title
23
+ };
24
+ }
25
+ function headlineProblems(fields) {
26
+ const problems = [];
27
+ const check = (name, value, max) => {
28
+ if (value === void 0 || value === null) return;
29
+ if (typeof value !== "string") {
30
+ problems.push(`${name} must be a string`);
31
+ return;
32
+ }
33
+ if (!value.trim()) {
34
+ problems.push(`${name} is blank; omit it to fall back to the title`);
35
+ return;
36
+ }
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`);
39
+ };
40
+ check("headline", fields.headline, HEADLINE_MAX_CHARS);
41
+ check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
42
+ check("subtitle", fields.subtitle, RUBRIC_MAX_CHARS);
43
+ check("seo_title", fields.seo_title, SEO_TITLE_MAX_CHARS);
44
+ return problems;
45
+ }
46
+
47
+ export { FLY_TITLE_MAX_CHARS, HEADLINE_MAX_CHARS, HEADLINE_RECOMMENDED_CHARS, RUBRIC_MAX_CHARS, SEO_TITLE_MAX_CHARS, headlineLength, headlineProblems, headlineSet };
package/dist/index.cjs CHANGED
@@ -122,14 +122,14 @@ function resolveSection(item) {
122
122
  if (/\b(food|dining|restaurant|restaurants|wine|winery|brewery|bakery|cafe|coffee|cuisine|chef)\b/i.test(desc)) return "Food & places";
123
123
  if (/\b(community|volunteer|volunteers|library|libraries|neighborhood|nonprofit|charity|youth|senior|seniors)\b/i.test(desc)) return "Community";
124
124
  }
125
- const text = `${item.title} ${item.excerpt ?? ""}`;
126
- if (/\b(health|hospitals?|clinics?|medical|doctors?|pharmac(?:y|ies)|wellness|veterinar(?:y|ian)|pet care|animal welfare|vaccinat(?:ion|ions)|patients?)\b/iu.test(text)) return "Health";
127
- if (/\b(real estate|homeownership|mortgage|housing|tenants?|apartments?|below-market rate|home sales?|condos?)\b/iu.test(text)) return "Real Estate";
128
- if (/\b(city council|planning commission|board of supervisors|ballot|measure [a-z0-9]|election|transit|traffic|police|firefighter|wildfire|evacuation|pedestrian safety|city manager|public works)\b/iu.test(text)) return "Civic life";
129
- if (/\b(storefront|retail|downtown business|chamber of commerce|opens? doors?|boutique|merchants?|shoppers?|commercial space)\b/iu.test(text)) return "Local businesses";
130
- if (/\b(festival|parade|fair|concert|workshop|exhibition|showcase|celebration|weigh-off|halloween|trunk-or-treat)\b/iu.test(text)) return "Events";
131
- if (/\b(schools?|students?|teachers?|campuses?|classrooms?|high school|elementary|school district)\b/iu.test(text)) return "Schools";
132
- if (/\b(restaurants?|cafes?|baker(?:y|ies)|winer(?:y|ies)|brewer(?:y|ies)|dining|chefs?|menus?)\b/iu.test(text)) return "Food & places";
125
+ const text2 = `${item.title} ${item.excerpt ?? ""}`;
126
+ if (/\b(health|hospitals?|clinics?|medical|doctors?|pharmac(?:y|ies)|wellness|veterinar(?:y|ian)|pet care|animal welfare|vaccinat(?:ion|ions)|patients?)\b/iu.test(text2)) return "Health";
127
+ if (/\b(real estate|homeownership|mortgage|housing|tenants?|apartments?|below-market rate|home sales?|condos?)\b/iu.test(text2)) return "Real Estate";
128
+ if (/\b(city council|planning commission|board of supervisors|ballot|measure [a-z0-9]|election|transit|traffic|police|firefighter|wildfire|evacuation|pedestrian safety|city manager|public works)\b/iu.test(text2)) return "Civic life";
129
+ if (/\b(storefront|retail|downtown business|chamber of commerce|opens? doors?|boutique|merchants?|shoppers?|commercial space)\b/iu.test(text2)) return "Local businesses";
130
+ if (/\b(festival|parade|fair|concert|workshop|exhibition|showcase|celebration|weigh-off|halloween|trunk-or-treat)\b/iu.test(text2)) return "Events";
131
+ if (/\b(schools?|students?|teachers?|campuses?|classrooms?|high school|elementary|school district)\b/iu.test(text2)) return "Schools";
132
+ if (/\b(restaurants?|cafes?|baker(?:y|ies)|winer(?:y|ies)|brewer(?:y|ies)|dining|chefs?|menus?)\b/iu.test(text2)) return "Food & places";
133
133
  return "Community";
134
134
  }
135
135
  function orderedSections(sections2, order = EDITORIAL_SECTION_ORDER) {
@@ -234,7 +234,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
234
234
  return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
235
235
  }
236
236
  function displayHeadline(article, selection = {}) {
237
- return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
237
+ return selection.stories?.[article.slug]?.display_title?.trim() || article.headline?.trim() || article.title;
238
238
  }
239
239
  function briefSummary(article, selection = {}) {
240
240
  const chosen = selection.stories?.[article.slug]?.brief?.trim();
@@ -284,11 +284,11 @@ function nearbyUpdates(items, selection = {}, now = Date.now()) {
284
284
  return activeNearby(selection, now).filter((update) => published.has(update.slug)).map((update) => ({ ...update, article: published.get(update.slug) }));
285
285
  }
286
286
  function shortDeck(value, limit = 160) {
287
- const text = value?.trim().replace(/\s+/gu, " ") ?? "";
288
- const characters = Array.from(text);
289
- if (characters.length <= limit) return text;
287
+ const text2 = value?.trim().replace(/\s+/gu, " ") ?? "";
288
+ const characters = Array.from(text2);
289
+ if (characters.length <= limit) return text2;
290
290
  let sentences = "";
291
- for (const { segment } of new Intl.Segmenter("en", { granularity: "sentence" }).segment(text)) {
291
+ for (const { segment } of new Intl.Segmenter("en", { granularity: "sentence" }).segment(text2)) {
292
292
  if (Array.from(sentences + segment.trimEnd()).length > limit) break;
293
293
  sentences += segment;
294
294
  }
@@ -322,8 +322,9 @@ function newsArticleJsonLd(publication, article, canonical, authorName) {
322
322
  return {
323
323
  "@context": "https://schema.org",
324
324
  "@type": "NewsArticle",
325
- headline: article.title,
326
- description: article.excerpt ?? void 0,
325
+ headline: article.headline?.trim() || article.title,
326
+ ...article.headline?.trim() && article.headline.trim() !== article.title ? { alternativeHeadline: article.title } : {},
327
+ description: article.subtitle ?? article.excerpt ?? void 0,
327
328
  datePublished: article.published_at,
328
329
  ...article.metadata?.editorial?.modified_at ? { dateModified: article.metadata.editorial.modified_at } : {},
329
330
  // updated_at also changes for media/operational maintenance. A trusted
@@ -370,15 +371,15 @@ function articleMeta(publication, article, canonical, indexable) {
370
371
  const canonicalUrl = publicHttps(article.canonical_url) ?? url;
371
372
  const image = publicHttps(article.og_image_url) ?? publicHttps(article.cover_image_url);
372
373
  return {
373
- title: article.title,
374
- description: article.seo_description ?? article.excerpt ?? publication.description,
374
+ title: article.seo_title?.trim() || article.title,
375
+ description: article.seo_description ?? article.subtitle ?? article.excerpt ?? publication.description,
375
376
  canonical: canonicalUrl,
376
377
  indexable: indexable && !article.no_index,
377
378
  openGraph: {
378
379
  type: "article",
379
380
  siteName: publication.name,
380
- title: article.title,
381
- description: article.excerpt ?? void 0,
381
+ title: article.headline?.trim() || article.title,
382
+ description: article.subtitle ?? article.excerpt ?? void 0,
382
383
  url,
383
384
  publishedTime: article.published_at,
384
385
  images: image ? [image] : []
@@ -807,6 +808,52 @@ function resolveNavigation(context) {
807
808
  return { primary, groups, featured: activeFeatured(context.config, now) };
808
809
  }
809
810
 
811
+ // src/headline.ts
812
+ var HEADLINE_RECOMMENDED_CHARS = 70;
813
+ var HEADLINE_MAX_CHARS = 120;
814
+ var FLY_TITLE_MAX_CHARS = 40;
815
+ var RUBRIC_MAX_CHARS = 500;
816
+ var SEO_TITLE_MAX_CHARS = 200;
817
+ var text = (value) => {
818
+ if (typeof value !== "string") return void 0;
819
+ const trimmed = value.replace(/\s+/gu, " ").trim();
820
+ return trimmed || void 0;
821
+ };
822
+ var headlineLength = (value) => Array.from(value).length;
823
+ function headlineSet(article, selection = {}) {
824
+ const display = selection.stories?.[article.slug];
825
+ const title = text(article.title) ?? "";
826
+ const headline = text(display?.display_title) ?? text(article.headline) ?? title;
827
+ return {
828
+ flyTitle: text(display?.fly_title) ?? text(article.fly_title) ?? text(article.section),
829
+ headline,
830
+ rubric: text(display?.deck) ?? text(article.subtitle),
831
+ seoTitle: text(article.seo_title) ?? title,
832
+ title
833
+ };
834
+ }
835
+ function headlineProblems(fields) {
836
+ const problems = [];
837
+ const check = (name, value, max) => {
838
+ if (value === void 0 || value === null) return;
839
+ if (typeof value !== "string") {
840
+ problems.push(`${name} must be a string`);
841
+ return;
842
+ }
843
+ if (!value.trim()) {
844
+ problems.push(`${name} is blank; omit it to fall back to the title`);
845
+ return;
846
+ }
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`);
849
+ };
850
+ check("headline", fields.headline, HEADLINE_MAX_CHARS);
851
+ check("fly_title", fields.fly_title, FLY_TITLE_MAX_CHARS);
852
+ check("subtitle", fields.subtitle, RUBRIC_MAX_CHARS);
853
+ check("seo_title", fields.seo_title, SEO_TITLE_MAX_CHARS);
854
+ return problems;
855
+ }
856
+
810
857
  // src/analytics.ts
811
858
  var ANALYTICS_VERSION = 1;
812
859
  var ANALYTICS_COOKIE = "napsite_analytics_v1";
@@ -869,9 +916,9 @@ var PUBLIC_PATHS = /* @__PURE__ */ new Set([
869
916
  ]);
870
917
  function shortText(value, max = 100) {
871
918
  if (typeof value !== "string") return void 0;
872
- const text = value.trim();
873
- if (!text || /[@\r\n\u0000-\u001f]/.test(text)) return void 0;
874
- return Array.from(text).slice(0, max).join("");
919
+ const text2 = value.trim();
920
+ if (!text2 || /[@\r\n\u0000-\u001f]/.test(text2)) return void 0;
921
+ return Array.from(text2).slice(0, max).join("");
875
922
  }
876
923
  function publicUrl(value, originOnly = false) {
877
924
  if (typeof value !== "string") return void 0;
@@ -979,13 +1026,13 @@ function canHostMeetup(accountCreatedAt, now = /* @__PURE__ */ new Date()) {
979
1026
  return Number.isFinite(created) && now.getTime() - created >= HOST_WAIT_MS;
980
1027
  }
981
1028
  function screenMessage(value) {
982
- const text = Array.from(value.trim()).slice(0, 1e3).join("");
983
- if (Array.from(text).length < 1) return { ok: false, reason: "Write a short message." };
984
- if (/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(text)) return { ok: false, reason: "Share an email in person, not in the chat." };
985
- if (/(https?:\/\/|www\.)\S+/i.test(text)) return { ok: false, reason: "Links are not allowed in the chat." };
986
- const digits = text.replace(/\D/g, "");
1029
+ const text2 = Array.from(value.trim()).slice(0, 1e3).join("");
1030
+ if (Array.from(text2).length < 1) return { ok: false, reason: "Write a short message." };
1031
+ if (/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(text2)) return { ok: false, reason: "Share an email in person, not in the chat." };
1032
+ if (/(https?:\/\/|www\.)\S+/i.test(text2)) return { ok: false, reason: "Links are not allowed in the chat." };
1033
+ const digits = text2.replace(/\D/g, "");
987
1034
  if (digits.length >= 10) return { ok: false, reason: "Share a phone number in person, not in the chat." };
988
- return { ok: true, text };
1035
+ return { ok: true, text: text2 };
989
1036
  }
990
1037
  function activityLabel(value, otherLabel = "") {
991
1038
  if (value === "other") return Array.from(otherLabel.trim()).slice(0, 40).join("") || "Something else";
@@ -1088,9 +1135,9 @@ function classifiedPath(id) {
1088
1135
  return `/v1/listings/${encodeURIComponent(id)}`;
1089
1136
  }
1090
1137
  function requireText(value, label, max, min = 1) {
1091
- const text = clipChars(typeof value === "string" ? value : "", max);
1092
- if (Array.from(text).length < min) throw new Error(`Please complete the ${label}.`);
1093
- return text;
1138
+ const text2 = clipChars(typeof value === "string" ? value : "", max);
1139
+ if (Array.from(text2).length < min) throw new Error(`Please complete the ${label}.`);
1140
+ return text2;
1094
1141
  }
1095
1142
  function prepareJobPost(input, towns) {
1096
1143
  const title = requireText(input.title, "job title", 120);
@@ -1623,7 +1670,7 @@ var COOKIE_CATEGORIES = [
1623
1670
  ];
1624
1671
  function CookieConsentBanner({
1625
1672
  title = "Your privacy choices",
1626
- text,
1673
+ text: text2,
1627
1674
  settingsUrl = "/cookies",
1628
1675
  className = "",
1629
1676
  publicationSlug,
@@ -1692,8 +1739,8 @@ function CookieConsentBanner({
1692
1739
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-cookie-banner-card", children: [
1693
1740
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-cookie-banner-content", children: [
1694
1741
  /* @__PURE__ */ jsxRuntime.jsx("h2", { className: "sm-cookie-banner-title", children: title }),
1695
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sm-cookie-banner-text", children: text ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1696
- text,
1742
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "sm-cookie-banner-text", children: text2 ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1743
+ text2,
1697
1744
  " ",
1698
1745
  /* @__PURE__ */ jsxRuntime.jsx("a", { href: settingsUrl, className: "sm-cookie-banner-link", children: "Cookie Settings" })
1699
1746
  ] }) : defaultText })
@@ -2398,12 +2445,12 @@ function readerSources(input) {
2398
2445
  const url = new URL(value.url);
2399
2446
  if (url.protocol !== "https:" || url.username || url.password || seen.has(url.href)) continue;
2400
2447
  seen.add(url.href);
2401
- const text = (raw, limit) => {
2448
+ const text2 = (raw, limit) => {
2402
2449
  if (typeof raw !== "string") return void 0;
2403
2450
  const trimmed = raw.trim();
2404
2451
  return trimmed ? Array.from(trimmed).slice(0, limit).join("") : void 0;
2405
2452
  };
2406
- output.push({ url: url.href, title: text(value.title, 500), publisher: text(value.publisher, 255) });
2453
+ output.push({ url: url.href, title: text2(value.title, 500), publisher: text2(value.publisher, 255) });
2407
2454
  if (output.length === 5) break;
2408
2455
  } catch {
2409
2456
  continue;
@@ -3254,23 +3301,23 @@ function normalizeUuid(id) {
3254
3301
  }
3255
3302
  return id.trim().toLowerCase();
3256
3303
  }
3257
- function escapeHtml(text) {
3258
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3304
+ function escapeHtml(text2) {
3305
+ return text2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3259
3306
  }
3260
3307
  function sanitizeInlineMarkdown(raw) {
3261
- let text = raw.trim();
3262
- text = text.replace(/on\w+\s*=\s*(?:["'][^"']*["']|[^\s>]+)/gi, "");
3263
- text = text.replace(/javascript\s*:/gi, "");
3264
- text = escapeHtml(text);
3265
- text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
3266
- text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
3267
- text = text.replace(/\*([^*]+?)\*/g, "<em>$1</em>");
3268
- text = text.replace(/_([^_]+?)_/g, "<em>$1</em>");
3269
- text = text.replace(/`([^`]+?)`/g, "<code>$1</code>");
3270
- text = text.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)]+)\)/g, (_m, label, url) => {
3308
+ let text2 = raw.trim();
3309
+ text2 = text2.replace(/on\w+\s*=\s*(?:["'][^"']*["']|[^\s>]+)/gi, "");
3310
+ text2 = text2.replace(/javascript\s*:/gi, "");
3311
+ text2 = escapeHtml(text2);
3312
+ text2 = text2.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
3313
+ text2 = text2.replace(/__(.+?)__/g, "<strong>$1</strong>");
3314
+ text2 = text2.replace(/\*([^*]+?)\*/g, "<em>$1</em>");
3315
+ text2 = text2.replace(/_([^_]+?)_/g, "<em>$1</em>");
3316
+ text2 = text2.replace(/`([^`]+?)`/g, "<code>$1</code>");
3317
+ text2 = text2.replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)]+)\)/g, (_m, label, url) => {
3271
3318
  return `<a href="${escapeHtml(url)}" rel="noopener noreferrer nofollow">${label}</a>`;
3272
3319
  });
3273
- return text;
3320
+ return text2;
3274
3321
  }
3275
3322
  function parseDocumentBlocks(doc) {
3276
3323
  const mediaList = doc.media || [];
@@ -3563,7 +3610,7 @@ function measurementConsent(headers, sponsorship = false, now = Date.now()) {
3563
3610
  }
3564
3611
 
3565
3612
  // src/sponsorship-report.ts
3566
- var escape = (text) => String(text ?? "").replace(
3613
+ var escape = (text2) => String(text2 ?? "").replace(
3567
3614
  /[&<>"']/g,
3568
3615
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
3569
3616
  );
@@ -3934,6 +3981,9 @@ exports.EDITORIAL_PRINCIPLES = EDITORIAL_PRINCIPLES;
3934
3981
  exports.EDITORIAL_SECTION_ORDER = EDITORIAL_SECTION_ORDER;
3935
3982
  exports.EVENTS = EVENTS;
3936
3983
  exports.FEATURE_NAMES = FEATURE_NAMES;
3984
+ exports.FLY_TITLE_MAX_CHARS = FLY_TITLE_MAX_CHARS;
3985
+ exports.HEADLINE_MAX_CHARS = HEADLINE_MAX_CHARS;
3986
+ exports.HEADLINE_RECOMMENDED_CHARS = HEADLINE_RECOMMENDED_CHARS;
3937
3987
  exports.JOB_EMPLOYMENT_TYPES = JOB_EMPLOYMENT_TYPES;
3938
3988
  exports.LAYOUT_COOKIE = LAYOUT_COOKIE;
3939
3989
  exports.LOCAL_BRIEFING_POLICY_VERSION = LOCAL_BRIEFING_POLICY_VERSION;
@@ -3947,6 +3997,8 @@ exports.NetworkCopyrightNotice = NetworkCopyrightNotice;
3947
3997
  exports.NewsError = NewsError;
3948
3998
  exports.READER_SSO_EVENTS = READER_SSO_EVENTS;
3949
3999
  exports.READER_SSO_PROPERTY_KEYS = READER_SSO_PROPERTY_KEYS;
4000
+ exports.RUBRIC_MAX_CHARS = RUBRIC_MAX_CHARS;
4001
+ exports.SEO_TITLE_MAX_CHARS = SEO_TITLE_MAX_CHARS;
3950
4002
  exports.SESSION_IDLE_MS = SESSION_IDLE_MS;
3951
4003
  exports.SPONSORSHIP_OFFERINGS = SPONSORSHIP_OFFERINGS;
3952
4004
  exports.SPONSORSHIP_POLICY = SPONSORSHIP_POLICY;
@@ -4008,6 +4060,9 @@ exports.getNetworkPublicationUrl = getNetworkPublicationUrl;
4008
4060
  exports.getNetworkReaderHandoffUrl = getNetworkReaderHandoffUrl;
4009
4061
  exports.getStoredTheme = getStoredTheme;
4010
4062
  exports.getSystemTheme = getSystemTheme;
4063
+ exports.headlineLength = headlineLength;
4064
+ exports.headlineProblems = headlineProblems;
4065
+ exports.headlineSet = headlineSet;
4011
4066
  exports.inventoryCacheOptions = inventoryCacheOptions;
4012
4067
  exports.isAdult = isAdult;
4013
4068
  exports.isClassifiedCategory = isClassifiedCategory;
package/dist/index.d.cts CHANGED
@@ -1,8 +1,8 @@
1
- import { a as PublicationTheme, P as PublicationInput, N as NewsPublication, F as FeatureName, b as NewsItem, H as HomepageSelection, c as NearbyUpdate, E as EditorialSection, d as NewsArticle, C as CouponOffer } from './types-22lEf_UU.cjs';
2
- export { A as ArticleCitation, e as CardType, f as CategoryRef, g as CouponSettings, h as CoverImageMetadata, i as FEATURE_NAMES, j as FeatureFlags, k as NewsFeed, l as NewsLayoutId, m as PublicationModules, R as ReferralSettings, S as StoryDisplay, T as TagRef, V as VerifiedCard, n as VerifiedCardAction, o as VerifiedCardFact, p as VerifiedCardImage } from './types-22lEf_UU.cjs';
1
+ import { a as PublicationTheme, P as PublicationInput, N as NewsPublication, F as FeatureName, b as NewsItem, H as HomepageSelection, c as NearbyUpdate, E as EditorialSection, d as NewsArticle, C as CouponOffer } from './types-aYDyFCGs.cjs';
2
+ export { A as ArticleCitation, e as CardType, f as CategoryRef, g as CouponSettings, h as CoverImageMetadata, i as FEATURE_NAMES, j as FeatureFlags, k as NewsFeed, l as NewsLayoutId, m as PublicationModules, R as ReferralSettings, S as StoryDisplay, T as TagRef, V as VerifiedCard, n as VerifiedCardAction, o as VerifiedCardFact, p as VerifiedCardImage } from './types-aYDyFCGs.cjs';
3
3
  export { M as MEET_ACTIVITIES, b as MeetActivity, c as MeetupInput, a as PreparedMeetup, P as PublicPlace, d as activityLabel, e as canHostMeetup, i as isAdult, p as prepareMeetup, s as screenMessage } from './meet-CO9Unz5M.cjs';
4
4
  export { b as CLASSIFIED_CATEGORIES, c as ClassifiedCategory, C as ClassifiedListing, E as EmploymentType, J as JOB_EMPLOYMENT_TYPES, d as PreparedClassified, e as PreparedJob, P as PublicDeal, f as PublicDealOffer, a as PublicJob, g as categoryLabel, h as classifiedPath, i as classifiedSearchPath, j as clipChars, k as dealDollars, l as dealGoPath, m as dealsFeedPath, n as employmentLabel, o as isClassifiedCategory, p as isEmploymentType, q as jobEmployerBody, r as jobOpeningBody, s as jobPostingBody, t as prepareClassified, u as prepareJobPost, v as publicJobPath, w as publicJobsPath } from './marketplace-29i5w4LS.cjs';
5
- export { b as PublicationRecord, P as PublicationSnapshot, S as SnapshotHooks, c as SyncSource, a as SyncedPublication, d as acceptRemoteRecord, e as acceptSnapshot, p as parseSnapshot, f as publicationFromSnapshot, g as publicationRecord, s as snapshotFromRecord, h as syncPublication } from './snapshot-BBTow5Bn.cjs';
5
+ export { b as PublicationRecord, P as PublicationSnapshot, S as SnapshotHooks, c as SyncSource, a as SyncedPublication, d as acceptRemoteRecord, e as acceptSnapshot, p as parseSnapshot, f as publicationFromSnapshot, g as publicationRecord, s as snapshotFromRecord, h as syncPublication } from './snapshot-C3kD6oFZ.cjs';
6
6
  import { CookieCategoryDefinition, CookieConsentRecord } from '@scalemule/compliance';
7
7
  export { COOKIE_CONSENT_BACKUP_KEY, COOKIE_CONSENT_EVENT, COOKIE_CONSENT_KEY, COOKIE_CONSENT_MAX_AGE_SECONDS, CookieCategory, CookieCategoryDefinition, CookieConsentRecord, CookieDisclosure, CookiePreferences, DEFAULT_CONSENT_HUB_URL, DEFAULT_PREFERENCES_ACCEPTED, DEFAULT_PREFERENCES_REJECTED, EU_EEA_UK_COUNTRIES, NETWORK_CONSENT_STORAGE_KEY, acceptAllCookies, checkUrlConsentBridge, clearCookieConsent, consentModel, decorateNetworkUrl, effectivePreferences, getCookieConsent, hasConsented, impliedPreferences, isCategoryAllowed, isGeoConsentRequired, queryNetworkConsentHub, rejectOptionalCookies, setCookieConsent, setNetworkConsentHub } from '@scalemule/compliance';
8
8
  export { CookieConsentBanner, CookieConsentBannerProps } from './cookie-consent-banner.cjs';
@@ -249,6 +249,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
249
249
  pendingImages: NewsItem[];
250
250
  navigation: EditorialSection[];
251
251
  };
252
+ /** The paper's override, else the article's short headline, else its long title (see `headlineSet`). */
252
253
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
253
254
  /** A complete existing sentence, not a cut-off or generated summary. */
254
255
  declare function briefSummary(article: NewsItem, selection?: HomepageSelection): string;
@@ -404,11 +405,12 @@ declare function newsArticleJsonLd(publication: NewsPublication, article: NewsAr
404
405
  mainEntityOfPage: string;
405
406
  url: string;
406
407
  dateModified?: string | undefined;
408
+ description: string | undefined;
409
+ datePublished: string;
410
+ alternativeHeadline?: string | undefined;
407
411
  "@context": string;
408
412
  "@type": string;
409
413
  headline: string;
410
- description: string | undefined;
411
- datePublished: string;
412
414
  };
413
415
  declare function publicationJsonLd(publication: NewsPublication, canonical: string): {
414
416
  "@context": string;
@@ -453,6 +455,48 @@ declare function articleMeta(publication: NewsPublication, article: NewsArticle,
453
455
  /** One line a paper can serve at /llms.txt. Towns and the article path stay explicit. */
454
456
  declare function llmsText(publication: NewsPublication, canonical: string): string;
455
457
 
458
+ /**
459
+ * The newspaper headline set. Every article may carry four pieces of title text, each with a job:
460
+ *
461
+ * - `fly_title` the kicker above the headline: a beat, column or section label (<= 40 characters);
462
+ * - `headline` the short display headline, written as a line, not a summary (recommended <= 70,
463
+ * hard limit 120); front pages and the article h1 show it;
464
+ * - `subtitle` the rubric (deck): one sentence that carries the facts;
465
+ * - `seo_title` the search title for <title> and link previews.
466
+ *
467
+ * `title` is the long descriptive title that every article already has; it is the fallback for the
468
+ * headline and the search title, so nothing changes for an article that carries only a title.
469
+ * A per-paper homepage override (`StoryDisplay.display_title`/`deck`) still wins where an editor set one.
470
+ */
471
+ declare const HEADLINE_RECOMMENDED_CHARS = 70;
472
+ declare const HEADLINE_MAX_CHARS = 120;
473
+ declare const FLY_TITLE_MAX_CHARS = 40;
474
+ declare const RUBRIC_MAX_CHARS = 500;
475
+ declare const SEO_TITLE_MAX_CHARS = 200;
476
+ type Headlined = Pick<NewsItem, "slug" | "title" | "section"> & Partial<Pick<NewsItem, "headline" | "fly_title" | "excerpt">> & Partial<Pick<NewsArticle, "subtitle" | "seo_title">>;
477
+ /** Characters, never bytes: Cyrillic, CJK and emoji count once each. */
478
+ declare const headlineLength: (value: string) => number;
479
+ type HeadlineSet = {
480
+ /** Kicker: the editor's fly title, else the section. */
481
+ flyTitle?: string;
482
+ /** Display headline: the paper's override, else the article's headline, else its title. */
483
+ headline: string;
484
+ /** Rubric: the paper's deck override, else the subtitle. Never a cut sentence. */
485
+ rubric?: string;
486
+ /** Search title for <title> and previews. */
487
+ seoTitle: string;
488
+ /** The long descriptive title, for alternativeHeadline when it differs from the headline. */
489
+ title: string;
490
+ };
491
+ declare function headlineSet(article: Headlined, selection?: HomepageSelection): HeadlineSet;
492
+ /** Validation shared by the edit command and the editors: hard limits match the service. */
493
+ declare function headlineProblems(fields: {
494
+ headline?: unknown;
495
+ fly_title?: unknown;
496
+ subtitle?: unknown;
497
+ seo_title?: unknown;
498
+ }): string[];
499
+
456
500
  /** Bounded event contract from Walnut Creek Times. Never accept raw form or chat text. */
457
501
  declare const ANALYTICS_VERSION = 1;
458
502
  declare const ANALYTICS_COOKIE = "napsite_analytics_v1";
@@ -675,4 +719,4 @@ declare function isSponsoredStoryItem(item: NewsItem): boolean;
675
719
  */
676
720
  declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
677
721
 
678
- export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ARTICLE_CONVERSION_EXPERIMENT, type ArticleConversionVariant, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, type CommunityInvitationCategory, type ContextualCommunityInvitation, CouponOffer, EDITORIAL_SECTION_ORDER, EVENTS, EditorialSection, type EffectiveTheme, FeatureName, type FeaturedDestination, HomepageSelection, LAYOUT_COOKIE, LOCAL_BRIEFING_POLICY_VERSION, type ListeningArticle, type LocalBriefing, type LocalBriefingItem, type LocalBriefingOptions, Measurement, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, type NavigationConfig, type NavigationContext, type NavigationDestination, type NavigationGroup, type NavigationGroupLinks, type NavigationKind, type NavigationLink, type NavigationOverride, NearbyUpdate, NewsArticle, NewsError, NewsItem, NewsPublication, PublicationInput, PublicationTheme, type ResolvedNavigation, SESSION_IDLE_MS, type SponsorshipReport, THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, type ThemeMode, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, applyTheme, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, classifyStoryInvitationCategory, clearTheme, composeEdition, composeLocalBriefing, contextualCommunityInvitation, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, inventoryCacheOptions, isSponsoredStoryItem, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveArticleConversionVariant, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, selectRelevantLocalStories, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
722
+ export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ARTICLE_CONVERSION_EXPERIMENT, type ArticleConversionVariant, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, type CommunityInvitationCategory, type ContextualCommunityInvitation, CouponOffer, EDITORIAL_SECTION_ORDER, EVENTS, EditorialSection, type EffectiveTheme, FLY_TITLE_MAX_CHARS, FeatureName, type FeaturedDestination, HEADLINE_MAX_CHARS, HEADLINE_RECOMMENDED_CHARS, type HeadlineSet, HomepageSelection, LAYOUT_COOKIE, LOCAL_BRIEFING_POLICY_VERSION, type ListeningArticle, type LocalBriefing, type LocalBriefingItem, type LocalBriefingOptions, Measurement, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, type NavigationConfig, type NavigationContext, type NavigationDestination, type NavigationGroup, type NavigationGroupLinks, type NavigationKind, type NavigationLink, type NavigationOverride, NearbyUpdate, NewsArticle, NewsError, NewsItem, NewsPublication, PublicationInput, PublicationTheme, RUBRIC_MAX_CHARS, type ResolvedNavigation, SEO_TITLE_MAX_CHARS, SESSION_IDLE_MS, type SponsorshipReport, THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, type ThemeMode, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, applyTheme, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, classifyStoryInvitationCategory, clearTheme, composeEdition, composeLocalBriefing, contextualCommunityInvitation, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, headlineLength, headlineProblems, headlineSet, inventoryCacheOptions, isSponsoredStoryItem, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveArticleConversionVariant, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, selectRelevantLocalStories, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { a as PublicationTheme, P as PublicationInput, N as NewsPublication, F as FeatureName, b as NewsItem, H as HomepageSelection, c as NearbyUpdate, E as EditorialSection, d as NewsArticle, C as CouponOffer } from './types-22lEf_UU.js';
2
- export { A as ArticleCitation, e as CardType, f as CategoryRef, g as CouponSettings, h as CoverImageMetadata, i as FEATURE_NAMES, j as FeatureFlags, k as NewsFeed, l as NewsLayoutId, m as PublicationModules, R as ReferralSettings, S as StoryDisplay, T as TagRef, V as VerifiedCard, n as VerifiedCardAction, o as VerifiedCardFact, p as VerifiedCardImage } from './types-22lEf_UU.js';
1
+ import { a as PublicationTheme, P as PublicationInput, N as NewsPublication, F as FeatureName, b as NewsItem, H as HomepageSelection, c as NearbyUpdate, E as EditorialSection, d as NewsArticle, C as CouponOffer } from './types-aYDyFCGs.js';
2
+ export { A as ArticleCitation, e as CardType, f as CategoryRef, g as CouponSettings, h as CoverImageMetadata, i as FEATURE_NAMES, j as FeatureFlags, k as NewsFeed, l as NewsLayoutId, m as PublicationModules, R as ReferralSettings, S as StoryDisplay, T as TagRef, V as VerifiedCard, n as VerifiedCardAction, o as VerifiedCardFact, p as VerifiedCardImage } from './types-aYDyFCGs.js';
3
3
  export { M as MEET_ACTIVITIES, b as MeetActivity, c as MeetupInput, a as PreparedMeetup, P as PublicPlace, d as activityLabel, e as canHostMeetup, i as isAdult, p as prepareMeetup, s as screenMessage } from './meet-BWpxY20X.js';
4
4
  export { b as CLASSIFIED_CATEGORIES, c as ClassifiedCategory, C as ClassifiedListing, E as EmploymentType, J as JOB_EMPLOYMENT_TYPES, d as PreparedClassified, e as PreparedJob, P as PublicDeal, f as PublicDealOffer, a as PublicJob, g as categoryLabel, h as classifiedPath, i as classifiedSearchPath, j as clipChars, k as dealDollars, l as dealGoPath, m as dealsFeedPath, n as employmentLabel, o as isClassifiedCategory, p as isEmploymentType, q as jobEmployerBody, r as jobOpeningBody, s as jobPostingBody, t as prepareClassified, u as prepareJobPost, v as publicJobPath, w as publicJobsPath } from './marketplace-29i5w4LS.js';
5
- export { b as PublicationRecord, P as PublicationSnapshot, S as SnapshotHooks, c as SyncSource, a as SyncedPublication, d as acceptRemoteRecord, e as acceptSnapshot, p as parseSnapshot, f as publicationFromSnapshot, g as publicationRecord, s as snapshotFromRecord, h as syncPublication } from './snapshot-CHdsfUnU.js';
5
+ export { b as PublicationRecord, P as PublicationSnapshot, S as SnapshotHooks, c as SyncSource, a as SyncedPublication, d as acceptRemoteRecord, e as acceptSnapshot, p as parseSnapshot, f as publicationFromSnapshot, g as publicationRecord, s as snapshotFromRecord, h as syncPublication } from './snapshot-CEtTFD_h.js';
6
6
  import { CookieCategoryDefinition, CookieConsentRecord } from '@scalemule/compliance';
7
7
  export { COOKIE_CONSENT_BACKUP_KEY, COOKIE_CONSENT_EVENT, COOKIE_CONSENT_KEY, COOKIE_CONSENT_MAX_AGE_SECONDS, CookieCategory, CookieCategoryDefinition, CookieConsentRecord, CookieDisclosure, CookiePreferences, DEFAULT_CONSENT_HUB_URL, DEFAULT_PREFERENCES_ACCEPTED, DEFAULT_PREFERENCES_REJECTED, EU_EEA_UK_COUNTRIES, NETWORK_CONSENT_STORAGE_KEY, acceptAllCookies, checkUrlConsentBridge, clearCookieConsent, consentModel, decorateNetworkUrl, effectivePreferences, getCookieConsent, hasConsented, impliedPreferences, isCategoryAllowed, isGeoConsentRequired, queryNetworkConsentHub, rejectOptionalCookies, setCookieConsent, setNetworkConsentHub } from '@scalemule/compliance';
8
8
  export { CookieConsentBanner, CookieConsentBannerProps } from './cookie-consent-banner.js';
@@ -249,6 +249,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
249
249
  pendingImages: NewsItem[];
250
250
  navigation: EditorialSection[];
251
251
  };
252
+ /** The paper's override, else the article's short headline, else its long title (see `headlineSet`). */
252
253
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
253
254
  /** A complete existing sentence, not a cut-off or generated summary. */
254
255
  declare function briefSummary(article: NewsItem, selection?: HomepageSelection): string;
@@ -404,11 +405,12 @@ declare function newsArticleJsonLd(publication: NewsPublication, article: NewsAr
404
405
  mainEntityOfPage: string;
405
406
  url: string;
406
407
  dateModified?: string | undefined;
408
+ description: string | undefined;
409
+ datePublished: string;
410
+ alternativeHeadline?: string | undefined;
407
411
  "@context": string;
408
412
  "@type": string;
409
413
  headline: string;
410
- description: string | undefined;
411
- datePublished: string;
412
414
  };
413
415
  declare function publicationJsonLd(publication: NewsPublication, canonical: string): {
414
416
  "@context": string;
@@ -453,6 +455,48 @@ declare function articleMeta(publication: NewsPublication, article: NewsArticle,
453
455
  /** One line a paper can serve at /llms.txt. Towns and the article path stay explicit. */
454
456
  declare function llmsText(publication: NewsPublication, canonical: string): string;
455
457
 
458
+ /**
459
+ * The newspaper headline set. Every article may carry four pieces of title text, each with a job:
460
+ *
461
+ * - `fly_title` the kicker above the headline: a beat, column or section label (<= 40 characters);
462
+ * - `headline` the short display headline, written as a line, not a summary (recommended <= 70,
463
+ * hard limit 120); front pages and the article h1 show it;
464
+ * - `subtitle` the rubric (deck): one sentence that carries the facts;
465
+ * - `seo_title` the search title for <title> and link previews.
466
+ *
467
+ * `title` is the long descriptive title that every article already has; it is the fallback for the
468
+ * headline and the search title, so nothing changes for an article that carries only a title.
469
+ * A per-paper homepage override (`StoryDisplay.display_title`/`deck`) still wins where an editor set one.
470
+ */
471
+ declare const HEADLINE_RECOMMENDED_CHARS = 70;
472
+ declare const HEADLINE_MAX_CHARS = 120;
473
+ declare const FLY_TITLE_MAX_CHARS = 40;
474
+ declare const RUBRIC_MAX_CHARS = 500;
475
+ declare const SEO_TITLE_MAX_CHARS = 200;
476
+ type Headlined = Pick<NewsItem, "slug" | "title" | "section"> & Partial<Pick<NewsItem, "headline" | "fly_title" | "excerpt">> & Partial<Pick<NewsArticle, "subtitle" | "seo_title">>;
477
+ /** Characters, never bytes: Cyrillic, CJK and emoji count once each. */
478
+ declare const headlineLength: (value: string) => number;
479
+ type HeadlineSet = {
480
+ /** Kicker: the editor's fly title, else the section. */
481
+ flyTitle?: string;
482
+ /** Display headline: the paper's override, else the article's headline, else its title. */
483
+ headline: string;
484
+ /** Rubric: the paper's deck override, else the subtitle. Never a cut sentence. */
485
+ rubric?: string;
486
+ /** Search title for <title> and previews. */
487
+ seoTitle: string;
488
+ /** The long descriptive title, for alternativeHeadline when it differs from the headline. */
489
+ title: string;
490
+ };
491
+ declare function headlineSet(article: Headlined, selection?: HomepageSelection): HeadlineSet;
492
+ /** Validation shared by the edit command and the editors: hard limits match the service. */
493
+ declare function headlineProblems(fields: {
494
+ headline?: unknown;
495
+ fly_title?: unknown;
496
+ subtitle?: unknown;
497
+ seo_title?: unknown;
498
+ }): string[];
499
+
456
500
  /** Bounded event contract from Walnut Creek Times. Never accept raw form or chat text. */
457
501
  declare const ANALYTICS_VERSION = 1;
458
502
  declare const ANALYTICS_COOKIE = "napsite_analytics_v1";
@@ -675,4 +719,4 @@ declare function isSponsoredStoryItem(item: NewsItem): boolean;
675
719
  */
676
720
  declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
677
721
 
678
- export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ARTICLE_CONVERSION_EXPERIMENT, type ArticleConversionVariant, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, type CommunityInvitationCategory, type ContextualCommunityInvitation, CouponOffer, EDITORIAL_SECTION_ORDER, EVENTS, EditorialSection, type EffectiveTheme, FeatureName, type FeaturedDestination, HomepageSelection, LAYOUT_COOKIE, LOCAL_BRIEFING_POLICY_VERSION, type ListeningArticle, type LocalBriefing, type LocalBriefingItem, type LocalBriefingOptions, Measurement, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, type NavigationConfig, type NavigationContext, type NavigationDestination, type NavigationGroup, type NavigationGroupLinks, type NavigationKind, type NavigationLink, type NavigationOverride, NearbyUpdate, NewsArticle, NewsError, NewsItem, NewsPublication, PublicationInput, PublicationTheme, type ResolvedNavigation, SESSION_IDLE_MS, type SponsorshipReport, THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, type ThemeMode, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, applyTheme, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, classifyStoryInvitationCategory, clearTheme, composeEdition, composeLocalBriefing, contextualCommunityInvitation, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, inventoryCacheOptions, isSponsoredStoryItem, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveArticleConversionVariant, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, selectRelevantLocalStories, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
722
+ export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ARTICLE_CONVERSION_EXPERIMENT, type ArticleConversionVariant, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, type CommunityInvitationCategory, type ContextualCommunityInvitation, CouponOffer, EDITORIAL_SECTION_ORDER, EVENTS, EditorialSection, type EffectiveTheme, FLY_TITLE_MAX_CHARS, FeatureName, type FeaturedDestination, HEADLINE_MAX_CHARS, HEADLINE_RECOMMENDED_CHARS, type HeadlineSet, HomepageSelection, LAYOUT_COOKIE, LOCAL_BRIEFING_POLICY_VERSION, type ListeningArticle, type LocalBriefing, type LocalBriefingItem, type LocalBriefingOptions, Measurement, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, type NavigationConfig, type NavigationContext, type NavigationDestination, type NavigationGroup, type NavigationGroupLinks, type NavigationKind, type NavigationLink, type NavigationOverride, NearbyUpdate, NewsArticle, NewsError, NewsItem, NewsPublication, PublicationInput, PublicationTheme, RUBRIC_MAX_CHARS, type ResolvedNavigation, SEO_TITLE_MAX_CHARS, SESSION_IDLE_MS, type SponsorshipReport, THEME_COOKIE, THEME_EVENT, THEME_MAX_AGE_SECONDS, THEME_STORAGE_KEY, type ThemeMode, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, applyTheme, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, classifyStoryInvitationCategory, clearTheme, composeEdition, composeLocalBriefing, contextualCommunityInvitation, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, headlineLength, headlineProblems, headlineSet, inventoryCacheOptions, isSponsoredStoryItem, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveArticleConversionVariant, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, selectRelevantLocalStories, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
package/dist/index.js CHANGED
@@ -8,6 +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-7SAFGLPH.js';
11
12
  export { communityMapEmbeds } from './chunk-IMYBSG7J.js';
12
13
  export { articleFormatLabel, articleHeroRatio, articlePresentation, formatArtworkCaption, parseDocumentBlocks, renderArticleBlocksToHtml, sanitizeInlineMarkdown } from './chunk-DN3TBZLK.js';
13
14
  import { definePublication } from './chunk-57YVL6BD.js';
@@ -178,7 +179,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
178
179
  return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
179
180
  }
180
181
  function displayHeadline(article, selection = {}) {
181
- return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
182
+ return selection.stories?.[article.slug]?.display_title?.trim() || article.headline?.trim() || article.title;
182
183
  }
183
184
  function briefSummary(article, selection = {}) {
184
185
  const chosen = selection.stories?.[article.slug]?.brief?.trim();
@@ -266,8 +267,9 @@ function newsArticleJsonLd(publication, article, canonical, authorName) {
266
267
  return {
267
268
  "@context": "https://schema.org",
268
269
  "@type": "NewsArticle",
269
- headline: article.title,
270
- description: article.excerpt ?? void 0,
270
+ headline: article.headline?.trim() || article.title,
271
+ ...article.headline?.trim() && article.headline.trim() !== article.title ? { alternativeHeadline: article.title } : {},
272
+ description: article.subtitle ?? article.excerpt ?? void 0,
271
273
  datePublished: article.published_at,
272
274
  ...article.metadata?.editorial?.modified_at ? { dateModified: article.metadata.editorial.modified_at } : {},
273
275
  // updated_at also changes for media/operational maintenance. A trusted
@@ -314,15 +316,15 @@ function articleMeta(publication, article, canonical, indexable) {
314
316
  const canonicalUrl = publicHttps(article.canonical_url) ?? url;
315
317
  const image = publicHttps(article.og_image_url) ?? publicHttps(article.cover_image_url);
316
318
  return {
317
- title: article.title,
318
- description: article.seo_description ?? article.excerpt ?? publication.description,
319
+ title: article.seo_title?.trim() || article.title,
320
+ description: article.seo_description ?? article.subtitle ?? article.excerpt ?? publication.description,
319
321
  canonical: canonicalUrl,
320
322
  indexable: indexable && !article.no_index,
321
323
  openGraph: {
322
324
  type: "article",
323
325
  siteName: publication.name,
324
- title: article.title,
325
- description: article.excerpt ?? void 0,
326
+ title: article.headline?.trim() || article.title,
327
+ description: article.subtitle ?? article.excerpt ?? void 0,
326
328
  url,
327
329
  publishedTime: article.published_at,
328
330
  images: image ? [image] : []
@@ -1,4 +1,4 @@
1
- import { P as PublicationInput, N as NewsPublication } from './types-22lEf_UU.cjs';
1
+ import { P as PublicationInput, N as NewsPublication } from './types-aYDyFCGs.cjs';
2
2
 
3
3
  /**
4
4
  * Serializable application config. Code hooks (`matchTag`, `coupons.resolve`)
@@ -1,4 +1,4 @@
1
- import { P as PublicationInput, N as NewsPublication } from './types-22lEf_UU.js';
1
+ import { P as PublicationInput, N as NewsPublication } from './types-aYDyFCGs.js';
2
2
 
3
3
  /**
4
4
  * Serializable application config. Code hooks (`matchTag`, `coupons.resolve`)
@@ -1,5 +1,5 @@
1
- import { P as PublicationSnapshot, S as SnapshotHooks, a as SyncedPublication } from './snapshot-BBTow5Bn.cjs';
2
- import './types-22lEf_UU.cjs';
1
+ import { P as PublicationSnapshot, S as SnapshotHooks, a as SyncedPublication } from './snapshot-C3kD6oFZ.cjs';
2
+ import './types-aYDyFCGs.cjs';
3
3
 
4
4
  declare function readSnapshotFile(file: string): Promise<PublicationSnapshot | null>;
5
5
  /**
@@ -1,5 +1,5 @@
1
- import { P as PublicationSnapshot, S as SnapshotHooks, a as SyncedPublication } from './snapshot-CHdsfUnU.js';
2
- import './types-22lEf_UU.js';
1
+ import { P as PublicationSnapshot, S as SnapshotHooks, a as SyncedPublication } from './snapshot-CEtTFD_h.js';
2
+ import './types-aYDyFCGs.js';
3
3
 
4
4
  declare function readSnapshotFile(file: string): Promise<PublicationSnapshot | null>;
5
5
  /**
@@ -97,7 +97,12 @@ type CategoryRef = {
97
97
  type NewsItem = {
98
98
  id: string;
99
99
  slug: string;
100
+ /** The long descriptive title; the fallback for `headline` and `seo_title`. */
100
101
  title: string;
102
+ /** Short display headline for front pages and the article h1 (see `headlineSet`). */
103
+ headline?: string | null;
104
+ /** Kicker above the headline; the section is the fallback. */
105
+ fly_title?: string | null;
101
106
  excerpt: string | null;
102
107
  cover_image_url: string | null;
103
108
  /** Versioned canonical pixel identity supplied by the publication owner. */
@@ -222,11 +227,13 @@ type NewsArticle = NewsItem & {
222
227
  cards?: VerifiedCard[] | unknown[];
223
228
  };
224
229
  };
230
+ /** A paper's own homepage overrides for one story; `fly_title` here overrides the article's kicker on that paper only. */
225
231
  type StoryDisplay = {
226
232
  display_title?: string;
227
233
  deck?: string;
228
234
  brief?: string;
229
235
  towns?: string[];
236
+ fly_title?: string;
230
237
  };
231
238
  type NearbyUpdate = {
232
239
  slug: string;
@@ -97,7 +97,12 @@ type CategoryRef = {
97
97
  type NewsItem = {
98
98
  id: string;
99
99
  slug: string;
100
+ /** The long descriptive title; the fallback for `headline` and `seo_title`. */
100
101
  title: string;
102
+ /** Short display headline for front pages and the article h1 (see `headlineSet`). */
103
+ headline?: string | null;
104
+ /** Kicker above the headline; the section is the fallback. */
105
+ fly_title?: string | null;
101
106
  excerpt: string | null;
102
107
  cover_image_url: string | null;
103
108
  /** Versioned canonical pixel identity supplied by the publication owner. */
@@ -222,11 +227,13 @@ type NewsArticle = NewsItem & {
222
227
  cards?: VerifiedCard[] | unknown[];
223
228
  };
224
229
  };
230
+ /** A paper's own homepage overrides for one story; `fly_title` here overrides the article's kicker on that paper only. */
225
231
  type StoryDisplay = {
226
232
  display_title?: string;
227
233
  deck?: string;
228
234
  brief?: string;
229
235
  towns?: string[];
236
+ fly_title?: string;
230
237
  };
231
238
  type NearbyUpdate = {
232
239
  slug: string;
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { V as VerifiedCard } from './types-22lEf_UU.cjs';
2
+ import { V as VerifiedCard } from './types-aYDyFCGs.cjs';
3
3
 
4
4
  /**
5
5
  * Validate that an action link is safe for readers.
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { V as VerifiedCard } from './types-22lEf_UU.js';
2
+ import { V as VerifiedCard } from './types-aYDyFCGs.js';
3
3
 
4
4
  /**
5
5
  * Validate that an action link is safe for readers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.92",
3
+ "version": "0.0.94",
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": {