@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 +1 -1
- package/dist/index.cjs +207 -14
- package/dist/index.d.cts +52 -1
- package/dist/index.d.ts +52 -1
- package/dist/index.js +202 -15
- package/package.json +1 -1
- package/tokens/broadsheet.css +4 -1
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/tokens/broadsheet.css
CHANGED
|
@@ -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-
|
|
518
|
+
grid-column: auto;
|
|
519
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
517
520
|
gap: 20px;
|
|
518
521
|
align-content: start;
|
|
519
522
|
}
|