@scalemule/news-publication 0.0.82 → 0.0.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -324,7 +324,7 @@ The publisher-supplied `PawMark` preserves the approved SVG paths. Hosts may set
324
324
 
325
325
  ### Homepage image identity
326
326
 
327
- `NewsItem.visual_identity` is a versioned public identity from the Blog feed. `composeEdition` allocates covers across the entire bounded inventory, gives the selected lead priority, and holds repeated covers as `null` without dropping stories or mutating inputs. Never re-add duplicate images as fallback. Legacy Photo rendition URLs are normalized by tenant/asset ID; server-side Photo identity detects copies under different IDs and ordinary resize/re-encoding/crop variants. Do not fetch or decode images in page components. Preserve existing caption/credit policy. See platform `docs/HOMEPAGE-PHOTO-IDENTITY.md` for storage, rights, concurrency and rollout tests.
327
+ `NewsItem.visual_identity` is a versioned public identity from the Blog feed. `composeEdition` allocates covers across the entire bounded inventory and holds repeated covers as `null` without mutating inputs. Lead, secondary, compact and section image placements require a usable distinct cover, including pinned stories. A held lead yields to the first ready story; an entirely unready inventory has no visual lead. Intentional briefs retain their editorial selection rules. `pendingImages` and the complete `navigation` inventory retain unresolved stories for repair and discovery: excluding them from image placements does not mean their photos were repaired. Consumer planners must not refill image placements from raw inventory or reintroduce duplicate covers. Legacy Photo rendition URLs are normalized by tenant/asset ID; server-side Photo identity detects copies under different IDs and ordinary resize/re-encoding/crop variants. Do not fetch or decode images in page components. Preserve existing caption/credit policy. See platform `docs/HOMEPAGE-PHOTO-IDENTITY.md` for storage, rights, concurrency and rollout tests.
328
328
 
329
329
  ### Standard article submission
330
330
 
package/dist/index.cjs CHANGED
@@ -188,7 +188,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
188
188
  return true;
189
189
  });
190
190
  const pins = !selection.pinned_until || Date.parse(selection.pinned_until) > now ? selection : {};
191
- const selectedLead = inventory.find((item) => item.slug === pins.lead_slug) ?? inventory[0];
191
+ const selectedLead = inventory.find((item) => item.slug === pins.lead_slug && coverIdentity(item)) ?? inventory.find((item) => coverIdentity(item));
192
192
  const rest = inventory.filter((item) => item !== selectedLead);
193
193
  const requested = new Set(pins.secondary_slugs ?? []);
194
194
  const ordered = [
@@ -197,13 +197,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
197
197
  ];
198
198
  const originalOrder = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
199
199
  const seenImages = /* @__PURE__ */ new Set();
200
- const duplicateImages = /* @__PURE__ */ new Set();
201
200
  const display = /* @__PURE__ */ new Map();
202
201
  for (const item of [selectedLead, ...originalOrder]) {
203
202
  if (!item) continue;
204
203
  const key = coverIdentity(item);
205
204
  if (key && seenImages.has(key)) {
206
- duplicateImages.add(item.id);
207
205
  display.set(item.id, { ...item, cover_image_url: null });
208
206
  } else {
209
207
  display.set(item.id, item);
@@ -214,14 +212,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
214
212
  const lead = selectedLead ? display.get(selectedLead.id) : void 0;
215
213
  let remaining = rest.map((item) => display.get(item.id));
216
214
  const uniqueOrdered = originalOrder.map((item) => display.get(item.id));
217
- const secondary = [];
218
- const deferred = [];
219
- for (const item of uniqueOrdered) {
220
- if (secondary.length >= 3) break;
221
- if (duplicateImages.has(item.id)) deferred.push(item);
222
- else secondary.push(item);
223
- }
224
- while (secondary.length < 3 && deferred.length > 0) secondary.push(deferred.shift());
215
+ const secondary = uniqueOrdered.filter((item) => coverIdentity(item)).slice(0, 3);
225
216
  const secondaryIds = new Set(secondary.map((item) => item.id));
226
217
  remaining = remaining.filter((item) => !secondaryIds.has(item.id));
227
218
  const eligible2 = remaining.filter((item) => {
@@ -236,10 +227,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
236
227
  briefIds.add(item.id);
237
228
  return true;
238
229
  });
239
- const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].slice(0, 4);
230
+ const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].filter((item) => coverIdentity(item)).slice(0, 4);
240
231
  const compactIds = new Set(compact.map((item) => item.id));
241
- const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id)));
242
- return { lead, secondary, briefs, compact, sections: sections2, navigation: editorialSections(inventory) };
232
+ const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id) && coverIdentity(item)));
233
+ const pendingImages = inventory.filter((item) => !coverIdentity(item));
234
+ return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
243
235
  }
244
236
  function displayHeadline(article, selection = {}) {
245
237
  return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
@@ -829,8 +821,11 @@ var EVENTS = {
829
821
  article_shared: browser("engagement"),
830
822
  promotion_viewed: browser("content"),
831
823
  promotion_clicked: browser("engagement"),
824
+ newsletter_invitation_viewed: browser("content"),
825
+ related_stories_viewed: browser("content"),
832
826
  newsletter_signup_started: browser("engagement"),
833
827
  newsletter_subscription_confirmed: server(),
828
+ experiment_exposed: browser("system"),
834
829
  web_vital_recorded: browser("system"),
835
830
  scroll_depth_reached: browser("content"),
836
831
  navigation_clicked: browser("navigation"),
@@ -3633,6 +3628,198 @@ function articleFormatLabel(format) {
3633
3628
  return format === "opinion" ? "Opinion" : format === "explainer" ? "Explainer" : format === "interview" ? "Interview" : format === "photo_essay" ? "Photo essay" : format === "video_story" ? "Video story" : void 0;
3634
3629
  }
3635
3630
 
3631
+ // src/community-conversion.ts
3632
+ var ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
3633
+ var CELEBRATION_PATTERN = /\b(celebrat(?:e|ion|ions|ing)|wedding|weddings|baby|babies|newborn|quarter[- ]?finals?|milestone|milestones|anniversar(?:y|ies)|graduat(?:e|es|ion|ions)|honor(?:s|ed|ing)?|award(?:s|ed)?|champion|champions|hero(?:es)?|little warrior|birthday)\b/iu;
3634
+ var SPONSORED_MARKER_PATTERN = /\b(sponsored|paid post|partner content|advertorial|promoted|brand studio)\b/iu;
3635
+ function resolveArticleConversionVariant(assignedVariant, queryOverride) {
3636
+ const candidate = (queryOverride ?? assignedVariant ?? "").trim().toLowerCase();
3637
+ if (candidate === "treatment" || candidate === "inline" || candidate === "b") {
3638
+ return "treatment";
3639
+ }
3640
+ return "control";
3641
+ }
3642
+ function classifyStoryInvitationCategory(story) {
3643
+ const combinedText = [
3644
+ story.title,
3645
+ story.excerpt ?? "",
3646
+ story.section ?? "",
3647
+ ...(story.tags ?? []).map((t) => t.name),
3648
+ ...(story.categories ?? []).map((c) => c.name)
3649
+ ].join(" ");
3650
+ if (CELEBRATION_PATTERN.test(combinedText)) {
3651
+ return "celebrations";
3652
+ }
3653
+ const section = resolveSection(story);
3654
+ switch (section) {
3655
+ case "Local businesses":
3656
+ return "local_businesses";
3657
+ case "Schools":
3658
+ return "education";
3659
+ case "Events":
3660
+ return "community_events";
3661
+ case "Civic life":
3662
+ case "Real Estate":
3663
+ case "Health":
3664
+ return "neighborhood_news";
3665
+ default:
3666
+ return "community";
3667
+ }
3668
+ }
3669
+ function contextualCommunityInvitation(story, publication) {
3670
+ const category = classifyStoryInvitationCategory(story);
3671
+ const region = publication.regionLabel.trim() || publication.name.trim();
3672
+ const coverage = publication.coverageText?.trim() || (publication.towns && publication.towns.length > 0 ? publication.towns.slice(0, 3).join(", ") : region);
3673
+ switch (category) {
3674
+ case "celebrations":
3675
+ return {
3676
+ category,
3677
+ eyebrow: `${region} Community`,
3678
+ heading: `Stay connected to ${region}`,
3679
+ subprompt: "Discover more people and milestones worth celebrating.",
3680
+ description: `${region} is full of stories worth celebrating. Discover the people, places, events, and milestones shaping ${coverage}.`,
3681
+ ctaLabel: "Subscribe",
3682
+ relatedHeading: `More stories from ${region}`
3683
+ };
3684
+ case "local_businesses":
3685
+ return {
3686
+ category,
3687
+ eyebrow: `${region} Local Businesses`,
3688
+ heading: `Stay connected to ${region}`,
3689
+ subprompt: "Meet the entrepreneurs building your community.",
3690
+ description: `Discover the founders, independent shops, places, and events shaping ${coverage}.`,
3691
+ ctaLabel: "Subscribe",
3692
+ relatedHeading: `More local stories in ${region}`
3693
+ };
3694
+ case "education":
3695
+ return {
3696
+ category,
3697
+ eyebrow: `${region} Schools & Youth`,
3698
+ heading: `Stay connected to ${region}`,
3699
+ subprompt: "Follow achievements from local students and schools.",
3700
+ description: `Discover the students, educators, neighborhood milestones, and events shaping ${coverage}.`,
3701
+ ctaLabel: "Subscribe",
3702
+ relatedHeading: `More stories from ${region}`
3703
+ };
3704
+ case "community_events":
3705
+ return {
3706
+ category,
3707
+ eyebrow: `${region} Events & Gatherings`,
3708
+ heading: `Stay connected to ${region}`,
3709
+ subprompt: "Find something happening near you this weekend.",
3710
+ description: `Discover the people, places, weekend gatherings, and local stories shaping ${coverage}.`,
3711
+ ctaLabel: "Subscribe",
3712
+ relatedHeading: `Happening around ${region}`
3713
+ };
3714
+ case "neighborhood_news":
3715
+ return {
3716
+ category,
3717
+ eyebrow: `${region} Neighborhood Briefing`,
3718
+ heading: `Stay connected to ${region}`,
3719
+ subprompt: "Stay informed about changes affecting your neighborhood.",
3720
+ description: `Follow the civic decisions, neighborhood projects, people, and events shaping ${coverage}.`,
3721
+ ctaLabel: "Subscribe",
3722
+ relatedHeading: `More from ${region}`
3723
+ };
3724
+ case "community":
3725
+ default:
3726
+ return {
3727
+ category: "community",
3728
+ eyebrow: `${region} Community`,
3729
+ heading: `Stay connected to ${region}`,
3730
+ subprompt: "Discover the people, places, and events shaping your community.",
3731
+ description: `Receive local reporting on the people, places, events, and stories shaping ${coverage}.`,
3732
+ ctaLabel: "Subscribe",
3733
+ relatedHeading: `More stories from ${region}`
3734
+ };
3735
+ }
3736
+ }
3737
+ function isSponsoredStoryItem(item) {
3738
+ const markers = [
3739
+ item.section ?? "",
3740
+ ...(item.tags ?? []).map((t) => `${t.name} ${t.slug}`),
3741
+ ...(item.categories ?? []).map((c) => `${c.name} ${c.slug}`)
3742
+ ].join(" ");
3743
+ return SPONSORED_MARKER_PATTERN.test(markers);
3744
+ }
3745
+ function normalizedCoverIdentity(item) {
3746
+ const raw = item.cover_image_url?.trim();
3747
+ if (!raw) return null;
3748
+ if (item.visual_identity?.version === 1 && /^[a-f0-9]{64}$/u.test(item.visual_identity.key)) {
3749
+ return `pixels:${item.visual_identity.key}`;
3750
+ }
3751
+ try {
3752
+ const url = new URL(raw);
3753
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
3754
+ url.hash = "";
3755
+ url.searchParams.sort();
3756
+ return `url:${url.toString()}`;
3757
+ } catch {
3758
+ return null;
3759
+ }
3760
+ }
3761
+ function selectRelevantLocalStories(currentStory, candidates, publication, limit = 3) {
3762
+ if (limit <= 0) return [];
3763
+ const currentSection = resolveSection(currentStory);
3764
+ const pubTowns = Array.isArray(publication.towns) ? publication.towns : [];
3765
+ const currentTowns = new Set(storyTowns(currentStory, pubTowns).map((t) => t.toLowerCase()));
3766
+ const currentTags = /* @__PURE__ */ new Set([
3767
+ ...(currentStory.tags ?? []).map((t) => t.name.toLowerCase()),
3768
+ ...(currentStory.categories ?? []).map((c) => c.name.toLowerCase())
3769
+ ]);
3770
+ const primaryRegion = publication.regionLabel.trim().toLowerCase();
3771
+ const seenIds = /* @__PURE__ */ new Set([currentStory.id]);
3772
+ const seenSlugs = /* @__PURE__ */ new Set([currentStory.slug]);
3773
+ const usedCovers = /* @__PURE__ */ new Set();
3774
+ const currentCover = normalizedCoverIdentity(currentStory);
3775
+ if (currentCover) usedCovers.add(currentCover);
3776
+ const scored = candidates.filter((item) => {
3777
+ if (!item?.id || !item?.slug) return false;
3778
+ if (seenIds.has(item.id) || seenSlugs.has(item.slug)) return false;
3779
+ if (isSponsoredStoryItem(item)) return false;
3780
+ return true;
3781
+ }).map((item) => {
3782
+ let score = 0;
3783
+ const cover = normalizedCoverIdentity(item);
3784
+ if (cover) score += 40;
3785
+ const itemTowns = storyTowns(item, pubTowns).map((t) => t.toLowerCase());
3786
+ if (itemTowns.some((t) => currentTowns.has(t))) {
3787
+ score += 25;
3788
+ } else if (primaryRegion && `${item.title} ${item.excerpt ?? ""}`.toLowerCase().includes(primaryRegion)) {
3789
+ score += 18;
3790
+ }
3791
+ const section = resolveSection(item);
3792
+ if (section === currentSection) {
3793
+ score += 20;
3794
+ } else if (["Community", "Events", "Schools", "Local businesses", "Food & places"].includes(section)) {
3795
+ score += 12;
3796
+ }
3797
+ const itemTags = [
3798
+ ...(item.tags ?? []).map((t) => t.name.toLowerCase()),
3799
+ ...(item.categories ?? []).map((c) => c.name.toLowerCase())
3800
+ ];
3801
+ const sharedTags = itemTags.filter((t) => currentTags.has(t)).length;
3802
+ score += Math.min(sharedTags * 8, 16);
3803
+ if (CELEBRATION_PATTERN.test(`${item.title} ${item.excerpt ?? ""}`)) {
3804
+ score += 6;
3805
+ }
3806
+ const parsedTime = Date.parse(item.published_at);
3807
+ const publishedMs = Number.isFinite(parsedTime) ? parsedTime : 0;
3808
+ return { item: { ...item, section }, cover, score, publishedMs };
3809
+ }).sort((a, b) => b.score - a.score || b.publishedMs - a.publishedMs);
3810
+ const selected = [];
3811
+ for (const entry of scored) {
3812
+ if (selected.length >= limit) break;
3813
+ if (seenIds.has(entry.item.id) || seenSlugs.has(entry.item.slug)) continue;
3814
+ if (entry.cover && usedCovers.has(entry.cover)) continue;
3815
+ seenIds.add(entry.item.id);
3816
+ seenSlugs.add(entry.item.slug);
3817
+ if (entry.cover) usedCovers.add(entry.cover);
3818
+ selected.push(entry.item);
3819
+ }
3820
+ return selected;
3821
+ }
3822
+
3636
3823
  Object.defineProperty(exports, "COOKIE_CONSENT_BACKUP_KEY", {
3637
3824
  enumerable: true,
3638
3825
  get: function () { return compliance.COOKIE_CONSENT_BACKUP_KEY; }
@@ -3731,6 +3918,7 @@ Object.defineProperty(exports, "setNetworkConsentHub", {
3731
3918
  });
3732
3919
  exports.ANALYTICS_COOKIE = ANALYTICS_COOKIE;
3733
3920
  exports.ANALYTICS_VERSION = ANALYTICS_VERSION;
3921
+ exports.ARTICLE_CONVERSION_EXPERIMENT = ARTICLE_CONVERSION_EXPERIMENT;
3734
3922
  exports.ArticleProvenance = ArticleProvenance;
3735
3923
  exports.BROADSHEET_THEME = BROADSHEET_THEME;
3736
3924
  exports.CLASSIFIED_CATEGORIES = CLASSIFIED_CATEGORIES;
@@ -3787,12 +3975,14 @@ exports.canHostMeetup = canHostMeetup;
3787
3975
  exports.categoryLabel = categoryLabel;
3788
3976
  exports.classifiedPath = classifiedPath;
3789
3977
  exports.classifiedSearchPath = classifiedSearchPath;
3978
+ exports.classifyStoryInvitationCategory = classifyStoryInvitationCategory;
3790
3979
  exports.clearTheme = clearTheme;
3791
3980
  exports.clipChars = clipChars;
3792
3981
  exports.communityMapEmbeds = communityMapEmbeds;
3793
3982
  exports.communityPublications = communityPublications;
3794
3983
  exports.composeEdition = composeEdition;
3795
3984
  exports.composeLocalBriefing = composeLocalBriefing;
3985
+ exports.contextualCommunityInvitation = contextualCommunityInvitation;
3796
3986
  exports.dealDollars = dealDollars;
3797
3987
  exports.dealGoPath = dealGoPath;
3798
3988
  exports.dealsFeedPath = dealsFeedPath;
@@ -3818,6 +4008,7 @@ exports.isAdult = isAdult;
3818
4008
  exports.isClassifiedCategory = isClassifiedCategory;
3819
4009
  exports.isEmploymentType = isEmploymentType;
3820
4010
  exports.isSafeActionUrl = isSafeActionUrl;
4011
+ exports.isSponsoredStoryItem = isSponsoredStoryItem;
3821
4012
  exports.jobEmployerBody = jobEmployerBody;
3822
4013
  exports.jobOpeningBody = jobOpeningBody;
3823
4014
  exports.jobPostingBody = jobPostingBody;
@@ -3850,6 +4041,7 @@ exports.readerSources = readerSources;
3850
4041
  exports.recordConsentToCompliance = recordConsentToCompliance;
3851
4042
  exports.referralCode = referralCode;
3852
4043
  exports.renderArticleBlocksToHtml = renderArticleBlocksToHtml;
4044
+ exports.resolveArticleConversionVariant = resolveArticleConversionVariant;
3853
4045
  exports.resolveCoupon = resolveCoupon;
3854
4046
  exports.resolveEffectiveTheme = resolveEffectiveTheme;
3855
4047
  exports.resolveLayout = resolveLayout;
@@ -3863,6 +4055,7 @@ exports.secretsMatch = secretsMatch;
3863
4055
  exports.sectionDefinition = sectionDefinition;
3864
4056
  exports.sectionId = sectionId;
3865
4057
  exports.sectionPath = sectionPath;
4058
+ exports.selectRelevantLocalStories = selectRelevantLocalStories;
3866
4059
  exports.setTheme = setTheme;
3867
4060
  exports.shortDeck = shortDeck;
3868
4061
  exports.shortText = shortText;
package/dist/index.d.cts CHANGED
@@ -246,6 +246,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
246
246
  briefs: NewsItem[];
247
247
  compact: NewsItem[];
248
248
  sections: EditorialSection[];
249
+ pendingImages: NewsItem[];
249
250
  navigation: EditorialSection[];
250
251
  };
251
252
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
@@ -624,4 +625,54 @@ interface SponsorshipReport {
624
625
  /** Shared by the newsroom print/export view and the repeatable PDF renderer. */
625
626
  declare function campaignReportHtml(report: SponsorshipReport): string;
626
627
 
627
- export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, 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, clearTheme, composeEdition, composeLocalBriefing, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, inventoryCacheOptions, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
628
+ declare const ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
629
+ type ArticleConversionVariant = "control" | "treatment";
630
+ type CommunityInvitationCategory = "celebrations" | "local_businesses" | "education" | "neighborhood_news" | "community_events" | "community";
631
+ interface ContextualCommunityInvitation {
632
+ category: CommunityInvitationCategory;
633
+ eyebrow: string;
634
+ heading: string;
635
+ subprompt: string;
636
+ description: string;
637
+ ctaLabel: string;
638
+ relatedHeading: string;
639
+ }
640
+ /**
641
+ * Resolves the `article_conversion_module` experiment variant consistently.
642
+ * Supports query-parameter preview overrides (`?conversion=treatment` or `?conversion=control`)
643
+ * before falling back to the assigned ScaleMule flag variant (`"control"` default).
644
+ */
645
+ declare function resolveArticleConversionVariant(assignedVariant?: string | null, queryOverride?: string | null): ArticleConversionVariant;
646
+ /**
647
+ * Infers the reader-facing community invitation category from a story's section,
648
+ * tags, categories, headline, and excerpt.
649
+ */
650
+ declare function classifyStoryInvitationCategory(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">): CommunityInvitationCategory;
651
+ /**
652
+ * Builds a publication-aware, category-tailored invitation that connects the reader's
653
+ * reason for visiting a specific article to staying connected with the local community.
654
+ */
655
+ interface CommunityInvitationPublicationContext {
656
+ name: string;
657
+ regionLabel: string;
658
+ coverageText?: string;
659
+ towns?: string[];
660
+ }
661
+ declare function contextualCommunityInvitation(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">, publication: CommunityInvitationPublicationContext): ContextualCommunityInvitation;
662
+ /**
663
+ * Returns true if a story carries any sponsored, paid, or partner-content markers.
664
+ * Editorial recommendation slots must never mix unlabeled sponsored items.
665
+ */
666
+ declare function isSponsoredStoryItem(item: NewsItem): boolean;
667
+ /**
668
+ * Selects 2–3 relevant local editorial stories for the post-prose Community Conversion Module.
669
+ *
670
+ * Rules:
671
+ * - Never includes the current story or duplicate story IDs/slugs.
672
+ * - Never includes sponsored/paid items (`isSponsoredStoryItem`).
673
+ * - Deduplicates cover images so recommended cards never repeat the current story's cover or each other's cover.
674
+ * - Prioritizes stories with valid cover photographs, shared town/geographic relevance, and community/section affinity.
675
+ */
676
+ declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
677
+
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 };
package/dist/index.d.ts CHANGED
@@ -246,6 +246,7 @@ declare function composeEdition(items: NewsItem[], selection?: HomepageSelection
246
246
  briefs: NewsItem[];
247
247
  compact: NewsItem[];
248
248
  sections: EditorialSection[];
249
+ pendingImages: NewsItem[];
249
250
  navigation: EditorialSection[];
250
251
  };
251
252
  declare function displayHeadline(article: NewsItem, selection?: HomepageSelection): string;
@@ -624,4 +625,54 @@ interface SponsorshipReport {
624
625
  /** Shared by the newsroom print/export view and the repeatable PDF renderer. */
625
626
  declare function campaignReportHtml(report: SponsorshipReport): string;
626
627
 
627
- export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ArticleProvenance, type Attribution, BROADSHEET_THEME, type BriefingCandidate, type BriefingPreferences, type BriefingReason, type BriefingSection, type BriefingSource, type BriefingVerification, COOKIE_CATEGORIES, CURATED_COMMUNITY_CARDS, 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, clearTheme, composeEdition, composeLocalBriefing, defaultRevalidateTargets, definePublication, displayHeadline, edgeCacheControl, editorialSections, featureEnabled, feedCacheKey, feedCacheOptions, filterNews, getStoredTheme, getSystemTheme, inventoryCacheOptions, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, recordConsentToCompliance, referralCode, resolveCoupon, resolveEffectiveTheme, resolveLayout, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, setTheme, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, subscribeTheme, themeInitScript, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
628
+ declare const ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
629
+ type ArticleConversionVariant = "control" | "treatment";
630
+ type CommunityInvitationCategory = "celebrations" | "local_businesses" | "education" | "neighborhood_news" | "community_events" | "community";
631
+ interface ContextualCommunityInvitation {
632
+ category: CommunityInvitationCategory;
633
+ eyebrow: string;
634
+ heading: string;
635
+ subprompt: string;
636
+ description: string;
637
+ ctaLabel: string;
638
+ relatedHeading: string;
639
+ }
640
+ /**
641
+ * Resolves the `article_conversion_module` experiment variant consistently.
642
+ * Supports query-parameter preview overrides (`?conversion=treatment` or `?conversion=control`)
643
+ * before falling back to the assigned ScaleMule flag variant (`"control"` default).
644
+ */
645
+ declare function resolveArticleConversionVariant(assignedVariant?: string | null, queryOverride?: string | null): ArticleConversionVariant;
646
+ /**
647
+ * Infers the reader-facing community invitation category from a story's section,
648
+ * tags, categories, headline, and excerpt.
649
+ */
650
+ declare function classifyStoryInvitationCategory(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">): CommunityInvitationCategory;
651
+ /**
652
+ * Builds a publication-aware, category-tailored invitation that connects the reader's
653
+ * reason for visiting a specific article to staying connected with the local community.
654
+ */
655
+ interface CommunityInvitationPublicationContext {
656
+ name: string;
657
+ regionLabel: string;
658
+ coverageText?: string;
659
+ towns?: string[];
660
+ }
661
+ declare function contextualCommunityInvitation(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">, publication: CommunityInvitationPublicationContext): ContextualCommunityInvitation;
662
+ /**
663
+ * Returns true if a story carries any sponsored, paid, or partner-content markers.
664
+ * Editorial recommendation slots must never mix unlabeled sponsored items.
665
+ */
666
+ declare function isSponsoredStoryItem(item: NewsItem): boolean;
667
+ /**
668
+ * Selects 2–3 relevant local editorial stories for the post-prose Community Conversion Module.
669
+ *
670
+ * Rules:
671
+ * - Never includes the current story or duplicate story IDs/slugs.
672
+ * - Never includes sponsored/paid items (`isSponsoredStoryItem`).
673
+ * - Deduplicates cover images so recommended cards never repeat the current story's cover or each other's cover.
674
+ * - Prioritizes stories with valid cover photographs, shared town/geographic relevance, and community/section affinity.
675
+ */
676
+ declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
677
+
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 };
package/dist/index.js CHANGED
@@ -132,7 +132,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
132
132
  return true;
133
133
  });
134
134
  const pins = !selection.pinned_until || Date.parse(selection.pinned_until) > now ? selection : {};
135
- const selectedLead = inventory.find((item) => item.slug === pins.lead_slug) ?? inventory[0];
135
+ const selectedLead = inventory.find((item) => item.slug === pins.lead_slug && coverIdentity(item)) ?? inventory.find((item) => coverIdentity(item));
136
136
  const rest = inventory.filter((item) => item !== selectedLead);
137
137
  const requested = new Set(pins.secondary_slugs ?? []);
138
138
  const ordered = [
@@ -141,13 +141,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
141
141
  ];
142
142
  const originalOrder = Array.from(new Map(ordered.map((item) => [item.id, item])).values());
143
143
  const seenImages = /* @__PURE__ */ new Set();
144
- const duplicateImages = /* @__PURE__ */ new Set();
145
144
  const display = /* @__PURE__ */ new Map();
146
145
  for (const item of [selectedLead, ...originalOrder]) {
147
146
  if (!item) continue;
148
147
  const key = coverIdentity(item);
149
148
  if (key && seenImages.has(key)) {
150
- duplicateImages.add(item.id);
151
149
  display.set(item.id, { ...item, cover_image_url: null });
152
150
  } else {
153
151
  display.set(item.id, item);
@@ -158,14 +156,7 @@ function composeEdition(items, selection = {}, now = Date.now()) {
158
156
  const lead = selectedLead ? display.get(selectedLead.id) : void 0;
159
157
  let remaining = rest.map((item) => display.get(item.id));
160
158
  const uniqueOrdered = originalOrder.map((item) => display.get(item.id));
161
- const secondary = [];
162
- const deferred = [];
163
- for (const item of uniqueOrdered) {
164
- if (secondary.length >= 3) break;
165
- if (duplicateImages.has(item.id)) deferred.push(item);
166
- else secondary.push(item);
167
- }
168
- while (secondary.length < 3 && deferred.length > 0) secondary.push(deferred.shift());
159
+ const secondary = uniqueOrdered.filter((item) => coverIdentity(item)).slice(0, 3);
169
160
  const secondaryIds = new Set(secondary.map((item) => item.id));
170
161
  remaining = remaining.filter((item) => !secondaryIds.has(item.id));
171
162
  const eligible2 = remaining.filter((item) => {
@@ -180,10 +171,11 @@ function composeEdition(items, selection = {}, now = Date.now()) {
180
171
  briefIds.add(item.id);
181
172
  return true;
182
173
  });
183
- const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].slice(0, 4);
174
+ const compact = [...briefs, ...remaining.filter((item) => !briefIds.has(item.id))].filter((item) => coverIdentity(item)).slice(0, 4);
184
175
  const compactIds = new Set(compact.map((item) => item.id));
185
- const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id)));
186
- return { lead, secondary, briefs, compact, sections: sections2, navigation: editorialSections(inventory) };
176
+ const sections2 = editorialSections(remaining.filter((item) => !compactIds.has(item.id) && coverIdentity(item)));
177
+ const pendingImages = inventory.filter((item) => !coverIdentity(item));
178
+ return { lead, secondary, briefs, compact, sections: sections2, pendingImages, navigation: editorialSections(inventory) };
187
179
  }
188
180
  function displayHeadline(article, selection = {}) {
189
181
  return selection.stories?.[article.slug]?.display_title?.trim() || article.title;
@@ -773,8 +765,11 @@ var EVENTS = {
773
765
  article_shared: browser("engagement"),
774
766
  promotion_viewed: browser("content"),
775
767
  promotion_clicked: browser("engagement"),
768
+ newsletter_invitation_viewed: browser("content"),
769
+ related_stories_viewed: browser("content"),
776
770
  newsletter_signup_started: browser("engagement"),
777
771
  newsletter_subscription_confirmed: server(),
772
+ experiment_exposed: browser("system"),
778
773
  web_vital_recorded: browser("system"),
779
774
  scroll_depth_reached: browser("content"),
780
775
  navigation_clicked: browser("navigation"),
@@ -1020,4 +1015,196 @@ function campaignReportHtml(report) {
1020
1015
  <footer><p>${escape(report.reporting_note)}</p><p>${escape(report.editorial_independence)}</p><p>Campaign reference: ${escape(report.campaign_id)}</p></footer></body></html>`;
1021
1016
  }
1022
1017
 
1023
- export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ArticleProvenance, EDITORIAL_SECTION_ORDER, EVENTS, LOCAL_BRIEFING_POLICY_VERSION, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, NewsError, SESSION_IDLE_MS, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, composeEdition, composeLocalBriefing, defaultRevalidateTargets, displayHeadline, edgeCacheControl, editorialSections, feedCacheKey, feedCacheOptions, filterNews, inventoryCacheOptions, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, referralCode, resolveCoupon, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
1018
+ // src/community-conversion.ts
1019
+ var ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
1020
+ var CELEBRATION_PATTERN = /\b(celebrat(?:e|ion|ions|ing)|wedding|weddings|baby|babies|newborn|quarter[- ]?finals?|milestone|milestones|anniversar(?:y|ies)|graduat(?:e|es|ion|ions)|honor(?:s|ed|ing)?|award(?:s|ed)?|champion|champions|hero(?:es)?|little warrior|birthday)\b/iu;
1021
+ var SPONSORED_MARKER_PATTERN = /\b(sponsored|paid post|partner content|advertorial|promoted|brand studio)\b/iu;
1022
+ function resolveArticleConversionVariant(assignedVariant, queryOverride) {
1023
+ const candidate = (queryOverride ?? assignedVariant ?? "").trim().toLowerCase();
1024
+ if (candidate === "treatment" || candidate === "inline" || candidate === "b") {
1025
+ return "treatment";
1026
+ }
1027
+ return "control";
1028
+ }
1029
+ function classifyStoryInvitationCategory(story) {
1030
+ const combinedText = [
1031
+ story.title,
1032
+ story.excerpt ?? "",
1033
+ story.section ?? "",
1034
+ ...(story.tags ?? []).map((t) => t.name),
1035
+ ...(story.categories ?? []).map((c) => c.name)
1036
+ ].join(" ");
1037
+ if (CELEBRATION_PATTERN.test(combinedText)) {
1038
+ return "celebrations";
1039
+ }
1040
+ const section = resolveSection(story);
1041
+ switch (section) {
1042
+ case "Local businesses":
1043
+ return "local_businesses";
1044
+ case "Schools":
1045
+ return "education";
1046
+ case "Events":
1047
+ return "community_events";
1048
+ case "Civic life":
1049
+ case "Real Estate":
1050
+ case "Health":
1051
+ return "neighborhood_news";
1052
+ default:
1053
+ return "community";
1054
+ }
1055
+ }
1056
+ function contextualCommunityInvitation(story, publication) {
1057
+ const category = classifyStoryInvitationCategory(story);
1058
+ const region = publication.regionLabel.trim() || publication.name.trim();
1059
+ const coverage = publication.coverageText?.trim() || (publication.towns && publication.towns.length > 0 ? publication.towns.slice(0, 3).join(", ") : region);
1060
+ switch (category) {
1061
+ case "celebrations":
1062
+ return {
1063
+ category,
1064
+ eyebrow: `${region} Community`,
1065
+ heading: `Stay connected to ${region}`,
1066
+ subprompt: "Discover more people and milestones worth celebrating.",
1067
+ description: `${region} is full of stories worth celebrating. Discover the people, places, events, and milestones shaping ${coverage}.`,
1068
+ ctaLabel: "Subscribe",
1069
+ relatedHeading: `More stories from ${region}`
1070
+ };
1071
+ case "local_businesses":
1072
+ return {
1073
+ category,
1074
+ eyebrow: `${region} Local Businesses`,
1075
+ heading: `Stay connected to ${region}`,
1076
+ subprompt: "Meet the entrepreneurs building your community.",
1077
+ description: `Discover the founders, independent shops, places, and events shaping ${coverage}.`,
1078
+ ctaLabel: "Subscribe",
1079
+ relatedHeading: `More local stories in ${region}`
1080
+ };
1081
+ case "education":
1082
+ return {
1083
+ category,
1084
+ eyebrow: `${region} Schools & Youth`,
1085
+ heading: `Stay connected to ${region}`,
1086
+ subprompt: "Follow achievements from local students and schools.",
1087
+ description: `Discover the students, educators, neighborhood milestones, and events shaping ${coverage}.`,
1088
+ ctaLabel: "Subscribe",
1089
+ relatedHeading: `More stories from ${region}`
1090
+ };
1091
+ case "community_events":
1092
+ return {
1093
+ category,
1094
+ eyebrow: `${region} Events & Gatherings`,
1095
+ heading: `Stay connected to ${region}`,
1096
+ subprompt: "Find something happening near you this weekend.",
1097
+ description: `Discover the people, places, weekend gatherings, and local stories shaping ${coverage}.`,
1098
+ ctaLabel: "Subscribe",
1099
+ relatedHeading: `Happening around ${region}`
1100
+ };
1101
+ case "neighborhood_news":
1102
+ return {
1103
+ category,
1104
+ eyebrow: `${region} Neighborhood Briefing`,
1105
+ heading: `Stay connected to ${region}`,
1106
+ subprompt: "Stay informed about changes affecting your neighborhood.",
1107
+ description: `Follow the civic decisions, neighborhood projects, people, and events shaping ${coverage}.`,
1108
+ ctaLabel: "Subscribe",
1109
+ relatedHeading: `More from ${region}`
1110
+ };
1111
+ case "community":
1112
+ default:
1113
+ return {
1114
+ category: "community",
1115
+ eyebrow: `${region} Community`,
1116
+ heading: `Stay connected to ${region}`,
1117
+ subprompt: "Discover the people, places, and events shaping your community.",
1118
+ description: `Receive local reporting on the people, places, events, and stories shaping ${coverage}.`,
1119
+ ctaLabel: "Subscribe",
1120
+ relatedHeading: `More stories from ${region}`
1121
+ };
1122
+ }
1123
+ }
1124
+ function isSponsoredStoryItem(item) {
1125
+ const markers = [
1126
+ item.section ?? "",
1127
+ ...(item.tags ?? []).map((t) => `${t.name} ${t.slug}`),
1128
+ ...(item.categories ?? []).map((c) => `${c.name} ${c.slug}`)
1129
+ ].join(" ");
1130
+ return SPONSORED_MARKER_PATTERN.test(markers);
1131
+ }
1132
+ function normalizedCoverIdentity(item) {
1133
+ const raw = item.cover_image_url?.trim();
1134
+ if (!raw) return null;
1135
+ if (item.visual_identity?.version === 1 && /^[a-f0-9]{64}$/u.test(item.visual_identity.key)) {
1136
+ return `pixels:${item.visual_identity.key}`;
1137
+ }
1138
+ try {
1139
+ const url = new URL(raw);
1140
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
1141
+ url.hash = "";
1142
+ url.searchParams.sort();
1143
+ return `url:${url.toString()}`;
1144
+ } catch {
1145
+ return null;
1146
+ }
1147
+ }
1148
+ function selectRelevantLocalStories(currentStory, candidates, publication, limit = 3) {
1149
+ if (limit <= 0) return [];
1150
+ const currentSection = resolveSection(currentStory);
1151
+ const pubTowns = Array.isArray(publication.towns) ? publication.towns : [];
1152
+ const currentTowns = new Set(storyTowns(currentStory, pubTowns).map((t) => t.toLowerCase()));
1153
+ const currentTags = /* @__PURE__ */ new Set([
1154
+ ...(currentStory.tags ?? []).map((t) => t.name.toLowerCase()),
1155
+ ...(currentStory.categories ?? []).map((c) => c.name.toLowerCase())
1156
+ ]);
1157
+ const primaryRegion = publication.regionLabel.trim().toLowerCase();
1158
+ const seenIds = /* @__PURE__ */ new Set([currentStory.id]);
1159
+ const seenSlugs = /* @__PURE__ */ new Set([currentStory.slug]);
1160
+ const usedCovers = /* @__PURE__ */ new Set();
1161
+ const currentCover = normalizedCoverIdentity(currentStory);
1162
+ if (currentCover) usedCovers.add(currentCover);
1163
+ const scored = candidates.filter((item) => {
1164
+ if (!item?.id || !item?.slug) return false;
1165
+ if (seenIds.has(item.id) || seenSlugs.has(item.slug)) return false;
1166
+ if (isSponsoredStoryItem(item)) return false;
1167
+ return true;
1168
+ }).map((item) => {
1169
+ let score = 0;
1170
+ const cover = normalizedCoverIdentity(item);
1171
+ if (cover) score += 40;
1172
+ const itemTowns = storyTowns(item, pubTowns).map((t) => t.toLowerCase());
1173
+ if (itemTowns.some((t) => currentTowns.has(t))) {
1174
+ score += 25;
1175
+ } else if (primaryRegion && `${item.title} ${item.excerpt ?? ""}`.toLowerCase().includes(primaryRegion)) {
1176
+ score += 18;
1177
+ }
1178
+ const section = resolveSection(item);
1179
+ if (section === currentSection) {
1180
+ score += 20;
1181
+ } else if (["Community", "Events", "Schools", "Local businesses", "Food & places"].includes(section)) {
1182
+ score += 12;
1183
+ }
1184
+ const itemTags = [
1185
+ ...(item.tags ?? []).map((t) => t.name.toLowerCase()),
1186
+ ...(item.categories ?? []).map((c) => c.name.toLowerCase())
1187
+ ];
1188
+ const sharedTags = itemTags.filter((t) => currentTags.has(t)).length;
1189
+ score += Math.min(sharedTags * 8, 16);
1190
+ if (CELEBRATION_PATTERN.test(`${item.title} ${item.excerpt ?? ""}`)) {
1191
+ score += 6;
1192
+ }
1193
+ const parsedTime = Date.parse(item.published_at);
1194
+ const publishedMs = Number.isFinite(parsedTime) ? parsedTime : 0;
1195
+ return { item: { ...item, section }, cover, score, publishedMs };
1196
+ }).sort((a, b) => b.score - a.score || b.publishedMs - a.publishedMs);
1197
+ const selected = [];
1198
+ for (const entry of scored) {
1199
+ if (selected.length >= limit) break;
1200
+ if (seenIds.has(entry.item.id) || seenSlugs.has(entry.item.slug)) continue;
1201
+ if (entry.cover && usedCovers.has(entry.cover)) continue;
1202
+ seenIds.add(entry.item.id);
1203
+ seenSlugs.add(entry.item.slug);
1204
+ if (entry.cover) usedCovers.add(entry.cover);
1205
+ selected.push(entry.item);
1206
+ }
1207
+ return selected;
1208
+ }
1209
+
1210
+ export { ANALYTICS_COOKIE, ANALYTICS_VERSION, ARTICLE_CONVERSION_EXPERIMENT, ArticleProvenance, EDITORIAL_SECTION_ORDER, EVENTS, LOCAL_BRIEFING_POLICY_VERSION, NAVIGATION_GROUPS, NETWORK_NAVIGATION, NEWS_CACHE, NewsError, SESSION_IDLE_MS, UTM_FIELDS, UUID, activeNearby, applyNavigationConfig, articleCacheKey, articleCacheOptions, articleMeta, articleUrl, attribution, briefSummary, campaignReportHtml, classifyStoryInvitationCategory, composeEdition, composeLocalBriefing, contextualCommunityInvitation, defaultRevalidateTargets, displayHeadline, edgeCacheControl, editorialSections, feedCacheKey, feedCacheOptions, filterNews, inventoryCacheOptions, isSponsoredStoryItem, llmsText, matchesStoryTag, measurementConsent, nearbyUpdates, newsArticleJsonLd, normalizedText, orderedSections, publicHttps, publicUrl, publicationJsonLd, publicationListeningCandidates, publicationListeningTrack, publicationMeta, readerSources, referralCode, resolveArticleConversionVariant, resolveCoupon, resolveNavigation, resolveSection, revalidateDecision, secretsMatch, sectionDefinition, sectionId, sectionPath, selectRelevantLocalStories, shortDeck, shortText, shouldServeLastKnown, storyDate, storyTowns, townSlug, validCalendarDate, walnutCreekTimes, withLastKnown };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.82",
3
+ "version": "0.0.84",
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": {
@@ -508,12 +508,15 @@
508
508
  .publication [class*="sectionCollection"] > [class*="section"]:only-child [class*="sectionFeature"],
509
509
  .publication [class*="sectionCollection"] > [class*="section"]:last-child:nth-child(odd) [class*="sectionFeature"] {
510
510
  display: block;
511
+ grid-column: auto;
512
+ grid-row: auto;
511
513
  }
512
514
 
513
515
  .publication [class*="sectionCollection"] > [class*="section"]:only-child [class*="sectionSupporting"],
514
516
  .publication [class*="sectionCollection"] > [class*="section"]:last-child:nth-child(odd) [class*="sectionSupporting"] {
515
517
  display: grid;
516
- grid-template-columns: minmax(0, 1fr);
518
+ grid-column: auto;
519
+ grid-template-columns: repeat(2, minmax(0, 1fr));
517
520
  gap: 20px;
518
521
  align-content: start;
519
522
  }