diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index 9f54464212..3f51174576 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -41,6 +41,7 @@
"hostPrivateInstancesDescription": "Surface insights from within your organization",
"questionFeed": "Question Feed",
"feed": "Feed",
+ "newCommentsSortNote": "New Comments only shows questions you have viewed before and that have unread comments.",
"collectiveForecastsForPublicGood": "Collective forecasts for the public good",
"followImportantTopics": "Follow important topics",
"followImportantTopicsDescription": "AI, Geopolitics, Biosecurity, and more",
@@ -235,6 +236,7 @@
"metaculus_prediction": "Metaculus Prediction",
"metaculus": "Metaculus",
"community": "community",
+ "enterProbability": "Enter probability",
"communityPrediction": "Community Prediction",
"unweightedAggregate": "Unweighted Aggregate",
"firstQuartile": "lower 25%",
@@ -272,6 +274,8 @@
"commentsReadGuidelines": "Read our guidelines",
"commentsLearnMarkdown": "Learn about our Markdown",
"commentDeleted": "Comment deleted",
+ "deleteCommentConfirmTitle": "Delete comment?",
+ "deleteCommentConfirmDescription": "This comment will be removed. This can't be undone.",
"commentsReportCommentHeading": "Report this comment",
"commentsReportCommentDescription": "Is there something wrong with this comment? Please help us keep our community great by reporting it.",
"commentsReportSpam": "Report as spam",
@@ -327,6 +331,8 @@
"hideCommunityPrediction": "Hide Community Prediction",
"viewMetaculusPrediction": "View Metaculus Prediction",
"questionSearchPlaceholder": "search questions...",
+ "feedSearchResultsFor": "{count} results for \"{search}\"",
+ "feedSearchResultsForWithoutCount": "Results for \"{search}\"",
"articlesSearchPlaceholder": "search articles...",
"tagSearchPlaceholder": "search tags...",
"search": "Search",
@@ -351,6 +357,7 @@
"searchOptionActivePrediction": "Active Prediction",
"searchOptionWithdrawnPrediction": "Withdrawn",
"searchOptionNotPredicted": "Not Predicted",
+ "searchOptionCommented": "Commented",
"searchOptionAuthored": "Authored",
"searchOptionUpvoted": "Upvoted",
"searchOptionPrivate": "Private",
@@ -375,6 +382,7 @@
"estimatedReadingTime": "{minutes} min read",
"predictions": "Predictions",
"predictors": "Predictors",
+ "predictorsMentionWarning": "Only curators and admins can notify @predictors. Your mention will not send notifications.",
"relativeLog": "Relative Log",
"unreadAll": "all unread",
"questionsLeft": "{count, plural, =1 {1 question left} other {{count} questions left} }",
@@ -384,6 +392,7 @@
"unreadWithTotalCount": "({unread_count_formatted} unread) {total_count_formatted} total",
"unreadWithTotalCountXs": "({unread_count_formatted}) {total_count_formatted}",
"forecastersWithCount": "{count_formatted} {count, plural, =1 {forecaster} other {forecasters}}",
+ "histogramBinShare": "{share} weight of forecasts",
"or": "or",
"signupInviteUsers": "Invite Users",
"signupInviteUsersDescription": "Enter the email addresses of users you would like to invite, separated by newlines. They will receive an invitation to sign up by email.",
@@ -489,6 +498,7 @@
"reminderErrorPercentLifetimeRequired": "Please set a percent of lifetime",
"reminderErrorPercentLifetimeCannotHappen": "This would result in no notification, please select a lower value",
"reminderErrorPercentLifetimeWouldNotRepeat": "This would result in no repetition. Consider switching to \"Doesn't Repeat\"",
+ "reminderErrorDuplicateDate": "This date is already selected. Please choose a different date.",
"reminderNotePlaceholder": "Write yourself a note...",
"reminderCPChangesDescription1": "Notify me when the forecast changes by more than 10%",
"reminderCPChangesDescription2": "For continuous questions, this is measured by a Kolmogorov–Smirnov test.",
@@ -601,6 +611,7 @@
"toggleAllTopics": "Toggle all topics",
"toggleNewsAnnotations": "Toggle news annotations",
"Filter": "Filter",
+ "sort": "Sort",
"done": "done",
"clear": "clear",
"predicted": "Predicted",
@@ -778,6 +789,7 @@
"aboutMetaculusTitle": "About Metaculus",
"aboutMetaculusDescription": "Metaculus is an online forecasting platform and aggregation engine working to improve human reasoning and coordination on topics of global importance.",
"more": "More",
+ "nMore": "{count} more",
"moreLikely": "percentage points higher",
"afterElection": "under {winner}",
"ifCandidateElected": "if {candidate} elected",
@@ -845,6 +857,7 @@
"userProfile": "{username}'s profile",
"unknownUserProfile": "User's profile",
"userMedals": "{username}'s medals",
+ "userContributions": "{username}'s contributions",
"unknownUserMedals": "User's medals",
"noMedals": "No medals yet",
"leaderboard": "Leaderboard",
@@ -910,6 +923,7 @@
"parentResolutionCriteria": "Parent Resolution Criteria",
"childResolutionCriteria": "Child Resolution Criteria",
"loadMoreComments": "Load more comments",
+ "linkedComment": "Linked comment",
"followers": "Followers",
"followed": "Followed",
"followButton": "Follow",
@@ -994,7 +1008,7 @@
"settingsNewEmail": "New Email",
"settingsEmailChangeDescription": "Enter a new email address and a password, and we'll send you a link to change your email.",
"settingsChangeEmailAddress": "Change email address",
- "settingsChangeEmailAddressSuccess": "A confirmation email has been sent to your new address. Please follow the link inside to activate it.",
+ "settingsChangeEmailAddressSuccess": "A confirmation email has been sent to your old address. Please follow the link inside to activate it.",
"emailChangeErrorMessage": "Failed to change email",
"emailChangeSuccessMessage": "Your email address has been changed successfully",
"socialAccountNoPasswordBanner": "Your account doesn't have a password yet. Set one below to change your email or log in with email and password.",
@@ -1093,6 +1107,7 @@
"choicesSeparatedBy": "Choices (separated by ,)",
"choicesLockedHelp": "Options can only be changed through the admin panel once forecasting has started.",
"projects": "projects",
+ "projectsTags": "Projects / Tags",
"FABPrizePool": "PRIZE POOL",
"FABPrizeValue": "$30,000",
"FABStartDate": "START DATE",
@@ -1210,6 +1225,7 @@
"deselectAll": "deselect all",
"forecastDataIsEmpty": "Forecast data is empty",
"noForecastsYet": "No forecasts yet",
+ "noHistogramData": "No histogram data available",
"RevealTemporarily": "Reveal Temporarily",
"CPIsHidden": "Community Prediction is hidden",
"createdByUserOnDate": "Created by {user} on {date}",
@@ -1309,6 +1325,9 @@
"youArePostingAPrivateComment": "You are posting a private comment",
"unread": "unread",
"contentTranslatedHeaderText": "Some content on this page is automatically translated, and may be inaccurate.",
+ "translatedBy": "translated by",
+ "listLayout": "List layout",
+ "gridLayout": "Grid layout",
"showOriginalContent": "Show original",
"translated_by": "translated by",
"nextQuestion": "Next Question",
@@ -1323,6 +1342,8 @@
"keyFactor": "Key Factor",
"scrollToKeyFactor": "Scroll to key factor",
"keyFactors": "Key Factors",
+ "keyFactorsFor": "Key factors for",
+ "goToKeyFactor": "Go to key factor {number}",
"topKeyFactors": "Top Key Factors",
"notAvailable": "Not available",
"indexesTitle": "Project",
@@ -1383,6 +1404,7 @@
"selectChildQuestion": "Select Child Question",
"pickQuestion": "Pick Question",
"viewQuestions": "View Questions({count})",
+ "includingSubquestions": "Including Subquestions: {count}",
"predictionFlow": "Prediction Flow",
"started": "Started",
"starts": "Starts",
@@ -1781,6 +1803,7 @@
"unknown": "Unknown",
"lowest": "Lowest",
"highest": "Highest",
+ "fanChart": "Fan Chart",
"timeline": "Timeline",
"inNews": "In the News",
"info": "Question Info",
@@ -1965,6 +1988,7 @@
"copyQuestionLinkSuccess": "Question link successfully created! You can see them under the Question Links section for each question.",
"viewQuestionLinks": "View Question Links",
"moreActions": "More actions",
+ "openOnMetaculus": "Open on Metaculus",
"copyQuestionLinkImpactPrefix": "has a",
"copyQuestionLinkImpactSuffix": "causal impact on:",
"copyQuestionLinkStrengthLabel": "The strength of this impact is:",
@@ -1980,6 +2004,7 @@
"loadMore": "Load More",
"noPrivateNotes": "No private notes yet",
"privateNotes": "Private Notes",
+ "questionLinks": "Question Links",
"justNow": "just now",
"cmmButtonShort": "Mind",
"myForecastingBots": "My Forecasting Bots",
@@ -2026,6 +2051,7 @@
"tournamentTimelineStarts": "Starts {when}",
"tournamentTimelineEnds": "Ends {when}",
"tournamentTimelineClosed": "Pending resolutions",
+ "tournamentTimelinePendingWinners": "Pending winners announcement",
"tournamentTimelineAllResolved": "All questions resolved",
"tournamentRelativeSoon": "soon",
"tournamentRelativeUnderMinute": "in under a minute",
@@ -2084,7 +2110,7 @@
"readFullReport": "Read full report",
"feedTileNewTournament": "New Tournament",
"feedTileForecastNewQuestions": "Forecast {count, plural, one {# new question} other {# new questions}}",
- "feedTileQuestionsResolved": "{count, plural, one {# question resolved} other {# questions resolved}}",
+ "feedTileQuestionsRecentlyResolved": "{count, plural, one {# question recently resolved} other {# questions recently resolved}}",
"feedTileCheckLeaderboards": "Check out the final leaderboards",
"feedTilePopularTournament": "Popular Tournament",
"feedTileClosesIn": "Closes in {when}",
@@ -2197,5 +2223,139 @@
"whyTrustMetaculusLessNoiseTitle": "Less noise, more truth",
"whyTrustMetaculusLessNoise": "Metaculus's crowd-powered forecasts cut through the noise by grounding every prediction in transparent evidence, accountable scoring, and a decade of demonstrated accuracy. Metaculus equips policymakers, researchers, journalists, and corporate organizations with evidence-based forecasts that surface clear, quantifiable insight into the world's most critical uncertainties. Explore our suite of Business Solutions to learn more about how Metaculus can improve your organization's decision-making.",
"publishTimeLockedDescription": "Publish time cannot be changed after creation.",
- "feedTileSummaryPlaceholder": "Optional: Enter a custom summary text to display on feed tiles (if not provided, a summary will be auto-generated from the notebook content)"
+ "feedTileSummaryPlaceholder": "Optional: Enter a custom summary text to display on feed tiles (if not provided, a summary will be auto-generated from the notebook content)",
+ "feedTileSummaryRequiredForFormattedContent": "Feed tile summary is required because the visible start of your notebook contains formatting (a list, blockquote, code block, table, or raw HTML) that wouldn't render cleanly on a feed tile. Add a short plain-text summary so readers see a clean preview.",
+ "laborHubJobsBreadcrumb": "All Jobs",
+ "laborHubJobsVisitCta": "Visit Jobs",
+ "laborHubJobsPageTitle": "All Jobs · Labor Automation Forecasting Hub | Metaculus",
+ "laborHubJobsPageDescription": "Browse 15 US occupations forecasted by the Metaculus community through 2035. See how AI may reshape each one.",
+ "laborHubJobsHeroTitle": "Which jobs will survive AI?",
+ "laborHubJobsHeroLead": "Metaculus forecasters track 15 representative US occupations through 2035. Pick a job to see the forecast trajectory, the why behind the numbers, and what comes next.",
+ "laborHubJobDetailTitle": "Will AI replace {name}? · Metaculus Labor Hub forecast",
+ "laborHubJobDetailDescription": "Community forecasts for {name} employment, wages and AI exposure through 2035. Live from the Metaculus Labor Automation Forecasting Hub.",
+ "laborHubJobDetailSubtitle": "Community forecast of employment change versus 2025, across 2027, 2030, and 2035.",
+ "laborHubJobsClickTileSubtitle": "Click any tile for the full forecast & forecaster commentary",
+ "laborHubJobsYearToggleLabel": "Forecast year",
+ "laborHubJobsExploreCta": "Explore the full Labor Hub Dashboard",
+ "laborHubJobsExploreCtaSub": "Occupations and wages · economy-wide forecasts · forecaster commentary",
+ "laborHubJobsByYear": "By {year}",
+ "laborHubJobsJumpToLabel": "Jump to:",
+ "laborHubJobsFeltenLabel": "Felten et al. exposure",
+ "laborHubJobsFeltenTooltip": "Academic measure of how much a job's required abilities overlap with current AI capabilities.",
+ "laborHubJobsMnaLabel": "M&A vulnerability",
+ "laborHubJobsMnaTooltip": "Academic measure combining a job's AI exposure with how well its workers can adapt.",
+ "laborHubJobsAoeLabel": "Anthropic Exposure",
+ "laborHubJobsAoeTooltip": "Grounded in real, anonymized Claude usage — the share of this job's tasks already being done with AI.",
+ "laborHubJobsRingHigh": "HIGH",
+ "laborHubJobsRingMed": "MED",
+ "laborHubJobsRingLow": "LOW",
+ "laborHubJobsCuratedInsightsTitle": "Curated insights",
+ "laborHubJobsCuratedInsightsEmpty": "No insights surfaced for {name} yet — check the full Labor Hub for active discussion.",
+ "laborHubJobsInsightSourceFromAnotherPost": "from a related question",
+ "laborHubJobsWagesTitle": "Median wage by 2035",
+ "laborHubJobsWagesSub": "Real change vs 2025",
+ "laborHubJobsWagesUnavailable": "Per-occupation wage forecast not yet tracked",
+ "laborHubJobsHoursTitle": "Hours / week · economy-wide",
+ "laborHubJobsHoursEconomyWideSub": "By 2035, vs {from} hrs/week in 2025",
+ "laborHubJobsShareTitle": "Share this forecast",
+ "laborHubJobsShareLead": "Download a share card and share this forecast with your network.",
+ "laborHubJobsShareYearLabel": "Headline year",
+ "laborHubJobsShareYearOption": "By {year}",
+ "laborHubJobsShareSave": "Save Image",
+ "laborHubJobsShareTweet": "Share on X",
+ "laborHubJobsShareLinkedin": "Share on LinkedIn",
+ "laborHubJobsMetricSeeAll": "Click to compare this to other jobs →",
+ "laborHubJobsMetricPaperLink": "Read the paper →",
+ "laborHubJobsProForecasterTitle": "Pro Forecaster",
+ "laborHubJobsMetricChartHead": "How compares",
+ "laborHubJobsMetricLower": "lower",
+ "laborHubJobsMetricHigher": "higher",
+ "laborHubJobsFeltenSource": "Felten, Raj & Seamans — Language modeling AIOE",
+ "laborHubJobsFeltenAxisLabel": "Felten exposure score (standardized)",
+ "laborHubJobsFeltenNature1": "The AI Occupational Exposure (AIOE) measures the overlap between AI capabilities and the abilities an occupation requires, aggregated from O*NET ability data.",
+ "laborHubJobsFeltenNature3": "Higher exposure does not by itself mean a job will be automated — it can equally signal that AI augments the work.",
+ "laborHubJobsFeltenBounds": "Scale: standardized z-score · the average occupation sits near 0; positive values are more exposed than average, negative values less.",
+ "laborHubJobsMnaSource": "Manning & Aguirre — Vulnerability score",
+ "laborHubJobsMnaAxisLabel": "Manning & Aguirre vulnerability (0–1)",
+ "laborHubJobsMnaNature1": "Academic measure combining exposure to AI with workers' adaptive capacity.",
+ "laborHubJobsMnaNature3": "Vulnerability scores are related both to the likelihood of displacement (through AI exposure) and its burden (through adaptive capacity).",
+ "laborHubJobsMnaBounds": "Scale: 0 to 1 · highest in occupations that are both heavily exposed and their workers are poorly cushioned against displacement.",
+ "laborHubJobsAoeSource": "Massenkoff & McCrory — Observed Exposure (AOE)",
+ "laborHubJobsAoeAxisLabel": "Observed AI exposure (0–100%)",
+ "laborHubJobsAoeNature1": "Unlike purely theoretical measures, this is also grounded in real, anonymized Claude usage: what people are actually doing with AI right now, mapped to O*NET occupation tasks.",
+ "laborHubJobsAoeNature3": "AOE captures observed adoption, not potential — a low value may mean the work simply isn't being brought to Claude yet, not that AI couldn't do it.",
+ "laborHubJobsAoeBounds": "Scale: 0% to 100% · highest in occupations whose tasks AI can theoretically do and is already being used for.",
+ "laborHubJobsChartA11y": "Employment forecast by year",
+ "laborHubJobsChartBaseline": "Baseline",
+ "midtermsHubMetaTitle": "2026 US Midterm Elections | Metaculus",
+ "midtermsHubMetaDescription": "Real-time forecasts from the Metaculus community on the 2026 US midterm elections.",
+ "midtermsHubChamberSenate": "Senate",
+ "midtermsHubChamberHouse": "House",
+ "midtermsHubChamberGovernor": "Governor",
+ "midtermsHubChamberControl": "Chamber control",
+ "midtermsHubCongressForecast": "Congressional Control",
+ "midtermsHubChamberCurrent": "Current:",
+ "midtermsHubChamberForecast": "Forecast:",
+ "midtermsHubChamberTooltipBody": "Forecasters give {party} a {pct}% chance of holding the most {chamber} seats.",
+ "midtermsHubChamberTooltipDisclaimer": "Click to view forecasting question",
+ "midtermsHubPartyDemocrats": "Democrats",
+ "midtermsHubPartyRepublicans": "Republicans",
+ "midtermsHubOutcomeRepRep": "Rep House / Rep Senate",
+ "midtermsHubOutcomeRepDem": "Dem House / Rep Senate",
+ "midtermsHubOutcomeDemRep": "Rep House / Dem Senate",
+ "midtermsHubOutcomeDemDem": "Dem House / Dem Senate",
+ "midtermsHubCongressSummary": "Forecasters' current views on Congress control are reflected in the probabilities above.",
+ "midtermsHubThingsToWatch": "Key Drivers",
+ "midtermsHubAbortionAmendment": "Missouri abortion-rights repeal",
+ "midtermsHubAbortionAmendmentContext": "Missouri voters decide whether to repeal the abortion-rights protections they enshrined in 2024.",
+ "midtermsHubElectoralConsequences": "Electoral Consequences",
+ "midtermsHubConsequenceQuestion": "Question",
+ "midtermsHubConsequenceIfRep": "if Rep Congress",
+ "midtermsHubConsequenceIfDem": "if Dem Congress",
+ "midtermsHubConsequenceHeaderRepTitle": "Republican Congress",
+ "midtermsHubConsequenceHeaderRepSubtitle": "If GOP controls",
+ "midtermsHubConsequenceHeaderSplitTitle": "Split Congress",
+ "midtermsHubConsequenceHeaderSplitSubtitle": "If control is split",
+ "midtermsHubConsequenceIfSplit": "if Split Congress",
+ "midtermsHubMailInBallots": "Mail-in ballot court challenge",
+ "midtermsHubMailInBallotsContext": "New USPS restrictions could reshape mail-in voting access, but pending lawsuits may block them before November.",
+ "midtermsHubElectionEmergency": "Election-integrity national emergency",
+ "midtermsHubElectionEmergencyContext": "A national emergency declaration could expand federal authority over the election.",
+ "midtermsHubConsequenceHeaderDemTitle": "Democratic Congress",
+ "midtermsHubConsequenceHeaderDemSubtitle": "If Dems control",
+ "midtermsHubConsequenceClimate": "Will major federal climate legislation be passed before 2028?",
+ "midtermsHubConsequenceMinWage": "Will the federal minimum wage be raised before 2028?",
+ "midtermsHubConsequenceImmigration": "Will a major immigration reform bill pass before 2028?",
+ "midtermsHubConsequenceShutdown": "Will there be a government shutdown lasting more than 14 days before 2028?",
+ "midtermsHubCommunityInsights": "Community Insights",
+ "midtermsHubScrollLeft": "Scroll insights left",
+ "midtermsHubScrollRight": "Scroll insights right",
+ "midtermsHubDemocrat": "Democrat",
+ "midtermsHubRepublican": "Republican",
+ "midtermsHubNotContested": "Not contested",
+ "midtermsHubDemPct": "{pct}% Democrat",
+ "midtermsHubRepPct": "{pct}% Republican",
+ "midtermsHubNoForecast": "No forecast",
+ "midtermsHubFooterDisclaimer": "Not affiliated with any political party.",
+ "midtermsHubHeroTitleLine1": "2026 US",
+ "midtermsHubHeroTitleLine2": "Midterm Elections",
+ "midtermsHubHeroSubtitle": "Forecasts from the Metaculus community on contested races and the policies they will shape.",
+ "midtermsHubHeroSubtitleMobile": "Real-time forecasts from the Metaculus community.",
+ "midtermsHubConsequencesSubtitle": "How forecasted outcomes shift depending on which party holds Congress.",
+ "midtermsHubUpdatedRealtime": "Updated in real time",
+ "midtermsHubMetaculusUser": "Metaculus User",
+ "midtermsHubComingSoon": "Coming Soon",
+ "midtermsHubClickToView": "Click to view question",
+ "midtermsHubSeatDistributionsTitle": "Seat Distributions",
+ "midtermsHubSeatDistributionsSubtitle": "The community's forecasts for the final seat advantage in each chamber.",
+ "midtermsHubSenateSeats": "Senate",
+ "midtermsHubHouseSeats": "House",
+ "midtermsHubDemSeatAdvantage": "Democratic Seat Advantage",
+ "midtermsHubRepSeatAdvantage": "Republican Seat Advantage",
+ "midtermsHubEven": "EVEN",
+ "midtermsHubForecastUnavailable": "Forecast unavailable",
+ "midtermsHubSeatAdvantageTooltip": "{count} seat advantage",
+ "midtermsHubSeatAdvantageOverTooltip": ">{count} seat advantage",
+ "midtermsHubProbabilityTooltip": "{value}% probability",
+ "midtermsHubEvenTooltip": "Even"
}
diff --git a/front_end/src/app/(main)/questions/components/notebook_form.tsx b/front_end/src/app/(main)/questions/components/notebook_form.tsx
index 9d3993ccf6..5f55597542 100644
--- a/front_end/src/app/(main)/questions/components/notebook_form.tsx
+++ b/front_end/src/app/(main)/questions/components/notebook_form.tsx
@@ -33,36 +33,100 @@ import {
getQuestionDraft,
saveQuestionDraft,
} from "@/utils/drafts/questionForm";
+import { getMarkdownSummary } from "@/utils/markdown";
import { getPostLink } from "@/utils/navigation";
import BacktoCreate from "./back_to_create";
import CategoryPicker from "./category_picker";
import { createQuestionPost, updatePost } from "../actions";
+// Run the same strip-markdown pipeline the feed tile uses (getMarkdownSummary)
+// to scan exactly what a reader would see. Sized to the widest realistic tile
+// (~900px → ~450 rendered chars); anything past that point is past every tile
+// size and doesn't need a summary. MAX_SOURCE_CHARS_TO_SCAN bounds the remark
+// parse cost on long notebooks — 2000 source chars maps to well over the
+// rendered budget even with markdown-heavy input. TILE_VISIBLE_SOURCE_CHARS
+// is a tighter window used for raw-source checks (lists, blockquotes, code
+// fences): their markers are stripped before render, so we can't see them in
+// the preview output and instead approximate "could appear on the tile" by
+// scanning the first ~600 source chars.
+const TILE_PREVIEW_WIDTH = 900;
+const TILE_PREVIEW_HEIGHT = 80;
+const MAX_SOURCE_CHARS_TO_SCAN = 2000;
+const TILE_VISIBLE_SOURCE_CHARS = 600;
+
+// Patterns whose presence in the *rendered* preview surfaces visibly on the
+// tile. strip-markdown leaves these in: raw HTML tags (iframes, divs) and
+// pipes from table-like content MDXEditor didn't promote to a real table.
+// Plain text and `[text](url)` link syntax are accepted: links re-render as
+// real tags via the tile's read-mode MarkdownEditor.
+const NOISY_RENDERED_PATTERNS: RegExp[] = [
+ /<[a-zA-Z][^>]*>/, // raw HTML
+ /\|[^|\n]*\|[^|\n]*\|/, // 3+ pipes on a single line (table-ish)
+];
+
+// Patterns whose markers strip-markdown removes, but whose visual semantics
+// (bullets, numbering, indentation, monospace) are part of the author's
+// intent. Without them the tile shows the content as run-on prose, which
+// silently strips meaning rather than displaying syntax. Checked against the
+// raw source within the tile-visible window. The `{0,3}` allowance matches
+// CommonMark's leading-space tolerance for these block constructs.
+const NOISY_RAW_SOURCE_PATTERNS: RegExp[] = [
+ /^[ \t]{0,3}[-*+][ \t]+\S/m, // bullet list item
+ /^[ \t]{0,3}\d+[.)][ \t]+\S/m, // ordered list item
+ /^[ \t]{0,3}>[ \t]?/m, // blockquote
+ /^[ \t]{0,3}```/m, // fenced code block
+];
+
+const hasNoisyTilePreview = (markdown: string | undefined): boolean => {
+ if (!markdown) return false;
+ const rawSource = markdown.slice(0, TILE_VISIBLE_SOURCE_CHARS);
+ if (NOISY_RAW_SOURCE_PATTERNS.some((pattern) => pattern.test(rawSource))) {
+ return true;
+ }
+ const preview = getMarkdownSummary({
+ markdown: markdown.slice(0, MAX_SOURCE_CHARS_TO_SCAN),
+ width: TILE_PREVIEW_WIDTH,
+ height: TILE_PREVIEW_HEIGHT,
+ });
+ return NOISY_RENDERED_PATTERNS.some((pattern) => pattern.test(preview));
+};
+
const createNotebookSchema = (t: ReturnType) => {
- return z.object({
- title: z
- .string()
- .min(4, {
- message: t("errorMinLength", { field: "String", minLength: 4 }),
- })
- .max(200, {
- message: t("errorMaxLength", { field: "String", maxLength: 200 }),
- }),
- short_title: z
- .string()
- .min(4, {
- message: t("errorMinLength", { field: "String", minLength: 4 }),
- })
- .max(80, {
- message: t("errorMaxLength", { field: "String", maxLength: 80 }),
+ return z
+ .object({
+ title: z
+ .string()
+ .min(4, {
+ message: t("errorMinLength", { field: "String", minLength: 4 }),
+ })
+ .max(200, {
+ message: t("errorMaxLength", { field: "String", maxLength: 200 }),
+ }),
+ short_title: z
+ .string()
+ .min(4, {
+ message: t("errorMinLength", { field: "String", minLength: 4 }),
+ })
+ .max(80, {
+ message: t("errorMaxLength", { field: "String", maxLength: 80 }),
+ }),
+ default_project: z.number(),
+ markdown: z.string().min(1, {
+ message: t("errorMinLength", { field: "String", minLength: 1 }),
}),
- default_project: z.number(),
- markdown: z.string().min(1, {
- message: t("errorMinLength", { field: "String", minLength: 1 }),
- }),
- feed_tile_summary: z.string().optional(),
- });
+ feed_tile_summary: z.string().optional(),
+ })
+ .superRefine((data, ctx) => {
+ const summary = data.feed_tile_summary?.trim() ?? "";
+ if (summary.length === 0 && hasNoisyTilePreview(data.markdown)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: t("feedTileSummaryRequiredForFormattedContent"),
+ path: ["feed_tile_summary"],
+ });
+ }
+ });
};
type FormData = z.infer>;