@scalemule/news-publication 0.0.82 → 0.0.83

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/dist/index.cjs CHANGED
@@ -829,8 +829,11 @@ var EVENTS = {
829
829
  article_shared: browser("engagement"),
830
830
  promotion_viewed: browser("content"),
831
831
  promotion_clicked: browser("engagement"),
832
+ newsletter_invitation_viewed: browser("content"),
833
+ related_stories_viewed: browser("content"),
832
834
  newsletter_signup_started: browser("engagement"),
833
835
  newsletter_subscription_confirmed: server(),
836
+ experiment_exposed: browser("system"),
834
837
  web_vital_recorded: browser("system"),
835
838
  scroll_depth_reached: browser("content"),
836
839
  navigation_clicked: browser("navigation"),
@@ -3633,6 +3636,198 @@ function articleFormatLabel(format) {
3633
3636
  return format === "opinion" ? "Opinion" : format === "explainer" ? "Explainer" : format === "interview" ? "Interview" : format === "photo_essay" ? "Photo essay" : format === "video_story" ? "Video story" : void 0;
3634
3637
  }
3635
3638
 
3639
+ // src/community-conversion.ts
3640
+ var ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
3641
+ 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;
3642
+ var SPONSORED_MARKER_PATTERN = /\b(sponsored|paid post|partner content|advertorial|promoted|brand studio)\b/iu;
3643
+ function resolveArticleConversionVariant(assignedVariant, queryOverride) {
3644
+ const candidate = (queryOverride ?? assignedVariant ?? "").trim().toLowerCase();
3645
+ if (candidate === "treatment" || candidate === "inline" || candidate === "b") {
3646
+ return "treatment";
3647
+ }
3648
+ return "control";
3649
+ }
3650
+ function classifyStoryInvitationCategory(story) {
3651
+ const combinedText = [
3652
+ story.title,
3653
+ story.excerpt ?? "",
3654
+ story.section ?? "",
3655
+ ...(story.tags ?? []).map((t) => t.name),
3656
+ ...(story.categories ?? []).map((c) => c.name)
3657
+ ].join(" ");
3658
+ if (CELEBRATION_PATTERN.test(combinedText)) {
3659
+ return "celebrations";
3660
+ }
3661
+ const section = resolveSection(story);
3662
+ switch (section) {
3663
+ case "Local businesses":
3664
+ return "local_businesses";
3665
+ case "Schools":
3666
+ return "education";
3667
+ case "Events":
3668
+ return "community_events";
3669
+ case "Civic life":
3670
+ case "Real Estate":
3671
+ case "Health":
3672
+ return "neighborhood_news";
3673
+ default:
3674
+ return "community";
3675
+ }
3676
+ }
3677
+ function contextualCommunityInvitation(story, publication) {
3678
+ const category = classifyStoryInvitationCategory(story);
3679
+ const region = publication.regionLabel.trim() || publication.name.trim();
3680
+ const coverage = publication.coverageText?.trim() || (publication.towns && publication.towns.length > 0 ? publication.towns.slice(0, 3).join(", ") : region);
3681
+ switch (category) {
3682
+ case "celebrations":
3683
+ return {
3684
+ category,
3685
+ eyebrow: `${region} Community`,
3686
+ heading: `Stay connected to ${region}`,
3687
+ subprompt: "Discover more people and milestones worth celebrating.",
3688
+ description: `${region} is full of stories worth celebrating. Discover the people, places, events, and milestones shaping ${coverage}.`,
3689
+ ctaLabel: "Subscribe",
3690
+ relatedHeading: `More stories from ${region}`
3691
+ };
3692
+ case "local_businesses":
3693
+ return {
3694
+ category,
3695
+ eyebrow: `${region} Local Businesses`,
3696
+ heading: `Stay connected to ${region}`,
3697
+ subprompt: "Meet the entrepreneurs building your community.",
3698
+ description: `Discover the founders, independent shops, places, and events shaping ${coverage}.`,
3699
+ ctaLabel: "Subscribe",
3700
+ relatedHeading: `More local stories in ${region}`
3701
+ };
3702
+ case "education":
3703
+ return {
3704
+ category,
3705
+ eyebrow: `${region} Schools & Youth`,
3706
+ heading: `Stay connected to ${region}`,
3707
+ subprompt: "Follow achievements from local students and schools.",
3708
+ description: `Discover the students, educators, neighborhood milestones, and events shaping ${coverage}.`,
3709
+ ctaLabel: "Subscribe",
3710
+ relatedHeading: `More stories from ${region}`
3711
+ };
3712
+ case "community_events":
3713
+ return {
3714
+ category,
3715
+ eyebrow: `${region} Events & Gatherings`,
3716
+ heading: `Stay connected to ${region}`,
3717
+ subprompt: "Find something happening near you this weekend.",
3718
+ description: `Discover the people, places, weekend gatherings, and local stories shaping ${coverage}.`,
3719
+ ctaLabel: "Subscribe",
3720
+ relatedHeading: `Happening around ${region}`
3721
+ };
3722
+ case "neighborhood_news":
3723
+ return {
3724
+ category,
3725
+ eyebrow: `${region} Neighborhood Briefing`,
3726
+ heading: `Stay connected to ${region}`,
3727
+ subprompt: "Stay informed about changes affecting your neighborhood.",
3728
+ description: `Follow the civic decisions, neighborhood projects, people, and events shaping ${coverage}.`,
3729
+ ctaLabel: "Subscribe",
3730
+ relatedHeading: `More from ${region}`
3731
+ };
3732
+ case "community":
3733
+ default:
3734
+ return {
3735
+ category: "community",
3736
+ eyebrow: `${region} Community`,
3737
+ heading: `Stay connected to ${region}`,
3738
+ subprompt: "Discover the people, places, and events shaping your community.",
3739
+ description: `Receive local reporting on the people, places, events, and stories shaping ${coverage}.`,
3740
+ ctaLabel: "Subscribe",
3741
+ relatedHeading: `More stories from ${region}`
3742
+ };
3743
+ }
3744
+ }
3745
+ function isSponsoredStoryItem(item) {
3746
+ const markers = [
3747
+ item.section ?? "",
3748
+ ...(item.tags ?? []).map((t) => `${t.name} ${t.slug}`),
3749
+ ...(item.categories ?? []).map((c) => `${c.name} ${c.slug}`)
3750
+ ].join(" ");
3751
+ return SPONSORED_MARKER_PATTERN.test(markers);
3752
+ }
3753
+ function normalizedCoverIdentity(item) {
3754
+ const raw = item.cover_image_url?.trim();
3755
+ if (!raw) return null;
3756
+ if (item.visual_identity?.version === 1 && /^[a-f0-9]{64}$/u.test(item.visual_identity.key)) {
3757
+ return `pixels:${item.visual_identity.key}`;
3758
+ }
3759
+ try {
3760
+ const url = new URL(raw);
3761
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
3762
+ url.hash = "";
3763
+ url.searchParams.sort();
3764
+ return `url:${url.toString()}`;
3765
+ } catch {
3766
+ return null;
3767
+ }
3768
+ }
3769
+ function selectRelevantLocalStories(currentStory, candidates, publication, limit = 3) {
3770
+ if (limit <= 0) return [];
3771
+ const currentSection = resolveSection(currentStory);
3772
+ const pubTowns = Array.isArray(publication.towns) ? publication.towns : [];
3773
+ const currentTowns = new Set(storyTowns(currentStory, pubTowns).map((t) => t.toLowerCase()));
3774
+ const currentTags = /* @__PURE__ */ new Set([
3775
+ ...(currentStory.tags ?? []).map((t) => t.name.toLowerCase()),
3776
+ ...(currentStory.categories ?? []).map((c) => c.name.toLowerCase())
3777
+ ]);
3778
+ const primaryRegion = publication.regionLabel.trim().toLowerCase();
3779
+ const seenIds = /* @__PURE__ */ new Set([currentStory.id]);
3780
+ const seenSlugs = /* @__PURE__ */ new Set([currentStory.slug]);
3781
+ const usedCovers = /* @__PURE__ */ new Set();
3782
+ const currentCover = normalizedCoverIdentity(currentStory);
3783
+ if (currentCover) usedCovers.add(currentCover);
3784
+ const scored = candidates.filter((item) => {
3785
+ if (!item?.id || !item?.slug) return false;
3786
+ if (seenIds.has(item.id) || seenSlugs.has(item.slug)) return false;
3787
+ if (isSponsoredStoryItem(item)) return false;
3788
+ return true;
3789
+ }).map((item) => {
3790
+ let score = 0;
3791
+ const cover = normalizedCoverIdentity(item);
3792
+ if (cover) score += 40;
3793
+ const itemTowns = storyTowns(item, pubTowns).map((t) => t.toLowerCase());
3794
+ if (itemTowns.some((t) => currentTowns.has(t))) {
3795
+ score += 25;
3796
+ } else if (primaryRegion && `${item.title} ${item.excerpt ?? ""}`.toLowerCase().includes(primaryRegion)) {
3797
+ score += 18;
3798
+ }
3799
+ const section = resolveSection(item);
3800
+ if (section === currentSection) {
3801
+ score += 20;
3802
+ } else if (["Community", "Events", "Schools", "Local businesses", "Food & places"].includes(section)) {
3803
+ score += 12;
3804
+ }
3805
+ const itemTags = [
3806
+ ...(item.tags ?? []).map((t) => t.name.toLowerCase()),
3807
+ ...(item.categories ?? []).map((c) => c.name.toLowerCase())
3808
+ ];
3809
+ const sharedTags = itemTags.filter((t) => currentTags.has(t)).length;
3810
+ score += Math.min(sharedTags * 8, 16);
3811
+ if (CELEBRATION_PATTERN.test(`${item.title} ${item.excerpt ?? ""}`)) {
3812
+ score += 6;
3813
+ }
3814
+ const parsedTime = Date.parse(item.published_at);
3815
+ const publishedMs = Number.isFinite(parsedTime) ? parsedTime : 0;
3816
+ return { item: { ...item, section }, cover, score, publishedMs };
3817
+ }).sort((a, b) => b.score - a.score || b.publishedMs - a.publishedMs);
3818
+ const selected = [];
3819
+ for (const entry of scored) {
3820
+ if (selected.length >= limit) break;
3821
+ if (seenIds.has(entry.item.id) || seenSlugs.has(entry.item.slug)) continue;
3822
+ if (entry.cover && usedCovers.has(entry.cover)) continue;
3823
+ seenIds.add(entry.item.id);
3824
+ seenSlugs.add(entry.item.slug);
3825
+ if (entry.cover) usedCovers.add(entry.cover);
3826
+ selected.push(entry.item);
3827
+ }
3828
+ return selected;
3829
+ }
3830
+
3636
3831
  Object.defineProperty(exports, "COOKIE_CONSENT_BACKUP_KEY", {
3637
3832
  enumerable: true,
3638
3833
  get: function () { return compliance.COOKIE_CONSENT_BACKUP_KEY; }
@@ -3731,6 +3926,7 @@ Object.defineProperty(exports, "setNetworkConsentHub", {
3731
3926
  });
3732
3927
  exports.ANALYTICS_COOKIE = ANALYTICS_COOKIE;
3733
3928
  exports.ANALYTICS_VERSION = ANALYTICS_VERSION;
3929
+ exports.ARTICLE_CONVERSION_EXPERIMENT = ARTICLE_CONVERSION_EXPERIMENT;
3734
3930
  exports.ArticleProvenance = ArticleProvenance;
3735
3931
  exports.BROADSHEET_THEME = BROADSHEET_THEME;
3736
3932
  exports.CLASSIFIED_CATEGORIES = CLASSIFIED_CATEGORIES;
@@ -3787,12 +3983,14 @@ exports.canHostMeetup = canHostMeetup;
3787
3983
  exports.categoryLabel = categoryLabel;
3788
3984
  exports.classifiedPath = classifiedPath;
3789
3985
  exports.classifiedSearchPath = classifiedSearchPath;
3986
+ exports.classifyStoryInvitationCategory = classifyStoryInvitationCategory;
3790
3987
  exports.clearTheme = clearTheme;
3791
3988
  exports.clipChars = clipChars;
3792
3989
  exports.communityMapEmbeds = communityMapEmbeds;
3793
3990
  exports.communityPublications = communityPublications;
3794
3991
  exports.composeEdition = composeEdition;
3795
3992
  exports.composeLocalBriefing = composeLocalBriefing;
3993
+ exports.contextualCommunityInvitation = contextualCommunityInvitation;
3796
3994
  exports.dealDollars = dealDollars;
3797
3995
  exports.dealGoPath = dealGoPath;
3798
3996
  exports.dealsFeedPath = dealsFeedPath;
@@ -3818,6 +4016,7 @@ exports.isAdult = isAdult;
3818
4016
  exports.isClassifiedCategory = isClassifiedCategory;
3819
4017
  exports.isEmploymentType = isEmploymentType;
3820
4018
  exports.isSafeActionUrl = isSafeActionUrl;
4019
+ exports.isSponsoredStoryItem = isSponsoredStoryItem;
3821
4020
  exports.jobEmployerBody = jobEmployerBody;
3822
4021
  exports.jobOpeningBody = jobOpeningBody;
3823
4022
  exports.jobPostingBody = jobPostingBody;
@@ -3850,6 +4049,7 @@ exports.readerSources = readerSources;
3850
4049
  exports.recordConsentToCompliance = recordConsentToCompliance;
3851
4050
  exports.referralCode = referralCode;
3852
4051
  exports.renderArticleBlocksToHtml = renderArticleBlocksToHtml;
4052
+ exports.resolveArticleConversionVariant = resolveArticleConversionVariant;
3853
4053
  exports.resolveCoupon = resolveCoupon;
3854
4054
  exports.resolveEffectiveTheme = resolveEffectiveTheme;
3855
4055
  exports.resolveLayout = resolveLayout;
@@ -3863,6 +4063,7 @@ exports.secretsMatch = secretsMatch;
3863
4063
  exports.sectionDefinition = sectionDefinition;
3864
4064
  exports.sectionId = sectionId;
3865
4065
  exports.sectionPath = sectionPath;
4066
+ exports.selectRelevantLocalStories = selectRelevantLocalStories;
3866
4067
  exports.setTheme = setTheme;
3867
4068
  exports.shortDeck = shortDeck;
3868
4069
  exports.shortText = shortText;
package/dist/index.d.cts CHANGED
@@ -624,4 +624,54 @@ interface SponsorshipReport {
624
624
  /** Shared by the newsroom print/export view and the repeatable PDF renderer. */
625
625
  declare function campaignReportHtml(report: SponsorshipReport): string;
626
626
 
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 };
627
+ declare const ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
628
+ type ArticleConversionVariant = "control" | "treatment";
629
+ type CommunityInvitationCategory = "celebrations" | "local_businesses" | "education" | "neighborhood_news" | "community_events" | "community";
630
+ interface ContextualCommunityInvitation {
631
+ category: CommunityInvitationCategory;
632
+ eyebrow: string;
633
+ heading: string;
634
+ subprompt: string;
635
+ description: string;
636
+ ctaLabel: string;
637
+ relatedHeading: string;
638
+ }
639
+ /**
640
+ * Resolves the `article_conversion_module` experiment variant consistently.
641
+ * Supports query-parameter preview overrides (`?conversion=treatment` or `?conversion=control`)
642
+ * before falling back to the assigned ScaleMule flag variant (`"control"` default).
643
+ */
644
+ declare function resolveArticleConversionVariant(assignedVariant?: string | null, queryOverride?: string | null): ArticleConversionVariant;
645
+ /**
646
+ * Infers the reader-facing community invitation category from a story's section,
647
+ * tags, categories, headline, and excerpt.
648
+ */
649
+ declare function classifyStoryInvitationCategory(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">): CommunityInvitationCategory;
650
+ /**
651
+ * Builds a publication-aware, category-tailored invitation that connects the reader's
652
+ * reason for visiting a specific article to staying connected with the local community.
653
+ */
654
+ interface CommunityInvitationPublicationContext {
655
+ name: string;
656
+ regionLabel: string;
657
+ coverageText?: string;
658
+ towns?: string[];
659
+ }
660
+ declare function contextualCommunityInvitation(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">, publication: CommunityInvitationPublicationContext): ContextualCommunityInvitation;
661
+ /**
662
+ * Returns true if a story carries any sponsored, paid, or partner-content markers.
663
+ * Editorial recommendation slots must never mix unlabeled sponsored items.
664
+ */
665
+ declare function isSponsoredStoryItem(item: NewsItem): boolean;
666
+ /**
667
+ * Selects 2–3 relevant local editorial stories for the post-prose Community Conversion Module.
668
+ *
669
+ * Rules:
670
+ * - Never includes the current story or duplicate story IDs/slugs.
671
+ * - Never includes sponsored/paid items (`isSponsoredStoryItem`).
672
+ * - Deduplicates cover images so recommended cards never repeat the current story's cover or each other's cover.
673
+ * - Prioritizes stories with valid cover photographs, shared town/geographic relevance, and community/section affinity.
674
+ */
675
+ declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
676
+
677
+ 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
@@ -624,4 +624,54 @@ interface SponsorshipReport {
624
624
  /** Shared by the newsroom print/export view and the repeatable PDF renderer. */
625
625
  declare function campaignReportHtml(report: SponsorshipReport): string;
626
626
 
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 };
627
+ declare const ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
628
+ type ArticleConversionVariant = "control" | "treatment";
629
+ type CommunityInvitationCategory = "celebrations" | "local_businesses" | "education" | "neighborhood_news" | "community_events" | "community";
630
+ interface ContextualCommunityInvitation {
631
+ category: CommunityInvitationCategory;
632
+ eyebrow: string;
633
+ heading: string;
634
+ subprompt: string;
635
+ description: string;
636
+ ctaLabel: string;
637
+ relatedHeading: string;
638
+ }
639
+ /**
640
+ * Resolves the `article_conversion_module` experiment variant consistently.
641
+ * Supports query-parameter preview overrides (`?conversion=treatment` or `?conversion=control`)
642
+ * before falling back to the assigned ScaleMule flag variant (`"control"` default).
643
+ */
644
+ declare function resolveArticleConversionVariant(assignedVariant?: string | null, queryOverride?: string | null): ArticleConversionVariant;
645
+ /**
646
+ * Infers the reader-facing community invitation category from a story's section,
647
+ * tags, categories, headline, and excerpt.
648
+ */
649
+ declare function classifyStoryInvitationCategory(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">): CommunityInvitationCategory;
650
+ /**
651
+ * Builds a publication-aware, category-tailored invitation that connects the reader's
652
+ * reason for visiting a specific article to staying connected with the local community.
653
+ */
654
+ interface CommunityInvitationPublicationContext {
655
+ name: string;
656
+ regionLabel: string;
657
+ coverageText?: string;
658
+ towns?: string[];
659
+ }
660
+ declare function contextualCommunityInvitation(story: Pick<NewsItem, "title" | "excerpt" | "section" | "tags" | "categories">, publication: CommunityInvitationPublicationContext): ContextualCommunityInvitation;
661
+ /**
662
+ * Returns true if a story carries any sponsored, paid, or partner-content markers.
663
+ * Editorial recommendation slots must never mix unlabeled sponsored items.
664
+ */
665
+ declare function isSponsoredStoryItem(item: NewsItem): boolean;
666
+ /**
667
+ * Selects 2–3 relevant local editorial stories for the post-prose Community Conversion Module.
668
+ *
669
+ * Rules:
670
+ * - Never includes the current story or duplicate story IDs/slugs.
671
+ * - Never includes sponsored/paid items (`isSponsoredStoryItem`).
672
+ * - Deduplicates cover images so recommended cards never repeat the current story's cover or each other's cover.
673
+ * - Prioritizes stories with valid cover photographs, shared town/geographic relevance, and community/section affinity.
674
+ */
675
+ declare function selectRelevantLocalStories(currentStory: NewsItem, candidates: NewsItem[], publication: Pick<NewsPublication, "regionLabel" | "towns">, limit?: number): NewsItem[];
676
+
677
+ 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
@@ -773,8 +773,11 @@ var EVENTS = {
773
773
  article_shared: browser("engagement"),
774
774
  promotion_viewed: browser("content"),
775
775
  promotion_clicked: browser("engagement"),
776
+ newsletter_invitation_viewed: browser("content"),
777
+ related_stories_viewed: browser("content"),
776
778
  newsletter_signup_started: browser("engagement"),
777
779
  newsletter_subscription_confirmed: server(),
780
+ experiment_exposed: browser("system"),
778
781
  web_vital_recorded: browser("system"),
779
782
  scroll_depth_reached: browser("content"),
780
783
  navigation_clicked: browser("navigation"),
@@ -1020,4 +1023,196 @@ function campaignReportHtml(report) {
1020
1023
  <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
1024
  }
1022
1025
 
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 };
1026
+ // src/community-conversion.ts
1027
+ var ARTICLE_CONVERSION_EXPERIMENT = "article_conversion_module";
1028
+ 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;
1029
+ var SPONSORED_MARKER_PATTERN = /\b(sponsored|paid post|partner content|advertorial|promoted|brand studio)\b/iu;
1030
+ function resolveArticleConversionVariant(assignedVariant, queryOverride) {
1031
+ const candidate = (queryOverride ?? assignedVariant ?? "").trim().toLowerCase();
1032
+ if (candidate === "treatment" || candidate === "inline" || candidate === "b") {
1033
+ return "treatment";
1034
+ }
1035
+ return "control";
1036
+ }
1037
+ function classifyStoryInvitationCategory(story) {
1038
+ const combinedText = [
1039
+ story.title,
1040
+ story.excerpt ?? "",
1041
+ story.section ?? "",
1042
+ ...(story.tags ?? []).map((t) => t.name),
1043
+ ...(story.categories ?? []).map((c) => c.name)
1044
+ ].join(" ");
1045
+ if (CELEBRATION_PATTERN.test(combinedText)) {
1046
+ return "celebrations";
1047
+ }
1048
+ const section = resolveSection(story);
1049
+ switch (section) {
1050
+ case "Local businesses":
1051
+ return "local_businesses";
1052
+ case "Schools":
1053
+ return "education";
1054
+ case "Events":
1055
+ return "community_events";
1056
+ case "Civic life":
1057
+ case "Real Estate":
1058
+ case "Health":
1059
+ return "neighborhood_news";
1060
+ default:
1061
+ return "community";
1062
+ }
1063
+ }
1064
+ function contextualCommunityInvitation(story, publication) {
1065
+ const category = classifyStoryInvitationCategory(story);
1066
+ const region = publication.regionLabel.trim() || publication.name.trim();
1067
+ const coverage = publication.coverageText?.trim() || (publication.towns && publication.towns.length > 0 ? publication.towns.slice(0, 3).join(", ") : region);
1068
+ switch (category) {
1069
+ case "celebrations":
1070
+ return {
1071
+ category,
1072
+ eyebrow: `${region} Community`,
1073
+ heading: `Stay connected to ${region}`,
1074
+ subprompt: "Discover more people and milestones worth celebrating.",
1075
+ description: `${region} is full of stories worth celebrating. Discover the people, places, events, and milestones shaping ${coverage}.`,
1076
+ ctaLabel: "Subscribe",
1077
+ relatedHeading: `More stories from ${region}`
1078
+ };
1079
+ case "local_businesses":
1080
+ return {
1081
+ category,
1082
+ eyebrow: `${region} Local Businesses`,
1083
+ heading: `Stay connected to ${region}`,
1084
+ subprompt: "Meet the entrepreneurs building your community.",
1085
+ description: `Discover the founders, independent shops, places, and events shaping ${coverage}.`,
1086
+ ctaLabel: "Subscribe",
1087
+ relatedHeading: `More local stories in ${region}`
1088
+ };
1089
+ case "education":
1090
+ return {
1091
+ category,
1092
+ eyebrow: `${region} Schools & Youth`,
1093
+ heading: `Stay connected to ${region}`,
1094
+ subprompt: "Follow achievements from local students and schools.",
1095
+ description: `Discover the students, educators, neighborhood milestones, and events shaping ${coverage}.`,
1096
+ ctaLabel: "Subscribe",
1097
+ relatedHeading: `More stories from ${region}`
1098
+ };
1099
+ case "community_events":
1100
+ return {
1101
+ category,
1102
+ eyebrow: `${region} Events & Gatherings`,
1103
+ heading: `Stay connected to ${region}`,
1104
+ subprompt: "Find something happening near you this weekend.",
1105
+ description: `Discover the people, places, weekend gatherings, and local stories shaping ${coverage}.`,
1106
+ ctaLabel: "Subscribe",
1107
+ relatedHeading: `Happening around ${region}`
1108
+ };
1109
+ case "neighborhood_news":
1110
+ return {
1111
+ category,
1112
+ eyebrow: `${region} Neighborhood Briefing`,
1113
+ heading: `Stay connected to ${region}`,
1114
+ subprompt: "Stay informed about changes affecting your neighborhood.",
1115
+ description: `Follow the civic decisions, neighborhood projects, people, and events shaping ${coverage}.`,
1116
+ ctaLabel: "Subscribe",
1117
+ relatedHeading: `More from ${region}`
1118
+ };
1119
+ case "community":
1120
+ default:
1121
+ return {
1122
+ category: "community",
1123
+ eyebrow: `${region} Community`,
1124
+ heading: `Stay connected to ${region}`,
1125
+ subprompt: "Discover the people, places, and events shaping your community.",
1126
+ description: `Receive local reporting on the people, places, events, and stories shaping ${coverage}.`,
1127
+ ctaLabel: "Subscribe",
1128
+ relatedHeading: `More stories from ${region}`
1129
+ };
1130
+ }
1131
+ }
1132
+ function isSponsoredStoryItem(item) {
1133
+ const markers = [
1134
+ item.section ?? "",
1135
+ ...(item.tags ?? []).map((t) => `${t.name} ${t.slug}`),
1136
+ ...(item.categories ?? []).map((c) => `${c.name} ${c.slug}`)
1137
+ ].join(" ");
1138
+ return SPONSORED_MARKER_PATTERN.test(markers);
1139
+ }
1140
+ function normalizedCoverIdentity(item) {
1141
+ const raw = item.cover_image_url?.trim();
1142
+ if (!raw) return null;
1143
+ if (item.visual_identity?.version === 1 && /^[a-f0-9]{64}$/u.test(item.visual_identity.key)) {
1144
+ return `pixels:${item.visual_identity.key}`;
1145
+ }
1146
+ try {
1147
+ const url = new URL(raw);
1148
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
1149
+ url.hash = "";
1150
+ url.searchParams.sort();
1151
+ return `url:${url.toString()}`;
1152
+ } catch {
1153
+ return null;
1154
+ }
1155
+ }
1156
+ function selectRelevantLocalStories(currentStory, candidates, publication, limit = 3) {
1157
+ if (limit <= 0) return [];
1158
+ const currentSection = resolveSection(currentStory);
1159
+ const pubTowns = Array.isArray(publication.towns) ? publication.towns : [];
1160
+ const currentTowns = new Set(storyTowns(currentStory, pubTowns).map((t) => t.toLowerCase()));
1161
+ const currentTags = /* @__PURE__ */ new Set([
1162
+ ...(currentStory.tags ?? []).map((t) => t.name.toLowerCase()),
1163
+ ...(currentStory.categories ?? []).map((c) => c.name.toLowerCase())
1164
+ ]);
1165
+ const primaryRegion = publication.regionLabel.trim().toLowerCase();
1166
+ const seenIds = /* @__PURE__ */ new Set([currentStory.id]);
1167
+ const seenSlugs = /* @__PURE__ */ new Set([currentStory.slug]);
1168
+ const usedCovers = /* @__PURE__ */ new Set();
1169
+ const currentCover = normalizedCoverIdentity(currentStory);
1170
+ if (currentCover) usedCovers.add(currentCover);
1171
+ const scored = candidates.filter((item) => {
1172
+ if (!item?.id || !item?.slug) return false;
1173
+ if (seenIds.has(item.id) || seenSlugs.has(item.slug)) return false;
1174
+ if (isSponsoredStoryItem(item)) return false;
1175
+ return true;
1176
+ }).map((item) => {
1177
+ let score = 0;
1178
+ const cover = normalizedCoverIdentity(item);
1179
+ if (cover) score += 40;
1180
+ const itemTowns = storyTowns(item, pubTowns).map((t) => t.toLowerCase());
1181
+ if (itemTowns.some((t) => currentTowns.has(t))) {
1182
+ score += 25;
1183
+ } else if (primaryRegion && `${item.title} ${item.excerpt ?? ""}`.toLowerCase().includes(primaryRegion)) {
1184
+ score += 18;
1185
+ }
1186
+ const section = resolveSection(item);
1187
+ if (section === currentSection) {
1188
+ score += 20;
1189
+ } else if (["Community", "Events", "Schools", "Local businesses", "Food & places"].includes(section)) {
1190
+ score += 12;
1191
+ }
1192
+ const itemTags = [
1193
+ ...(item.tags ?? []).map((t) => t.name.toLowerCase()),
1194
+ ...(item.categories ?? []).map((c) => c.name.toLowerCase())
1195
+ ];
1196
+ const sharedTags = itemTags.filter((t) => currentTags.has(t)).length;
1197
+ score += Math.min(sharedTags * 8, 16);
1198
+ if (CELEBRATION_PATTERN.test(`${item.title} ${item.excerpt ?? ""}`)) {
1199
+ score += 6;
1200
+ }
1201
+ const parsedTime = Date.parse(item.published_at);
1202
+ const publishedMs = Number.isFinite(parsedTime) ? parsedTime : 0;
1203
+ return { item: { ...item, section }, cover, score, publishedMs };
1204
+ }).sort((a, b) => b.score - a.score || b.publishedMs - a.publishedMs);
1205
+ const selected = [];
1206
+ for (const entry of scored) {
1207
+ if (selected.length >= limit) break;
1208
+ if (seenIds.has(entry.item.id) || seenSlugs.has(entry.item.slug)) continue;
1209
+ if (entry.cover && usedCovers.has(entry.cover)) continue;
1210
+ seenIds.add(entry.item.id);
1211
+ seenSlugs.add(entry.item.slug);
1212
+ if (entry.cover) usedCovers.add(entry.cover);
1213
+ selected.push(entry.item);
1214
+ }
1215
+ return selected;
1216
+ }
1217
+
1218
+ 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.83",
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
  }