From 6dc8bffc5b0f1ca3f0500306fbf86845b41f2ebc Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Tue, 17 Feb 2026 14:51:35 -0600 Subject: [PATCH 01/16] New page generates newsletter --- blog/src/content/markdown-style-guide.md | 2 + blog/src/layouts/NewsletterPost.astro | 84 +++ blog/src/pages/generate-newsletter.astro | 659 +++++++++++++++++++++++ blog/src/utils/excerpt.ts | 124 +++++ 4 files changed, 869 insertions(+) create mode 100644 blog/src/layouts/NewsletterPost.astro create mode 100644 blog/src/pages/generate-newsletter.astro create mode 100644 blog/src/utils/excerpt.ts diff --git a/blog/src/content/markdown-style-guide.md b/blog/src/content/markdown-style-guide.md index 6b90474ee..520260430 100644 --- a/blog/src/content/markdown-style-guide.md +++ b/blog/src/content/markdown-style-guide.md @@ -29,6 +29,8 @@ The following HTML `

`—`

` elements represent six levels of section head Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat. + + Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat. ## Images diff --git a/blog/src/layouts/NewsletterPost.astro b/blog/src/layouts/NewsletterPost.astro new file mode 100644 index 000000000..4979232cc --- /dev/null +++ b/blog/src/layouts/NewsletterPost.astro @@ -0,0 +1,84 @@ +--- +import type { CollectionEntry } from "astro:content"; +import FormattedDate from "../components/FormattedDate.astro"; + +type Props = { + post: CollectionEntry<"blog">; + slug: string; + excerpt: string; +}; + +const { post, slug, excerpt } = Astro.props; +const { title, description, author, organization, pubDate } = post.data; + +// Generate the full URL to the post using site/base from Astro config +const basePath = import.meta.env.BASE_URL || "/"; +const normalizedBase = basePath.endsWith("/") ? basePath : `${basePath}/`; +const postPath = `${normalizedBase}${slug}/`; +const postUrl = Astro.site + ? new URL(postPath, Astro.site).toString() + : postPath; + +const styles = { + table: + "width: 100%; max-width: 600px; margin: 0 auto 2em auto; border-collapse: collapse;", + cell: "padding: 1.5em; background: #ffffff; border: 1px solid #e0e0e0; border-radius: 8px;", + title: + "margin: 0 0 0.5em 0; font-size: 1.5em; line-height: 1.3; color: #1a1a1a; font-family: sans-serif;", + meta: "margin: 0 0 1em 0; font-size: 0.9em; color: #666; font-style: italic; font-family: sans-serif;", + description: + "margin: 0 0 1em 0; font-size: 1em; line-height: 1.6; color: #555; font-weight: 500; font-family: sans-serif;", + body: "margin: 0 0 1em 0; font-family: sans-serif; font-size: 1em; line-height: 1.6; color: #333;", + buttonWrap: "margin: 1.5em 0 0 0; font-family: sans-serif;", + button: + "display: inline-block; padding: 0.6em 1.2em; background: #0066cc; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 500;", + separator: + "width: 100%; max-width: 600px; margin: 0 auto 2em auto; border-collapse: collapse;", + separatorCell: "height: 1px; background: #e0e0e0;", +}; +--- + + + + + + +
+ +

+ + {title} + +

+ + +

+ By {author}{organization ? ` (${organization})` : ""} • +

+ + + {description &&

{description}

} + + +
+ + + + + + +
diff --git a/blog/src/pages/generate-newsletter.astro b/blog/src/pages/generate-newsletter.astro new file mode 100644 index 000000000..151fb800b --- /dev/null +++ b/blog/src/pages/generate-newsletter.astro @@ -0,0 +1,659 @@ +--- +import { getCollection, render } from "astro:content"; +import { + extractUntilMarker, + cleanForEmail, + applyEmailStyles, +} from "../utils/excerpt"; + +// ============================================================================ +// NEWSLETTER CONFIGURATION +// ============================================================================ +// Adjust these values to control which posts appear and how much content +// from each post is included in the newsletter. + +const newsletterConfig = { + // Defaults that can be overridden on the page + default: { + // Only include posts published on or after this date + sinceDate: new Date("2024-01-01"), + + // Newsletter metadata + title: "Doenet Newsletter", + subtitle: "Recent updates from the Doenet blog", + }, + + // Issue date is not user-editable on the page + issueDate: new Date(), // Current date + + // Excerpt marker configuration + // Posts should include the marker to indicate the end of the clip. + // If the marker is missing, the full post content is used. + excerptMarker: "more", + + // Base URL configuration is derived from Astro config at runtime +}; + +// ============================================================================ +// DATA FETCHING AND PROCESSING +// ============================================================================ + +// Fetch all blog posts +const allPosts = await getCollection("blog"); + +// Sort all posts by publication date (newest first) +// We'll filter by date on the client side so users can adjust dynamically +const recentPosts = allPosts.sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), +); + +// Render posts and extract excerpts +const postsWithExcerpts = await Promise.all( + recentPosts.map(async (post) => { + const { Content } = await render(post); + // Use post.id as the slug (filename without extension) + const slug = post.id.replace(/\.(md|mdx)$/, ""); + const config = { untilMarker: newsletterConfig.excerptMarker }; + + return { + post, + slug, + Content, + config, + }; + }), +); + +// Count initially visible posts +const initiallyVisibleCount = postsWithExcerpts.filter( + ({ post }) => post.data.pubDate >= newsletterConfig.default.sinceDate, +).length; + +// Format the issue date +const issueDateFormatted = newsletterConfig.issueDate.toLocaleDateString( + "en-US", + { + year: "numeric", + month: "long", + day: "numeric", + }, +); + +const siteUrl = Astro.site ? Astro.site.toString().replace(/\/$/, "") : ""; +const blogBasePath = import.meta.env.BASE_URL || "/"; +const defaults = newsletterConfig.default; +--- + + + + + + + {defaults.title} - {issueDateFormatted} + + + + +
+

📧 Newsletter Generator

+

+ This page generates an email-friendly HTML newsletter from recent blog + posts. Configure which posts to include and how much content to show by + editing the configuration. +

+ +
+ + +
+ +
+ + +
+ +
+ + + + Posts published before this date will be hidden + +
+ + + +
+ Newsletter Stats:
+ • Posts included: {initiallyVisibleCount} of + {postsWithExcerpts.length} total
+ • Date range: Since {defaults.sinceDate.toLocaleDateString()}
+ • Issue date: {issueDateFormatted} +
+
+ + +
+ + + + + + + + + + + +
+

+ {defaults.title} +

+

+ {defaults.subtitle} +

+

+ {issueDateFormatted} +

+
+ + + { + postsWithExcerpts.map(({ post, slug, Content, config }) => { + const isInitiallyVisible = + post.data.pubDate >= defaults.sinceDate; + return ( + <> + {/* Render the full content in a hidden div so we can extract from it */} +
+ +
+ + {/* This div will be populated with the excerpt via client-side script */} +
+ + ); + }) + } + + + + + + +
+

+ Visit the Doenet Blog for more updates +

+

+ © {new Date().getFullYear()} Doenet. All rights reserved. +

+
+
+ + + + + diff --git a/blog/src/utils/excerpt.ts b/blog/src/utils/excerpt.ts new file mode 100644 index 000000000..cc9033291 --- /dev/null +++ b/blog/src/utils/excerpt.ts @@ -0,0 +1,124 @@ +/** + * Utility functions for extracting and processing excerpts from blog post content + */ + +/** + * Extract content up to an HTML comment marker (e.g., ) + */ +export function extractUntilMarker( + html: string, + marker: string = "more", +): string { + const markerPattern = new RegExp(``, "i"); + const match = html.match(markerPattern); + + if (match && match.index !== undefined) { + return html.substring(0, match.index).trim(); + } + + return html; +} + +/** + * Clean HTML for email compatibility + * - Remove script tags + * - Remove style tags (we'll use inline styles instead) + * - Remove interactive elements that won't work in email + */ +export function cleanForEmail(html: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // Remove scripts + doc.querySelectorAll("script").forEach((el) => el.remove()); + + // Remove style tags (we'll use inline styles) + doc.querySelectorAll("style").forEach((el) => el.remove()); + + // Remove interactive elements that won't work in email + doc + .querySelectorAll("button, input, textarea, select, form") + .forEach((el) => el.remove()); + + // Remove any React/MDX components that might have been rendered + doc.querySelectorAll("[data-astro-cid], [data-component]").forEach((el) => { + // Keep the element but remove the attributes + el.removeAttribute("data-astro-cid"); + el.removeAttribute("data-component"); + }); + + return doc.body.innerHTML; +} + +/** + * Apply inline styles for email compatibility + */ +export function applyEmailStyles(html: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // Style paragraphs + doc.querySelectorAll("p").forEach((p) => { + p.setAttribute( + "style", + "margin: 0 0 1em 0; line-height: 1.6; color: #333;", + ); + }); + + // Style headings + doc.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => { + h.setAttribute( + "style", + "margin: 1.5em 0 0.5em 0; line-height: 1.3; color: #1a1a1a;", + ); + }); + + // Style links + doc.querySelectorAll("a").forEach((a) => { + a.setAttribute("style", "color: #0066cc; text-decoration: underline;"); + }); + + // Style lists + doc.querySelectorAll("ul, ol").forEach((list) => { + list.setAttribute("style", "margin: 0 0 1em 0; padding-left: 2em;"); + }); + + doc.querySelectorAll("li").forEach((li) => { + li.setAttribute("style", "margin: 0 0 0.5em 0; line-height: 1.6;"); + }); + + // Style code blocks + doc.querySelectorAll("pre").forEach((pre) => { + pre.setAttribute( + "style", + "background: #f5f5f5; padding: 1em; border-radius: 4px; overflow-x: auto; margin: 1em 0;", + ); + }); + + doc.querySelectorAll("code").forEach((code) => { + if (code.parentElement?.tagName !== "PRE") { + code.setAttribute( + "style", + "background: #f5f5f5; padding: 0.2em 0.4em; border-radius: 3px; font-family: monospace;", + ); + } + }); + + // Style blockquotes + doc.querySelectorAll("blockquote").forEach((bq) => { + bq.setAttribute( + "style", + "border-left: 4px solid #ddd; padding-left: 1em; margin: 1em 0; color: #666;", + ); + }); + + // Style images + doc.querySelectorAll("img").forEach((img) => { + img.setAttribute( + "style", + "max-width: 100%; height: auto; display: block; margin: 1em 0;", + ); + }); + + return doc.body.innerHTML; +} From 1ef9172c15f8ca1b7131f6f931bea7bf00195d4b Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Tue, 17 Feb 2026 14:54:57 -0600 Subject: [PATCH 02/16] Fix license on footer. --- blog/src/pages/generate-newsletter.astro | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/blog/src/pages/generate-newsletter.astro b/blog/src/pages/generate-newsletter.astro index 151fb800b..87316873e 100644 --- a/blog/src/pages/generate-newsletter.astro +++ b/blog/src/pages/generate-newsletter.astro @@ -271,8 +271,7 @@ const defaults = newsletterConfig.default; { postsWithExcerpts.map(({ post, slug, Content, config }) => { - const isInitiallyVisible = - post.data.pubDate >= defaults.sinceDate; + const isInitiallyVisible = post.data.pubDate >= defaults.sinceDate; return ( <> {/* Render the full content in a hidden div so we can extract from it */} @@ -311,18 +310,21 @@ const defaults = newsletterConfig.default; style="padding: 1.5em 1em; text-align: center; background: #f8f9fa; border-top: 1px solid #e0e0e0;" >

Visit the Doenet Blog for more updates -

-

- © {new Date().getFullYear()} Doenet. All rights reserved. + > for more updates. Licensed under + + CC BY 4.0 + .

From 7217e0d5e12a5ad36ab2883c99f1f955bf3c3589 Mon Sep 17 00:00:00 2001 From: Charles Nykamp Date: Tue, 17 Feb 2026 16:21:38 -0600 Subject: [PATCH 03/16] Apply suggestions --- blog/src/layouts/NewsletterPost.astro | 84 ------ blog/src/pages/generate-newsletter.astro | 334 +++++++++++++++++------ blog/src/utils/excerpt.ts | 124 --------- blog/src/utils/newsletterStyles.ts | 30 ++ 4 files changed, 284 insertions(+), 288 deletions(-) delete mode 100644 blog/src/layouts/NewsletterPost.astro delete mode 100644 blog/src/utils/excerpt.ts create mode 100644 blog/src/utils/newsletterStyles.ts diff --git a/blog/src/layouts/NewsletterPost.astro b/blog/src/layouts/NewsletterPost.astro deleted file mode 100644 index 4979232cc..000000000 --- a/blog/src/layouts/NewsletterPost.astro +++ /dev/null @@ -1,84 +0,0 @@ ---- -import type { CollectionEntry } from "astro:content"; -import FormattedDate from "../components/FormattedDate.astro"; - -type Props = { - post: CollectionEntry<"blog">; - slug: string; - excerpt: string; -}; - -const { post, slug, excerpt } = Astro.props; -const { title, description, author, organization, pubDate } = post.data; - -// Generate the full URL to the post using site/base from Astro config -const basePath = import.meta.env.BASE_URL || "/"; -const normalizedBase = basePath.endsWith("/") ? basePath : `${basePath}/`; -const postPath = `${normalizedBase}${slug}/`; -const postUrl = Astro.site - ? new URL(postPath, Astro.site).toString() - : postPath; - -const styles = { - table: - "width: 100%; max-width: 600px; margin: 0 auto 2em auto; border-collapse: collapse;", - cell: "padding: 1.5em; background: #ffffff; border: 1px solid #e0e0e0; border-radius: 8px;", - title: - "margin: 0 0 0.5em 0; font-size: 1.5em; line-height: 1.3; color: #1a1a1a; font-family: sans-serif;", - meta: "margin: 0 0 1em 0; font-size: 0.9em; color: #666; font-style: italic; font-family: sans-serif;", - description: - "margin: 0 0 1em 0; font-size: 1em; line-height: 1.6; color: #555; font-weight: 500; font-family: sans-serif;", - body: "margin: 0 0 1em 0; font-family: sans-serif; font-size: 1em; line-height: 1.6; color: #333;", - buttonWrap: "margin: 1.5em 0 0 0; font-family: sans-serif;", - button: - "display: inline-block; padding: 0.6em 1.2em; background: #0066cc; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 500;", - separator: - "width: 100%; max-width: 600px; margin: 0 auto 2em auto; border-collapse: collapse;", - separatorCell: "height: 1px; background: #e0e0e0;", -}; ---- - - - - - - -
- -

- - {title} - -

- - -

- By {author}{organization ? ` (${organization})` : ""} • -

- - - {description &&

{description}

} - - -
- - - - - - -
diff --git a/blog/src/pages/generate-newsletter.astro b/blog/src/pages/generate-newsletter.astro index 87316873e..3dda29ec1 100644 --- a/blog/src/pages/generate-newsletter.astro +++ b/blog/src/pages/generate-newsletter.astro @@ -1,10 +1,9 @@ --- import { getCollection, render } from "astro:content"; -import { - extractUntilMarker, - cleanForEmail, - applyEmailStyles, -} from "../utils/excerpt"; +import { newsletterStyles } from "../utils/newsletterStyles"; + +// Note: Helper functions (escapeRegExp, extractUntilMarker, cleanForEmail, applyEmailStyles) +// are inlined directly in the client-side script to avoid eval and CSP issues. // ============================================================================ // NEWSLETTER CONFIGURATION @@ -69,15 +68,7 @@ const initiallyVisibleCount = postsWithExcerpts.filter( ({ post }) => post.data.pubDate >= newsletterConfig.default.sinceDate, ).length; -// Format the issue date -const issueDateFormatted = newsletterConfig.issueDate.toLocaleDateString( - "en-US", - { - year: "numeric", - month: "long", - day: "numeric", - }, -); +// Issue date will be computed client-side to always show current date const siteUrl = Astro.site ? Astro.site.toString().replace(/\/$/, "") : ""; const blogBasePath = import.meta.env.BASE_URL || "/"; @@ -89,7 +80,7 @@ const defaults = newsletterConfig.default; - {defaults.title} - {issueDateFormatted} + {defaults.title} - Loading...