Source for data.dailycal.org: investigative stories, data analysis and graphics by The Daily Californian's Data Team.
Requirements: Node >=22.23.4.
| Command | Action |
|---|---|
npm install |
Install dependencies |
npm run dev |
Start the dev server at http://localhost:4321 (runs in the background; stop it with npx astro dev stop) |
npm run build |
Build the static site to dist/, saving its output as a page at /editors/build |
npm run preview |
Serve the built dist/ locally, to check the real production output |
npm run lint |
Check code style with ESLint and Prettier |
npm run lint:fix |
Fix ESLint and Prettier style errors |
npm run optimize -- <image> [<image> ...] |
Optimize images (see Optimizing images) |
npm run deploy |
Build and deploy the site to Cloudflare (see Deploying) |
Always run npm run build and npm run lint before pushing. GitHub Actions also runs both on every commit and pull request (.github/workflows/ci.yml). The build fails on content problems (a misspelled frontmatter field, a badly named file, etc.), and says exactly what to fix.
Deploying: the site is entirely static, with nothing running on a server. It's hosted on Cloudflare as a Worker named data that only serves static assets (configured in wrangler.jsonc), so hosting it costs nothing. npm run deploy builds the site and uploads dist/ with wrangler deploy (run npx wrangler login first).
Redirects are real HTTP 301s, listed in public/_redirects, which Cloudflare reads.
Previews: every pull request from a branch in this repository gets its own copy of the site at https://pr<number>.datadev.dailycal.org (e.g. pr131.datadev.dailycal.org), updated on every push and deleted when the pull request is closed or merged (.github/workflows/preview.yml). The workflow comments the address on the pull request. Previews are Cloudflare Workers Previews of the data Worker, so they're served exactly like the real site, redirects included, but kept out of search engines. Pull requests from forks aren't previewed, since GitHub doesn't give them the repository's secrets; push the branch to this repository instead. To preview a branch from your own machine: npm run build && npx wrangler preview --name <name>, which serves it at https://<name>.datadev.dailycal.org.
Previews need a one-time setup: in Cloudflare, the data Worker's Domains tab has the custom domain datadev.dailycal.org, enabled for Preview only; and the GitHub repository has the secrets CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (an API token that can edit Workers Scripts).
The site is always served from the root of its domain, so internal links are plain paths like /article/....
src/
content/articles/ One .mdx file per article, in a folder per year (e.g. 2026/)
content/authors/ One .mdx file per author (see "Authors and the team")
assets/images/ Every article's images, in a folder per article named after its short slug
(its file name without the date), in a folder for its year (e.g. 2026/nobel/)
assets/authors/ Authors' pictures, named after their .mdx files
assets/site/ Images that aren't an article's: the logos, and /apply's puzzle hint
components/ Reusable components (charts, images, links, SEO)
config/ Site-wide settings (see "Site configuration")
layouts/ PageLayout (header/footer/<head>) and the article layouts
lib/ Helpers: article loading/validation, the search index, build plugins
pages/ Every page and its URL (file-based routing); pages/interactive/ holds the
interactives built here (see "Interactives")
styles/ Global and article CSS
visuals/ Articles' visuals (React components), one folder per project, in a folder for
its article's year (e.g. 2026/nobel-visuals/; see "Visuals")
scripts/ optimize-image.mjs: the command-line image optimizer
public/ Static files served as-is: the favicon, fonts, the data files visuals fetch
(data/<short slug>/) and interactives built elsewhere (interactive/<name>/)
| URL | Page |
|---|---|
/ |
Homepage: every article, newest first |
/article/<yyyy-mm-dd>-<slug> |
An article (pages have no trailing slash; /article/<slug>/ redirects to it) |
/<yyyy-mm-dd>-<slug>/ |
301 redirect to /article/<yyyy-mm-dd>-<slug> (the old site's article URLs) |
/interactive/<name> |
An interactive on a page of its own (see "Interactives") |
/search?q=<query> |
Search (see "Search") |
/team |
The Data Team now (see "Authors and the team") |
/team/past |
Everyone else with a byline or an author file, by their latest story, grouped by its year |
/team/<firstname-lastname> |
An author's page: their picture, role, links, bio and stories |
/apply |
Where the Data Department's recruiting flyer points: the application, and a form for the flyer's puzzle (hidden from search engines) |
/editors/image-optimization |
In-browser image optimizer for editors (hidden from search engines) |
/sitemap-index.xml, /robots.txt |
Generated at build time from site in astro.config.mjs. Don't add a public/robots.txt |
Every article is one .mdx file (frontmatter + body) in the folder for its year in src/content/articles/, named <yyyy-mm-dd>-<slug>.mdx, e.g. src/content/articles/2026/2026-03-03-ophdreport.mdx:
- The date should be the article's publish date (its
datefrontmatter), and the folder is its year. - The slug must be lowercase words and numbers joined by hyphens.
- It's served at
/article/<yyyy-mm-dd>-<slug>, so don't rename it once it's published.
The build fails if a file name isn't in this form or the file isn't in the folder for its year, listing every bad file. Because articles are a folder deeper than other files in src/content/, paths in them start with ../../../ (e.g. "../../../assets/images/2026/my-story/cover.jpg").
Unknown fields fail the build, so a misspelled field can't be silently ignored.
| Field | Description | Required |
|---|---|---|
date |
Publish date, "YYYY-MM-DD". Determines the order on the homepage. |
Yes |
title |
The headline. | Yes |
byline |
Author names, e.g. ["Jane Doe", "John Smith"]. At least one. Each links to the author's page at /team/<firstname-lastname>, so spell a name the same way in every article (the build fails if it isn't; see Authors and the team). |
Yes, unless the article has a link |
featuredImage |
The featured (hero) image: its path relative to the article, e.g. "../../../assets/images/2026/my-story/cover.jpg". Also used on homepage cards and in social previews. |
Yes |
featuredImageAlt |
Alt text describing the featured image, for screen readers and social previews. Defaults to Featured image for "<title>", so please set it. |
No |
description |
Short summary for search engines' results and social previews, also searched by the site's own search. Defaults to subhead, then the start of the article's first paragraph (then the site description, for an article with no prose). |
No |
keywords |
["keyword one", "keyword two"]. Populates <meta name="keywords">, and is searched by the site's search. |
No |
layout |
true (the default) renders the article in the article layout. false renders the article's body alone as the whole page, for an article that's one full-page visual. |
No |
link |
Where the article's homepage card goes instead of its own page: an interactive (e.g. "/interactive/election-2026/index.html", which must exist or the build fails; link a folder by its index.html, since the dev server doesn't serve folders) or a URL on another site. An article with a link gets no page of its own, so it needs only date, title and featuredImage, and its body isn't shown. |
No |
These are only used by the article layout (layout: true):
| Field | Description |
|---|---|
subhead |
A short subheading under the title. |
imageAttribution |
Credit for the featured image, e.g. "Jane Doe | Staff". |
hideHeroImage |
true hides the featured image on the article page itself (it still shows on cards and in social previews). Use this when the body opens with its own image or graphic. Default false. |
secondaryFeaturedImage |
An image to show at the top of the article page instead of featuredImage (which is still used for cards and social previews). |
imageCaption |
Caption shown under the featured image. |
aboutStory |
Extra text for the "About this Story" box at the end, e.g. where the data came from. |
jointDept |
Another department's name, e.g. "News", for an article made jointly with the Data Department. |
---
date: "2026-05-01"
title: "My Headline"
subhead: "A short dek explaining the piece"
description: "One or two sentences for search results and social previews."
byline: ["Jane Doe", "John Smith"]
featuredImage: "../../../assets/images/2026/my-story/cover.jpg"
featuredImageAlt: "Students walk past Sather Gate"
imageAttribution: "Jane Doe | Staff"
aboutStory: "Data for this story came from the UC Office of the President."
---
import DatawrapperChart from "../../../components/DatawrapperChart.jsx";
import ArticleImage from "../../../components/ArticleImage.astro";
import protest from "../../../assets/images/2026/my-story/protest.jpg";
The body is regular Markdown/MDX. Paragraphs, **bold**, _italics_ and [links](https://dailycal.org) all work.
<DatawrapperChart client:load chartId="MvImD/7/" />
<ArticleImage src={protest} alt="Students march on Sproul Plaza" caption="Students march on Sproul Plaza." attribution="Jane Doe | Staff" />- Visuals in
src/visuals/: see Visuals. <DatawrapperChart>(src/components/DatawrapperChart.jsx): embeds a Datawrapper chart by its ID, resizing to fit the chart. It needsclient:load.<ArticleImage>(src/components/ArticleImage.astro): an image with an optionalcaptionandattribution. Or a group of images side by side, sharing one caption:<ArticleImage images={[{ src: a, alt: "..." }, { src: b, alt: "..." }]} columns={2} caption="..." />. Groups stack into one column on phones.srcis an imported image (see Adding images).
Every image goes in src/assets/images/<yyyy>/<short slug>/, where the year is the article's and the short slug is its file name without the date (e.g. src/assets/images/2026/my-story/ for 2026-05-01-my-story.mdx), including images its visuals use. Authors' pictures are in src/assets/authors/ (see Authors and the team), and other images that aren't an article's, like the logos, are in src/assets/site/. Optimize it first, then:
-
featuredImage(andsecondaryFeaturedImage) in frontmatter: its path relative to the article,featuredImage: "../../../assets/images/2026/my-story/cover.jpg", and -
inline images: import the image at the top of the article, then pass it to
<ArticleImage>, or to Astro's<Image>(import { Image } from "astro:assets") for an image without a caption:import protest from "../../../assets/images/2026/my-story/protest.jpg"; <ArticleImage src={protest} alt="Students march on Sproul Plaza" />
At build time, Astro resizes each image to the sizes it's shown at and converts it to WebP (and social previews to JPEG/PNG), so readers download a fraction of the file. Optimizing images before they're committed still matters: it keeps the repository small and the build fast, since every image is processed on every build. The build fails if an article refers to an image that doesn't exist, or links to a file in public/data/ or an interactive that doesn't exist.
The only images outside src/assets/ are the ones apps built elsewhere load by URL at run time (the 2026 election results in public/interactive/ and the rent-o-meter's in public/data/), and public/favicon.ico.
Everyone in an article's byline gets a page at /team/<firstname-lastname> (e.g. "Ji Yeon Woo" is /team/ji-yeon-woo) listing their stories, newest first, and a link to their Daily Californian profile. Bylines link there. "Daily Cal Staff" and "Editorial Board" credit groups, so they don't link anywhere (GROUP_BYLINES in src/lib/authors.ts).
To give someone a picture, a role and a bio, add src/content/authors/<firstname-lastname>.mdx, named after the name as it's spelled in bylines. The body is the bio:
---
name: "Ji Yeon Woo"
role: "Deputy Data Editor"
joined: "Fall 2024"
image: "../../assets/authors/ji-yeon-woo.jpg"
email: "jwoo@dailycal.org"
links:
LinkedIn: "https://www.linkedin.com/in/..."
---
Ji Yeon covers campus finances and housing...| Field | Description | Required |
|---|---|---|
name |
Their name, spelled as in their bylines. | Yes |
role |
Shown on their card and page, whether or not they're on the team now, e.g. "Data Reporter". At most 40 characters. |
Yes |
joined |
The semester they joined: "Spring 2025", "Summer 2025" or "Fall 2025". |
Yes |
image |
A square picture in src/assets/authors/, named after their file: "../../assets/authors/ji-yeon-woo.jpg" (optimize it first). Without one, their initials are shown. |
No |
email |
Their email address, linked as "Email" on their page. | No |
links |
Links shown on their page, by label, e.g. LinkedIn: "https://..." (http(s) URLs). Their Daily Californian profile is added unless one of these is on dailycal.org. |
No |
An author file is optional, for team members too: without one, their page has their name, their stories and their Daily Californian profile link.
Who's on the team now is src/config/team.json, by name, spelled as in their bylines. /team shows them in this order, each role by last name (reporters with a byline first, then those without one); everyone else is on /team/past, grouped by the year of their latest story:
{
"editor": "Jane Doe",
"deputyEditors": ["Ji Yeon Woo"],
"reporters": ["John Smith", "Alex Lee"]
}The build fails, listing every problem, if:
- an author file's frontmatter is missing a field or has one that's misspelled or invalid,
- an author file isn't named after the
namein it, or its picture isn't square or isn't named after it, - two bylines, author files or
team.jsonentries spell the same name differently, e.g. "Ian Macgregor" and "Ian MacGregor", team.jsondoesn't have oneeditorand one to threedeputyEditors, or lists someone twice.
Source photos straight off a camera or phone are typically 10-25MB, which is a lot to store in the repository (and to process on every build) for something that renders at a few hundred pixels wide. Before committing an image, convert it to a web-optimized JPEG:
- it's scaled down so its long edge is at most 2560px (never scaled up), and
- it's compressed with mozjpeg at quality 85, keeping full-resolution color (4:4:4) so colored edges in illustrations and text stay crisp.
An image with transparent areas (e.g. a logo, map icon or cutout drawn over something) becomes a WebP instead, at the same quality with its transparency kept, since JPEG has no transparency.
Either:
- In the browser: open data.dailycal.org/editors/image-optimization (or
http://localhost:4321/editors/image-optimizationwhilenpm run devis running) and choose an image. Your browser converts it on your own machine, without uploading it anywhere. The page shows the old and new resolution and file size; switch between Original and Optimized to compare the two, then download the optimized.jpg. It flattens transparent areas onto white, so use the command line for images that need their transparency. Use Chrome or Safari: Firefox ignores some embedded color profiles, which shifts colors slightly. HEIC (iPhone photos) and TIFF files only open in Safari. Or, - On the command line:
npm run optimize -- path/to/image.png [more images...]converts each image to a.jpg(or.webp, if it has transparent areas) next to it, and deletes the original (add--keep-originalto keep it). An image that's already optimized (re-encoding it would save less than 10%) is left as it is, so it's safe to run on a whole folder:find src/assets/images -type f ! -name '*.svg' -print0 | xargs -0 npm run optimize --.
Don't optimize SVGs: they're already small.
An article's interactive graphics are React components in src/visuals/<yyyy>/<project>/, one folder per project in the folder for its article's year, imported at the top of its .mdx file like any other component:
import BikeTheftMap from "../../../visuals/2023/theft-visuals/bikeTheftMap";
import "../../../styles/leaflet.css";
import "../../../visuals/2023/theft-visuals/BikeTheftMap.css";
<BikeTheftMap client:only="react" />Astro renders components to HTML at build time and doesn't send their JavaScript to the browser unless told to. Give a visual a directive if it needs to run in the browser:
client:load: for anything interactive (state, effects, event handlers, charts that measure the page, d3). It's rendered at build time and then made interactive as the page loads.client:only="react": for visuals that can't render outside a browser at all, e.g. Leaflet maps. Rendered only in the browser. Its CSS is imported by the.mdx, not by the visual (as above): Astro adds the CSS aclient:onlyvisual imports to every page that lists articles (the homepage,/team, every article), not just its own. A Leaflet map's article importssrc/styles/leaflet.css, which has Leaflet's CSS and the frame every map on the site has. Aclient:loadvisual can import its own CSS.- No directive: for visuals that are only HTML (an iframe, a static timeline), which then cost no JavaScript.
A few things to know when writing or porting a visual:
- JSX goes in
.jsx/.tsxfiles, not.js. - Props passed to a
client:visual must be plain data (strings, numbers, arrays, objects): a function can't be sent to the browser. - An
.mdxfile can read its own frontmatter asprops.frontmatter, e.g.<Story title={props.frontmatter.title} />.dateis"yyyy-mm-dd", and the byline is also available under the old site's names,bylineName/bylineUrl. - Images go in the article's
src/assets/images/<yyyy>/<short slug>/folder (see Adding images). A visual imports one for use as a URL (<img src={icon} />) with?url:import icon from "../../../assets/images/2026/my-story/icon.webp?url". Without it, Astro imports an object with the image's details. Visuals' images are served as they are, not resized at build time, so optimize them. - Environment variables: two AC Transit visuals read Gatsby-style
GATSBY_CARTO_KEY(a Carto basemap key) andGATSBY_PACK_BASE(where its data bundle is hosted) asprocess.env.*;astro.config.mjspasses them in from the build's environment. Both are optional. - Data files a visual imports go next to it, e.g.
import data from "./data/data.csv?url"(Vite fingerprints them, so browsers can cache them for good). Data an app fetches by URL at run time goes inpublic/data/<short slug>/, referenced by its site path, e.g.fetch("/data/my-story/data.csv").
Write a story's text in its .mdx and have the visual place it, instead of writing it in the component, so search can find it and editors can edit it without touching code. Pass each piece as a named slot:
<CandidateFinanceVisuals client:load>
<Fragment slot="intro">
First paragraph, in Markdown.
Second paragraph.
</Fragment>
<Fragment slot="end">Text on one line, with no paragraph around it.</Fragment>
</CandidateFinanceVisuals>The component gets each slot as a prop of the same name (here intro and end), already rendered to HTML, and puts it where the text goes: <div className="story-prose">{intro}</div>. It can't change the HTML, so style the paragraphs with CSS (.story-prose p { ... }). Leave the blank lines inside a <Fragment> around paragraphs, or they won't become paragraphs. Shiny-hunters, candidate and AC Transit work this way (AC Transit numbers its passages step1 to step25, and steps.js names each one's slot).
An interactive that needs a page of its own, instead of a spot in an article, lives at /interactive/<name>. There are two kinds:
- Built here: the code goes in
src/visuals/<yyyy>/<project>/like any visual, andsrc/pages/interactive/<name>.astrois a few lines that render it, only there to give it its URL (e.g.src/pages/interactive/gghenge.astro). Vite bundles and minifies it with the rest of the site. - Built elsewhere: an app with its own repository and build (e.g. the 2026 election results, from election-night-tracker) is copied in, as built, to
public/interactive/<name>/, and served as it is. Copy in a new build to update it; don't edit the files here.
An article can link to an interactive from its body ([Check out the interactive map](/interactive/gghenge)), or be nothing but a homepage card for it, with link set to its URL (see Frontmatter). Either way, the build fails if the interactive doesn't exist.
Until 2026 this site was built with Gatsby. Its articles' visuals were moved into src/visuals/ with as few changes as possible, so a few pieces of compatibility infrastructure keep that code working:
src/lib/compat/: stand-ins for APIs that no longer exist or changed, so the old code only needed its imports changed:mui-styles.js(Material UI's oldmakeStyles/withStyles) andmaterial-ui.jsx(Material UI with v4's default look, for the visuals written for v4). Gatsby'sLink/withPrefixbecame plain links, andreact-helmetbecame React 19's<meta>/<link>handling andreact-dom'spreload()/preinit().src/styles/legacy-typography.css: the old site's global base styles, which those visuals were designed on top of, scoped to article bodies and blank pages.src/lib/browser-only-packages.mjs: replaces Leaflet, react-leaflet and sigma with an inert stand-in when pages are rendered at build time, since they crash outside a browser even when only imported.src/lib/keep-d3-side-effects.mjs: keeps d3-transition in the bundle, sod3.select(...).transition()works.src/lib/unwrap-jsx-paragraphs.mjs: renders text inside JSX elements like<p>...</p>in.mdxthe way the old site did (without wrapping it in another paragraph).- Some charts set recharts props marked
// recharts 3: ...where recharts 3 changed a default, so they look the same as before. leaflet.heatstays on 0.2: the Clery story map extends its 0.2 internals, and 1.0 is a rewrite.
src/visuals/, the old Gatsby components in src/components/ and src/styles/ that some visuals use (ArticleFooter.jsx, ButtonList.jsx, customTheme.js, theme.js, button.module.css, darkStory.css), and the articles themselves are excluded from formatting (see .prettierignore and eslint.config.js), so they stay as they were written.
The old site also listed articles that were hosted elsewhere (their oldLink frontmatter). Those 30 now live on projects.dailycal.org, each at /<its file name>/, and their articles here are only homepage cards: each one's link goes there.
The old site served every article at /<file name>/, including the ones whose card links elsewhere, and served a few pages (/about/, the gghenge map, the election results) at other URLs. public/_redirects 301-redirects each of them straight to where it is now (/article/<file name>, or the article's link, e.g. its story on projects.dailycal.org), so links to them (e.g. from dailycal.org) and search results keep working. A new article never had an old URL, so it needs no redirect.
Cloudflare reads the rules in order, and ignores all but the first 100 that come after a rule with a *, so those go at the end.
/search?q=<query> searches every article's title, byline, subhead, description, keywords and text, in the browser, with Pagefind. npm run build builds the index into dist/pagefind/ at the end (src/lib/search-index.mjs), so search only works in the built site: in npm run dev the page says so. To try it, run npm run build and then npx wrangler dev.
- An article's text is what's written in its
.mdx(src/lib/search-text.ts): paragraphs, headings, lists and tables, including inside HTML elements and components (a scrollytelling component's steps). It never comes from the built page, so text that visuals draw (chart axes, legends, tooltips) isn't searched. Text written inside a visual's component isn't searched, so write a visual's story text in the.mdx. An article with no text of its own, like a tool or an interactive's card, is found by its title, byline,subhead,descriptionandkeywords. - The 30 articles hosted on projects.dailycal.org get their text from that site's search index (
https://projects.dailycal.org/search-index.json), downloaded during the build, and each is one result that links there. If it can't be downloaded, the build warns and they're searched by title, subhead and byline only.
Every page loads Google Tag Manager (container GTM-W9R66S6S), which loads Google Analytics; set up what's tracked in Tag Manager, not here. npm run build adds the loader to the top of each page's <head> once the site is built (src/lib/analytics.mjs), so it's in every page however its <head> is written, including interactives copied in prebuilt, and no layout or component needs it. It only runs on data.dailycal.org, so pull request previews, npx wrangler dev and npm run dev don't count as visits. It leaves out /editors/ and the election results' Instagram Live loop (EXCLUDE in that file).
An article's layout frontmatter picks how it's rendered:
-
layout: true(the default):src/layouts/ArticleLayout.astro, the site header and footer around the title, date, hero image, byline, body and "About this Story" box. -
layout: false:src/layouts/BlankArticleLayout.astro, the article's body alone as the whole page (with the article's SEO tags, but no header, title, byline or footer). The page itself doesn't scroll and the body fills the window, so the visual handles its own layout and scrolling. The body is usually just one component:--- title: "See how AC Transit ridership recovered from the pandemic" layout: false # ...the other required fields --- import AcTransitStory from "../../../visuals/2026/ac-transit-visuals/AcTransitStory"; <AcTransitStory client:load />
site.ts: the site name, default description, contact email, GitHub/donate links, and authors' Daily Californian profile URLs.team.json: who's on the team now (see Authors and the team).
- Footer links are the
footerLinksarray insrc/layouts/PageLayout.astro. <head>and SEO (title, description, canonical URL, Open Graph/X cards, JSON-LD) are rendered bysrc/components/Head.astrofrom the props each page passes toPageLayout: a page passes its name (title="Search"becomes "Search — The Daily Californian: Data"), and an article's layout passesarticleHead(article). The default social image is the Daily Cal logo; articles use their featured image.- Colors and fonts are CSS variables in
src/styles/global.css.
The Data Department can be contacted at projects@dailycal.org.