diff --git a/blog/.env.example b/blog/.env.example index 5b806721b..030d5872b 100644 --- a/blog/.env.example +++ b/blog/.env.example @@ -5,5 +5,11 @@ # Main Doenet application URL # Local development: http://localhost:3000 -# Production: https://doenet.org -PUBLIC_DOENET_MAIN_URL=https://doenet.org +# Production: https://beta.doenet.org +PUBLIC_DOENET_MAIN_URL=https://beta.doenet.org + +# Newsletter generator configuration (server/build only) +NEWSLETTER_ISSUE_DATE=2026-02-18 +NEWSLETTER_EXCERPT_MARKER=more +NEWSLETTER_MANUAL_ORDER=["simplify-creating-interactive-activities","learn-doenet-saint-louis-university","community-challenge-fractions"] +NEWSLETTER_EVENTS_URL=https://beta.doenet.org/events diff --git a/blog/.gitignore b/blog/.gitignore index 5975753da..8f475d9c2 100644 --- a/blog/.gitignore +++ b/blog/.gitignore @@ -18,8 +18,12 @@ pnpm-debug.log* .env.local .env.production +# newsletter content (untracked for easy editing) +newsletter-events.md + # macOS-specific files .DS_Store # jetbrains setting folder .idea/ + diff --git a/blog/package.json b/blog/package.json index 9375ece5e..89c9ba1d8 100644 --- a/blog/package.json +++ b/blog/package.json @@ -19,10 +19,11 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^5.17.1", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", + "marked": "^17.0.3", "react": "^19.2.3", "react-dom": "^19.2.3", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "sharp": "^0.34.3" } } diff --git a/blog/src/content/community-challenge-fractions.md b/blog/src/content/community-challenge-fractions.mdx similarity index 68% rename from blog/src/content/community-challenge-fractions.md rename to blog/src/content/community-challenge-fractions.mdx index 54728503e..bf979e120 100644 --- a/blog/src/content/community-challenge-fractions.md +++ b/blog/src/content/community-challenge-fractions.mdx @@ -9,4 +9,8 @@ heroImage: "/blog/images/community-challenge-fractions/fractions-snapshot.png" Challenger: Anurag Katyal is an avid Doenet user, who enjoys creating problems for other people. -Understanding fractions is a common issue in building number sense for students. What does a fraction mean? How do we add fractions, and why does it work? How do we compare fractions? We’d love to have more activities to help our students build conceptual understanding of fractions! To participate in this challenge, create a public activity at beta.doenet.org and submit a link to your activity to communications@doenet.org. The winner of this challenge will have their activity featured in the next Doenet newsletter! +Understanding fractions is a common issue in building number sense for students. What does a fraction mean? How do we add fractions, and why does it work? How do we compare fractions? We’d love to have more activities to help our students build conceptual understanding of fractions! + +
+ +To participate in this challenge, create a public activity at beta.doenet.org and submit a link to your activity to communications@doenet.org. The winner of this challenge will have their activity featured in the next Doenet newsletter! diff --git a/blog/src/content/learn-doenet-saint-louis-university.mdx b/blog/src/content/learn-doenet-saint-louis-university.mdx index aeb39462e..9602d540d 100644 --- a/blog/src/content/learn-doenet-saint-louis-university.mdx +++ b/blog/src/content/learn-doenet-saint-louis-university.mdx @@ -13,6 +13,8 @@ During our November 7-8 Doenet workshop, participants built classroom-ready acti The hands-on format allowed participants to focus deeply on their specific course needs, with individualized support from facilitators throughout the development process. Each participant completed a polished activity ready for immediate classroom use, with several planning to deploy their work as early as the following semester. +
+ The final show-and-tell session showcased what participants were able to accomplish in just two days. One of the examples worth mentioning is the interactive Mean Value Theorem themed activity featuring dynamic graphs that respond to student input, built-in answer checking, and a carefully designed set of problems, designed by Daniel Kang. Students working through this activity can explore the theorem visually while receiving immediate feedback on their responses, transforming what's traditionally a challenging calculus concept into an engaging, interactive experience. ![Mean Value Theorem activity](/blog/images/learn-doenet-saint-louis-university/mean-value-theorem.png) diff --git a/blog/src/content/simplify-creating-interactive-activities.mdx b/blog/src/content/simplify-creating-interactive-activities.mdx index e802d43d6..6e22db817 100644 --- a/blog/src/content/simplify-creating-interactive-activities.mdx +++ b/blog/src/content/simplify-creating-interactive-activities.mdx @@ -24,6 +24,8 @@ Here's a snippet from one of these activities where students discover how to ske Despite how well my students respond to interactive exploratory activities, one thing I have discovered after writing hundreds is that **making them is a huge pain!** For the past couple decades, I've been working on ways to ease that pain. +
+ When I started to experiment in 2005 with creating my own interactives, I was teaching multivariable calc, so my efforts mostly took the form of 3D applets. [Here’s an example](https://mathinsight.org/directional_derivative_gradient_introduction) from 2011 about directional derivatives and gradients. ![A surface plot of a function of x and y along with a contour plot of the same function. Both plots contain a movable point, an arrow representing the gradient, and a slider to set an angle between the gradient and another arrow. Information about the function, its gradient, and its directional derivative are displayed.](/blog/images/simplify-creating-interactive-activities/directional-derivative-gradients.png) diff --git a/blog/src/pages/generate-newsletter.astro b/blog/src/pages/generate-newsletter.astro new file mode 100644 index 000000000..c727881da --- /dev/null +++ b/blog/src/pages/generate-newsletter.astro @@ -0,0 +1,757 @@ +--- +import { getCollection, render } from "astro:content"; +import { newsletterStyles } from "../utils/newsletterStyles"; +import { newsletterConfig } from "../utils/newsletter-config"; +import fs from "node:fs"; +import path from "node:path"; +import { Marked } from "marked"; + +// Note: Helper functions (escapeRegExp, extractUntilMarker, cleanForEmail, applyEmailStyles) +// are inlined directly in the client-side script to avoid eval and CSP issues. + +// ============================================================================ +// Newsletter configuration is sourced from environment variables. +// See blog/.env.example for the available keys. +// ============================================================================ + +// ============================================================================ +// DATA FETCHING AND PROCESSING +// ============================================================================ + +// Fetch all blog posts +const allPosts = await getCollection("blog"); + +// Filter and sort posts by ordered slug list +const manualOrder = newsletterConfig.manualOrder; +const manualIndex = new Map( + manualOrder.map((slug, index) => [slug, index]), +); +const recentPosts = allPosts + .filter((post) => manualIndex.has(post.id.replace(/\.(md|mdx)$/, ""))) + .sort((a, b) => { + const slugA = a.id.replace(/\.(md|mdx)$/, ""); + const slugB = b.id.replace(/\.(md|mdx)$/, ""); + const indexA = manualIndex.get(slugA) ?? 0; + const indexB = manualIndex.get(slugB) ?? 0; + return indexA - indexB; + }); + +// 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, + }; + }), +); + +const siteUrl = Astro.site ? Astro.site.toString().replace(/\/$/, "") : ""; +const blogBasePath = import.meta.env.BASE_URL || "/"; + +// For newsletter images, always use the production URL so they work in emails +const productionUrl = "https://beta.doenet.org"; +const logoUrl = `${productionUrl}${blogBasePath}/Doenet_Logo_Frontpage_color_text.png`; + +const issueDateFormatted = newsletterConfig.issueDate.toLocaleDateString( + "en-US", + { + year: "numeric", + month: "short", + }, +); + +// Load events content from newsletter-events.md if it exists +let eventsContent = ""; +let eventsUrl = ""; +try { + const eventsFilePath = path.join(process.cwd(), "newsletter-events.md"); + if (fs.existsSync(eventsFilePath)) { + const eventsMarkdown = fs.readFileSync(eventsFilePath, "utf-8"); + const marked = new Marked(); + eventsContent = marked.parse(eventsMarkdown) as string; + } + eventsUrl = + newsletterConfig.eventsUrl || + `${siteUrl}${blogBasePath}`.replace(/\/$/, ""); +} catch (error) { + console.warn("Could not load newsletter-events.md:", error); +} +--- + + + + + + + Doenet {issueDateFormatted} Newsletter + + + + +
+

📧 Newsletter Generator

+

+ This page generates an email-friendly HTML newsletter from the posts + listed in the configuration. +

+ + + +
+ + +
+ + + + + + + + + + + +
+ + + + + +
+ + {issueDateFormatted} Newsletter + + + Doenet +
+
+ + + { + postsWithExcerpts.map(({ post, slug, Content, config }, index) => { + 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 */} +
+ + {/* Events Section after first post */} + {index === 0 && eventsContent && ( +
+ )} + + ); + }) + } + + + + + + +
+

+ Visit the Doenet Blog for more updates. Licensed under + + CC BY 4.0 + . Reply to unsubscribe. +

+
+
+ + + + + diff --git a/blog/src/utils/newsletter-config.ts b/blog/src/utils/newsletter-config.ts new file mode 100644 index 000000000..b9e5c68b6 --- /dev/null +++ b/blog/src/utils/newsletter-config.ts @@ -0,0 +1,47 @@ +type NewsletterConfig = { + issueDate: Date; + excerptMarker: string; + manualOrder: string[]; + eventsUrl: string; +}; + +const defaults: NewsletterConfig = { + issueDate: new Date(), + excerptMarker: "more", + manualOrder: [], + eventsUrl: "", +}; + +function parseManualOrder(rawValue: string | undefined): string[] { + if (!rawValue) return []; + + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) { + return parsed + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean); + } + } catch { + return []; + } + + return []; +} + +function parseIssueDate(rawValue: string | undefined): Date { + if (!rawValue) return defaults.issueDate; + const parsed = new Date(rawValue); + if (Number.isNaN(parsed.getTime())) return defaults.issueDate; + return parsed; +} + +const env = import.meta.env; + +export const newsletterConfig: NewsletterConfig = { + issueDate: parseIssueDate(env.NEWSLETTER_ISSUE_DATE), + excerptMarker: env.NEWSLETTER_EXCERPT_MARKER || defaults.excerptMarker, + manualOrder: parseManualOrder(env.NEWSLETTER_MANUAL_ORDER), + eventsUrl: env.NEWSLETTER_EVENTS_URL || defaults.eventsUrl, +}; diff --git a/blog/src/utils/newsletterStyles.ts b/blog/src/utils/newsletterStyles.ts new file mode 100644 index 000000000..b5877ad19 --- /dev/null +++ b/blog/src/utils/newsletterStyles.ts @@ -0,0 +1,94 @@ +/** + * Inline style strings for dynamically generated newsletter HTML + * (Used in generate-newsletter.astro for client-side HTML generation) + */ + +export const newsletterStyles = { + // Top-level newsletter layout styles + newsletterTable: + "width: 600px; margin: 0 auto; border-collapse: collapse; font-family: sans-serif;", + newsletterHeaderCell: + "padding: 27px 18px 2px 18px; background: #ffffff; border-bottom: 3px solid #0066cc;", + newsletterHeaderTable: "width: 100%; border-collapse: collapse;", + newsletterHeaderLeftCell: + "text-align: left; vertical-align: bottom; font-size: 22px; color: #666; font-family: sans-serif; padding-bottom: 10px;", + newsletterHeaderRightCell: "text-align: right; vertical-align: bottom;", + newsletterLogoImage: + "height: 50px; width: auto; display: inline-block; margin: 0 0 0 0;", + newsletterSpacerCell: "height: 54px;", + newsletterFooterTable: + "width: 600px; margin: 36px auto 0 auto; border-collapse: collapse; font-family: sans-serif;", + newsletterFooterCell: + "padding: 27px 18px; text-align: center; background: #f8f9fa; border-top: 1px solid #e0e0e0;", + newsletterFooterText: + "margin: 0; font-size: 15px; color: #666; font-family: sans-serif;", + newsletterFooterLink: "color: #0066cc; text-decoration: underline;", + + // Download/copy wrapper styles + wrapperBody: + "margin: 0; padding: 0; font-family: sans-serif; font-size: 18px; background-color: #f5f5f5;", + wrapperOuterTable: "margin: 0; padding: 0;", + wrapperOuterCell: "padding: 40px 0;", + wrapperInnerTable: "width: 600px; background-color: #ffffff;", + + // Table and cell styles + table: + "width: 600px; margin: 0 auto 54px auto; border-collapse: collapse; table-layout: fixed;", + headerCell: + "padding: 36px 22px 14px 22px; background: #ffffff; border: none; border-radius: 0; word-break: break-word; overflow-wrap: anywhere;", + headerInnerTable: + "width: 100%; border-collapse: collapse; vertical-align: middle;", + headerImageCell: "width: 150px; padding: 0 18px 0 0; vertical-align: middle;", + headerContentCell: "vertical-align: middle; padding: 0;", + cell: "padding: 10px 22px; background: #ffffff; border: none; border-radius: 0; word-break: break-word; overflow-wrap: anywhere;", + separator: + "width: 600px; margin: 0 auto 36px auto; border-collapse: collapse;", + separatorCell: "height: 2px; background: #c0c0c0;", + heroImage: "width: 150px; height: auto; display: block; border-radius: 4px;", + + // Text styles + title: + "margin: 0 0 5px 0; font-size: 28px; line-height: 1.3; color: #1a1a1a; font-family: sans-serif;", + titleLink: "color: #1a1a1a; text-decoration: none; font-size: 28px;", + meta: "margin: 0 0 0px 0; font-size: 16px; color: #666; font-style: italic; font-family: sans-serif;", + description: + "margin: 0 0 18px 0; font-size: 18px; line-height: 1.6; color: #555; font-family: sans-serif;", + body: "margin: 0 0 18px 0; font-family: sans-serif; font-size: 18px; line-height: 1.6; color: #333; word-break: break-word; overflow-wrap: anywhere;", + + // Excerpt element styles + paragraph: + "margin: 0 0 18px 0; line-height: 1.6; color: #333; font-size: 18px;", + heading1: + "margin: 27px 0 9px 0; line-height: 1.3; color: #1a1a1a; font-size: 32px;", + heading2: + "margin: 27px 0 9px 0; line-height: 1.3; color: #1a1a1a; font-size: 28px;", + heading3: + "margin: 27px 0 9px 0; line-height: 1.3; color: #1a1a1a; font-size: 24px;", + heading4: + "margin: 27px 0 9px 0; line-height: 1.3; color: #1a1a1a; font-size: 20px;", + link: "color: #0066cc; text-decoration: underline; font-size: 18px;", + list: "margin: 0 0 18px 0; padding-left: 36px; font-size: 18px;", + listItem: "margin: 0 0 9px 0; line-height: 1.6; font-size: 18px;", + codeBlock: + "background: #f5f5f5; padding: 18px; border-radius: 4px; overflow-x: auto; margin: 18px 0; font-size: 16px;", + codeInline: + "background: #f5f5f5; padding: 4px 7px; border-radius: 3px; font-family: monospace; font-size: 16px;", + blockquote: + "border-left: 4px solid #ddd; padding-left: 18px; margin: 18px 0; color: #666; font-size: 18px;", + image: "max-width: 100%; height: auto; display: block; margin: 18px 0;", + + // Button styles + buttonWrap: "margin: 27px 0 0 0; font-family: sans-serif; font-size: 18px;", + button: + "display: inline-block; padding: 11px 22px; background: #0066cc; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 500; font-size: 18px;", + + // Events section styles + eventsSection: + "width: 600px; margin: 0 auto 54px auto; border-collapse: collapse; table-layout: fixed; background: #f8f9fa;", + eventsHeader: "padding: 18px 22px; background: #0066cc; color: #ffffff;", + eventsTitle: + "margin: 0; font-size: 24px; line-height: 1.3; color: #ffffff; font-family: sans-serif; font-weight: bold;", + eventsContent: "padding: 36px 22px;", + eventsBody: + "margin: 0 0 18px 0; font-family: sans-serif; font-size: 18px; line-height: 1.6; color: #333;", +}; diff --git a/package-lock.json b/package-lock.json index b16508f80..12a33698d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "astro": "^5.17.1", + "marked": "^17.0.3", "react": "^19.2.3", "react-dom": "^19.2.3", "rehype-katex": "^7.0.1", @@ -17237,6 +17238,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz",