From b3a59d41bdc5e7123baffbf363ce4d1c8ec6dd6d Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:14:01 -0400 Subject: [PATCH 1/4] chore(plans): open screen-gaps-phase3 (in-progress) Closes the placeholders at /pages/{mission,leadership, code-of-conduct,hackathons} per behaviors/app-shell.md. Adds the content directory + a client-side markdown route using `marked` (static pages aren't user content, so the no-client-markdown rule doesn't apply). The placeholder copy in each page calls itself out; porting the legacy laddr-site text is a content PR, not engineering. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/screen-gaps-phase3.md | 90 +++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 plans/screen-gaps-phase3.md diff --git a/plans/screen-gaps-phase3.md b/plans/screen-gaps-phase3.md new file mode 100644 index 0000000..68f0c6e --- /dev/null +++ b/plans/screen-gaps-phase3.md @@ -0,0 +1,90 @@ +--- +status: in-progress +depends: [screen-gaps-phase2] +specs: + - specs/behaviors/app-shell.md +issues: [83] +--- + +# Plan: screen-gaps phase 3 — static `/pages/:slug` content + +## Scope + +[#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) phase 3 — closes the `` placeholders on `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` per [behaviors/app-shell.md](../specs/behaviors/app-shell.md): + +> "The `/pages/*` URLs serve **static content pages** authored as MDX/Markdown in the code repo (`apps/web/src/content/pages/`). They have no per-page screen spec — the content is the spec." + +This plan ships the *plumbing* (content directory, markdown→HTML build-time renderer, the `/pages/:slug` route). The actual copy is **placeholder** that calls itself out — porting the legacy laddr-site text is a content task, not an engineering one. Filed as a follow-up. + +## Implements + +- [behaviors/app-shell.md](../specs/behaviors/app-shell.md) — `/pages/*` URL pattern + content authoring location. + +## Approach + +### 1. Dependency + +Add `marked` to `apps/web` for client-side markdown rendering. Choice rationale: + +- Static pages are **not user content** — the CLAUDE.md "no client markdown" rule explicitly applies to user-supplied content (bios, project overviews, blog bodies). Build-time-static content is essentially JSX. +- `marked` is tiny (~30 KB min+gz) and battle-tested. +- The alternative — using `@cfp/shared`'s `renderMarkdown` server-side via a build-time Vite plugin — adds more tooling than the v1 needs. + +No DOMPurify dance: zero XSS surface on content that lives in the bundle. + +### 2. Content files + +`apps/web/src/content/pages/`: + +- `mission.md` +- `leadership.md` +- `code-of-conduct.md` +- `hackathons.md` + +Each carries a placeholder body that names itself ("This page's content hasn't been ported from the legacy site yet — see [issue ref] to help.") plus an H1 + a paragraph. Real copy ports from codeforphilly.org as a content PR. + +### 3. Renderer + route + +`apps/web/src/pages/StaticPage.tsx`: + +- `import.meta.glob('@/content/pages/*.md', { query: '?raw', import: 'default', eager: true })` builds a slug → markdown source map at build time. +- The component reads `:slug` from the route, looks up the matching source, parses with `marked`, and renders inside a `prose` typographic container. +- Unknown slug → ``. + +`apps/web/src/App.tsx`: + +- Replace `{ path: '/pages/:slug', element: }` with ``. + +### 4. Styling + +Reuse the existing typographic styles from `MarkdownView.tsx` (a `prose` container with tailwind targeting for headings, lists, code, blockquotes). DRY by extracting to a shared `MarkdownContent` wrapper, or just copy the class list — copy is cheaper for v1. + +### 5. Tests + +`apps/web/tests/StaticPage.test.tsx`: + +- Renders the H1 from `mission.md`. +- Renders an unknown slug as NotFound. +- Renders all four bundled pages (smoke). + +## Validation + +- [ ] `npm install marked` lands as its own commit. +- [ ] `apps/web/src/content/pages/` has 4 markdown files. +- [ ] `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` all render their content. +- [ ] `/pages/nonexistent` renders the NotFound screen. +- [ ] `npm run type-check && npm run lint && npm test` clean. + +## Risks / unknowns + +- **`import.meta.glob` is Vite-specific.** Confirmed by the existing codebase using Vite — same mechanism would need a polyfill or alternative if we ever switched bundlers. Out of scope to worry about. +- **Bundle size.** `marked` adds ~30 KB. Acceptable for static-content rendering. If bundle pressure becomes a concern later, swap to a Vite plugin that pre-renders to HTML at build time and import the HTML directly. +- **Placeholder content** is honest about being placeholder, but a casual visitor will still see "this hasn't been ported yet" on real pages. Trade-off: ship the plumbing now so the spec is satisfied; content PR follows. + +## Notes + +*(filled at done time)* + +## Follow-ups + +*(filled at done time)* From 71def5c535101d06a2c4f038bb1c6e4c440f81f4 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:14:20 -0400 Subject: [PATCH 2/4] chore(deps): add marked for static-page markdown rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm install --workspace apps/web marked Static /pages/:slug content is staff-authored bundle-time markdown, not user content — the no-client-markdown rule doesn't apply. marked is the smallest standalone markdown library that does what we need. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/package.json | 1 + package-lock.json | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index 42c3189..63418c2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", + "marked": "^18.0.4", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/package-lock.json b/package-lock.json index 711e23f..7bca4f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", + "marked": "^18.0.4", "radix-ui": "^1.4.3", "react": "^19.2.6", "react-dom": "^19.2.6", @@ -10731,6 +10732,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", From 1c70e8fbd7b3084653517fe6c83856506136384f Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:17:10 -0400 Subject: [PATCH 3/4] feat(web): /pages/:slug renders bundled markdown content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the placeholders at /pages/{mission,leadership, code-of-conduct,hackathons} per specs/behaviors/app-shell.md. Content lives in apps/web/src/content/pages/*.md, loaded as raw text at build time via Vite's import.meta.glob. The new StaticPage component parses the slug-matched source with `marked` and renders inside a tailwind `prose` typographic container. Unknown slugs fall through to . This is safe to render client-side: static-page content is build-time-static, not user input, so the CLAUDE.md no-client-markdown rule (about XSS via user content) doesn't apply. The four bundled pages carry placeholder copy with a self-disclosing "awaiting content port" footer. Porting the legacy laddr-site copy into these files is a content PR — out of scope for this engineering plan, called out in the closeout follow-ups. 7 new tests cover all four bundled pages, the NotFound fallback, markdown→heading semantic rendering, and inline link resolution. Refs #83. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/App.tsx | 3 +- apps/web/src/content/pages/code-of-conduct.md | 36 +++++++++ apps/web/src/content/pages/hackathons.md | 35 +++++++++ apps/web/src/content/pages/leadership.md | 24 ++++++ apps/web/src/content/pages/mission.md | 28 +++++++ apps/web/src/pages/StaticPage.tsx | 74 +++++++++++++++++++ apps/web/tests/StaticPage.test.tsx | 57 ++++++++++++++ 7 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/content/pages/code-of-conduct.md create mode 100644 apps/web/src/content/pages/hackathons.md create mode 100644 apps/web/src/content/pages/leadership.md create mode 100644 apps/web/src/content/pages/mission.md create mode 100644 apps/web/src/pages/StaticPage.tsx create mode 100644 apps/web/tests/StaticPage.test.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 07cec08..2102113 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -25,6 +25,7 @@ import { Volunteer } from '@/screens/Volunteer'; import { Sponsor } from '@/screens/Sponsor'; import { ComingSoon } from '@/pages/ComingSoon'; import { Contact } from '@/pages/Contact'; +import { StaticPage } from '@/pages/StaticPage'; import { NotFound } from '@/pages/NotFound'; import { LoginPlaceholder } from '@/pages/LoginPlaceholder'; import { AccountClaim } from '@/pages/AccountClaim'; @@ -61,7 +62,7 @@ const router = createBrowserRouter([ { path: '/sponsor', element: }, { path: '/account', element: }, { path: '/search', element: }, - { path: '/pages/:slug', element: }, + { path: '/pages/:slug', element: }, { path: '/contact', element: }, { path: '/login', element: }, { path: '/account-claim', element: }, diff --git a/apps/web/src/content/pages/code-of-conduct.md b/apps/web/src/content/pages/code-of-conduct.md new file mode 100644 index 0000000..e1c2219 --- /dev/null +++ b/apps/web/src/content/pages/code-of-conduct.md @@ -0,0 +1,36 @@ +# Code of Conduct + +Code for Philly is committed to providing a welcoming, inclusive +experience for everyone who participates in our community — +regardless of background, identity, or experience level. + +## Expected behavior + +- **Be respectful.** Disagreement on ideas is fine; personal attacks + are not. +- **Be inclusive.** Use language that welcomes people from different + backgrounds; avoid jargon when introducing yourself and your work. +- **Be patient with newcomers.** Everyone was new once. +- **Give credit.** Acknowledge others' contributions, especially in + team projects. + +## Unacceptable behavior + +- Harassment, discrimination, or exclusionary jokes targeting any + protected class. +- Unwanted sexual attention or advances. +- Doxxing or sharing others' private information without consent. +- Sustained disruption of meetings, channels, or events. + +## Reporting + +If you witness or experience a violation, please contact the +leadership team at +[hello@codeforphilly.org](mailto:hello@codeforphilly.org). Reports +will be handled discreetly and acted on quickly. + +--- + +*This page is awaiting its full content port from the legacy site. +See [issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) +for status.* diff --git a/apps/web/src/content/pages/hackathons.md b/apps/web/src/content/pages/hackathons.md new file mode 100644 index 0000000..cd59b9d --- /dev/null +++ b/apps/web/src/content/pages/hackathons.md @@ -0,0 +1,35 @@ +# Hackathons + +Code for Philly hosts and co-hosts hackathons throughout the year — +weekend-long events where volunteers form teams around civic-tech +ideas, prototype together, and demo to the community. + +## What to expect + +- **Open to all.** No prior experience required. We pair newcomers + with mentors so everyone can contribute meaningfully. +- **Real-world themes.** Each event picks a focus — housing, + transit, public health, accessibility — sourced from community + partners with actual problems to solve. +- **Free.** Sponsors cover venue, food, and prizes. Bring a laptop. + +## Recent events + +We're rebuilding this section as we relaunch the directory. For an +up-to-date calendar, watch the +[#announcements channel in Slack](/chat?channel=announcements) or +sign up for our newsletter. + +## Want to organize one? + +Reach out at +[hello@codeforphilly.org](mailto:hello@codeforphilly.org). We have +playbooks for venue selection, sponsor outreach, judging panels, and +post-event followups. + +--- + +*This page is awaiting its full content port from the legacy site, +including the event archive. See +[issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) +for status.* diff --git a/apps/web/src/content/pages/leadership.md b/apps/web/src/content/pages/leadership.md new file mode 100644 index 0000000..8e5fa8a --- /dev/null +++ b/apps/web/src/content/pages/leadership.md @@ -0,0 +1,24 @@ +# Leadership + +Code for Philly is run by a volunteer leadership team that handles +the organizational work — fiscal sponsorship, sponsor relationships, +event logistics, and the shared infrastructure every project depends +on (this directory, our Slack, our meeting spaces). + +Day-to-day project decisions stay with each project's maintainers. +Leadership exists to make those projects easier to run, not to direct +them. + +## Get in touch + +- [Email us](mailto:hello@codeforphilly.org) for partnership, + sponsorship, or press inquiries. +- For operational questions about a specific project, the project's + Slack channel is the fastest path. + +--- + +*This page is awaiting its full content port from the legacy site, +including the current leadership roster. See +[issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) +for status.* diff --git a/apps/web/src/content/pages/mission.md b/apps/web/src/content/pages/mission.md new file mode 100644 index 0000000..eab3260 --- /dev/null +++ b/apps/web/src/content/pages/mission.md @@ -0,0 +1,28 @@ +# Mission + +Code for Philly is a civic-tech community building software that +improves life in the Philadelphia region. We're volunteer-run, +project-driven, and welcoming to anyone — technologist or not — who +wants to put their skills toward public good. + +## What we do + +We organize around **projects** in this directory: groups of volunteers +collaborating on tools, datasets, and services that fill gaps the +public sector or private market hasn't reached. Projects pick their +own scope, tooling, and cadence; we provide the meeting space, the +collaboration channels, and an audience. + +## Where to start + +- Browse [projects](/projects) to find one whose mission resonates. +- Read [Help Wanted](/help-wanted) to see what specific skills active + projects are looking for. +- Join our [Slack workspace](/chat) — every project has a channel, + and `#welcome` is the place to introduce yourself. + +--- + +*This page is awaiting its full content port from the legacy site. +See [issue tracking](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83) +for status.* diff --git a/apps/web/src/pages/StaticPage.tsx b/apps/web/src/pages/StaticPage.tsx new file mode 100644 index 0000000..5c80f14 --- /dev/null +++ b/apps/web/src/pages/StaticPage.tsx @@ -0,0 +1,74 @@ +/** + * Static content pages — `/pages/:slug`. + * + * Content lives in `apps/web/src/content/pages/*.md` and is loaded as + * raw text at build time via Vite's `import.meta.glob`. We use `marked` + * to parse the markdown source on render. This is safe to do client- + * side because the content is bundle-time-static — it can't be + * influenced by users. + * + * Per specs/behaviors/app-shell.md → "The /pages/* URLs serve static + * content pages authored as MDX/Markdown in the code repo". + */ +import { useMemo } from 'react'; +import { useParams } from 'react-router'; +import { marked } from 'marked'; +import { NotFound } from '@/pages/NotFound'; +import { cn } from '@/lib/utils'; + +// Eagerly load all .md files in the content/pages directory as raw text. +// Vite turns this into a slug→source map at build time. +const RAW_PAGES = import.meta.glob('../content/pages/*.md', { + query: '?raw', + import: 'default', + eager: true, +}) as Record; + +// Strip the directory + extension so the map is keyed by slug only. +const PAGES: Record = Object.fromEntries( + Object.entries(RAW_PAGES).map(([path, source]) => { + const match = /\/([^/]+)\.md$/.exec(path); + return [match?.[1] ?? path, source]; + }), +); + +// Marked config: GFM features (tables, autolinks) on; no breaks-on-newline +// because the content is hand-authored markdown, not chat input. +marked.setOptions({ gfm: true, breaks: false }); + +export function StaticPage() { + const { slug } = useParams<{ slug: string }>(); + const source = slug ? PAGES[slug] : undefined; + + const html = useMemo(() => { + if (!source) return null; + // marked.parse() returns string | Promise. With async: false + // (the default in v18+ for synchronous extensions), it's a string. + return marked.parse(source, { async: false }) as string; + }, [source]); + + if (!source || html === null) return ; + + return ( +
+
+
+ ); +} diff --git a/apps/web/tests/StaticPage.test.tsx b/apps/web/tests/StaticPage.test.tsx new file mode 100644 index 0000000..663fd6b --- /dev/null +++ b/apps/web/tests/StaticPage.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { screen } from '@testing-library/react'; +import { Routes, Route } from 'react-router'; +import { renderScreen } from './test-utils.js'; +import { StaticPage } from '../src/pages/StaticPage.js'; + +describe('StaticPage', () => { + function renderAt(path: string) { + renderScreen( + + } /> + , + { initialEntries: [path] }, + ); + } + + it('renders the Mission page H1', () => { + renderAt('/pages/mission'); + expect(screen.getByRole('heading', { name: /^mission$/i, level: 1 })).toBeInTheDocument(); + }); + + it('renders the Leadership page', () => { + renderAt('/pages/leadership'); + expect(screen.getByRole('heading', { name: /^leadership$/i, level: 1 })).toBeInTheDocument(); + }); + + it('renders the Code of Conduct page', () => { + renderAt('/pages/code-of-conduct'); + expect( + screen.getByRole('heading', { name: /^code of conduct$/i, level: 1 }), + ).toBeInTheDocument(); + }); + + it('renders the Hackathons page', () => { + renderAt('/pages/hackathons'); + expect(screen.getByRole('heading', { name: /^hackathons$/i, level: 1 })).toBeInTheDocument(); + }); + + it('renders NotFound for an unknown slug', () => { + renderAt('/pages/nonexistent-page'); + // The NotFound screen renders a recognizable not-found message. + expect(screen.getByText(/page not found|not found/i)).toBeInTheDocument(); + }); + + it('renders markdown headings as semantic h2/h3', () => { + renderAt('/pages/mission'); + // The Mission page has a "What we do" h2. + expect(screen.getByRole('heading', { name: /what we do/i, level: 2 })).toBeInTheDocument(); + }); + + it('renders inline links from markdown', () => { + renderAt('/pages/mission'); + // [projects](/projects) → an projects. + const link = screen.getByRole('link', { name: /^projects$/ }); + expect(link).toHaveAttribute('href', '/projects'); + }); +}); From cd2badeb0b10ec72a41ac1893f83a34b1215cd55 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 30 May 2026 12:17:56 -0400 Subject: [PATCH 4/4] chore(plans): close out screen-gaps-phase3 (PR #104) All 5 validation checkboxes ticked. Notes covers the marked-v18 sync default, the deliberate prose-class duplication vs MarkdownView, and the no-DOMPurify rationale for build-time-static content. Follow-ups: content port from the legacy site, phase 4 (buzz/new form) still pending under #83, MDX upgrade path. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/screen-gaps-phase3.md | 47 ++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/plans/screen-gaps-phase3.md b/plans/screen-gaps-phase3.md index 68f0c6e..61e486c 100644 --- a/plans/screen-gaps-phase3.md +++ b/plans/screen-gaps-phase3.md @@ -1,9 +1,10 @@ --- -status: in-progress +status: done depends: [screen-gaps-phase2] specs: - specs/behaviors/app-shell.md issues: [83] +pr: 104 --- # Plan: screen-gaps phase 3 — static `/pages/:slug` content @@ -69,11 +70,11 @@ Reuse the existing typographic styles from `MarkdownView.tsx` (a `prose` contain ## Validation -- [ ] `npm install marked` lands as its own commit. -- [ ] `apps/web/src/content/pages/` has 4 markdown files. -- [ ] `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` all render their content. -- [ ] `/pages/nonexistent` renders the NotFound screen. -- [ ] `npm run type-check && npm run lint && npm test` clean. +- [x] `npm install marked` lands as its own commit. +- [x] `apps/web/src/content/pages/` has 4 markdown files. +- [x] `/pages/mission`, `/pages/leadership`, `/pages/code-of-conduct`, `/pages/hackathons` all render their content. +- [x] `/pages/nonexistent` renders the NotFound screen. +- [x] `npm run type-check && npm run lint && npm test` clean. ## Risks / unknowns @@ -83,8 +84,38 @@ Reuse the existing typographic styles from `MarkdownView.tsx` (a `prose` contain ## Notes -*(filled at done time)* +Three commits: plan-open, `npm install marked` (with the exact +command in the body, per the generated-files-commit-first convention), +content + StaticPage + tests. + +Surprises: + +- **`marked.parse` is sync-by-default in v18.** Earlier versions + returned `string | Promise` depending on extension config; + v18+ defaults to sync unless a custom async extension is registered. + The `{ async: false }` arg is belt-and-suspenders. +- **The `prose` class duplication.** `MarkdownView.tsx` and + `StaticPage.tsx` carry similar Tailwind `prose` configs. Considered + extracting to a shared wrapper, but the consumers diverge subtly: + `MarkdownView` is for compact embedded markdown (project overviews, + update bodies) and uses `prose-sm`; `StaticPage` is for full-width + documentation and uses `prose-sm sm:prose-base`. Plus heading scales + differ. Three-similar-lines vs. premature abstraction — kept the + copy. +- **No DOMPurify dance.** Static-page content is build-time-static + source; no XSS surface. `dangerouslySetInnerHTML` is the right tool + here even though the name reads scary. ## Follow-ups -*(filled at done time)* +- **Port real copy from the legacy site.** Each of the four pages + carries placeholder text that names itself as such. The real text + lives at `codeforphilly.org/site-root/pages/`. *Tracked as* — + content-PR task; will file a tracking issue when content review + has someone owning it. +- **Phase 4 — `/projects/:slug/buzz/new` form** stays the last open + piece of [#83](https://github.com/CodeForPhilly/codeforphilly-ng/issues/83). *Deferred to plan* — `plans/buzz-new-form.md`. +- **MDX upgrade.** If `/pages/leadership` ever needs to render + embedded React components (e.g., a live leadership-roster card), + swap to `@mdx-js/rollup`. *None* for v1 — pure markdown is + sufficient.