Skip to content

Commit a49b2aa

Browse files
Merge pull request #91 from CodeForPhilly/feat/markdown-transforms
feat: markdown @mention + external-link transforms (closes #81)
2 parents e128909 + b0f82ff commit a49b2aa

21 files changed

Lines changed: 632 additions & 31 deletions

File tree

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,14 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars
7979
# serves the SPA as a fallthrough for non-/api/* routes (single-image
8080
# deploy per specs/architecture.md). Leave unset in dev — Vite owns 5173.
8181
# CFP_WEB_DIST_PATH=/app/apps/web/dist
82+
83+
# ---------------------------------------------------------------------------
84+
# Markdown rendering
85+
# ---------------------------------------------------------------------------
86+
87+
# Public-facing host. Used by the server-side markdown renderer's
88+
# external-link transform — anchors with a host different from this one
89+
# get target="_blank" rel="noopener nofollow". See
90+
# specs/behaviors/markdown-rendering.md. Sandbox: next-v2.codeforphilly.org.
91+
# Production: codeforphilly.org (the default).
92+
# CFP_SITE_HOST=codeforphilly.org

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import storePlugin from './plugins/store.js';
3737
import reconcilePlugin from './plugins/reconcile.js';
3838
import pushDaemonPlugin from './plugins/push-daemon.js';
3939
import servicesPlugin from './plugins/services.js';
40+
import markdownPlugin from './plugins/markdown.js';
4041
import rateLimitPlugin from './plugins/rate-limit.js';
4142
import idempotencyPlugin from './plugins/idempotency.js';
4243
import sessionMiddlewarePlugin from './auth/middleware.js';
@@ -129,6 +130,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
129130

130131
// ----- 6c. Services (loads in-memory state + FTS, boots after store) -----
131132
await fastify.register(servicesPlugin);
133+
await fastify.register(markdownPlugin);
132134

133135
// ----- 7. Rate limiting -----
134136
await fastify.register(rateLimitPlugin);

apps/api/src/env.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ export const EnvSchema = z.object({
5959
* image; unset in dev (Vite owns 5173).
6060
*/
6161
CFP_WEB_DIST_PATH: z.string().optional(),
62+
/**
63+
* Host of the public-facing site (e.g. `codeforphilly.org` in prod,
64+
* `next-v2.codeforphilly.org` in sandbox). Used by the server-side
65+
* markdown renderer to distinguish internal from external links — anchors
66+
* with a host different from this one get `target="_blank" rel="noopener
67+
* nofollow"`. Per specs/behaviors/markdown-rendering.md.
68+
*/
69+
CFP_SITE_HOST: z.string().default('codeforphilly.org'),
6270
});
6371

6472
export type Env = z.infer<typeof EnvSchema>;
@@ -95,5 +103,6 @@ export const envJsonSchema = {
95103
SAML_CERTIFICATE: { type: 'string' },
96104
SLACK_TEAM_HOST: { type: 'string', default: 'codeforphilly.slack.com' },
97105
CFP_WEB_DIST_PATH: { type: 'string' },
106+
CFP_SITE_HOST: { type: 'string', default: 'codeforphilly.org' },
98107
},
99108
} as const;

apps/api/src/plugins/markdown.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Markdown plugin.
3+
*
4+
* Installs a `renderMarkdown` implementation into
5+
* `apps/api/src/services/serializers/common.ts` that closes over:
6+
*
7+
* - `CFP_SITE_HOST` (from env) — used by the external-link transform
8+
* to decide which anchors get `target="_blank" rel="noopener nofollow"`.
9+
* - `inMemoryState.personIdBySlug.has` — used by the `@mention` transform
10+
* to resolve which usernames link to a real Person.
11+
*
12+
* Every serializer renders markdown via `common.renderMarkdown`, which
13+
* dispatches to whichever function this plugin most recently installed.
14+
* Until installed (tests, ad-hoc scripts), it falls back to the bare
15+
* `@cfp/shared` renderer — same output as before, no transforms.
16+
*
17+
* Per specs/behaviors/markdown-rendering.md.
18+
*/
19+
import { renderMarkdown } from '@cfp/shared';
20+
import type { FastifyInstance } from 'fastify';
21+
import fp from 'fastify-plugin';
22+
23+
import { setRenderMarkdown } from '../services/serializers/common.js';
24+
25+
async function markdownPlugin(fastify: FastifyInstance): Promise<void> {
26+
const siteHost = fastify.config.CFP_SITE_HOST;
27+
// Closure over the LIVE inMemoryState reference (not its value) so the
28+
// resolver always sees the current Map even after hot-reload swaps state
29+
// in place. (Hot reload preserves `state` identity per
30+
// specs/behaviors/storage.md#hot-reload — the Maps are mutated in place,
31+
// not replaced.)
32+
const state = fastify.inMemoryState;
33+
setRenderMarkdown((source) =>
34+
renderMarkdown(source, {
35+
siteHost,
36+
resolveMention: (slug) => state.personIdBySlug.has(slug),
37+
}),
38+
);
39+
}
40+
41+
export default fp(markdownPlugin, {
42+
name: 'markdown',
43+
dependencies: ['services'],
44+
});

apps/api/src/routes/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* editor preview path.
1111
*/
1212
import type { FastifyInstance } from 'fastify';
13-
import { renderMarkdown } from '@cfp/shared';
13+
import { renderMarkdown } from '../services/serializers/common.js';
1414
import { ok } from '../lib/response.js';
1515
import { ApiValidationError } from '../lib/errors.js';
1616

apps/api/src/services/serializers/common.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
11
/**
22
* Shared serialization helpers used across entity serializers.
33
*/
4-
import { renderMarkdown } from '@cfp/shared';
4+
import { renderMarkdown as rawRenderMarkdown, type RenderMarkdownResult } from '@cfp/shared';
55
import type { Person, Tag } from '@cfp/shared/schemas';
66

7+
/**
8+
* Boot-installed renderer. Defaults to the bare `@cfp/shared` pipeline so
9+
* tests + dev code that import serializers directly without booting the
10+
* markdown plugin keep working. The markdown plugin
11+
* (`apps/api/src/plugins/markdown.ts`) calls `setRenderMarkdown` at boot
12+
* to swap in a renderer bound to `CFP_SITE_HOST` + the live
13+
* `inMemoryState.personIdBySlug` lookup, so all serializer output applies
14+
* the external-link + `@mention` transforms from
15+
* specs/behaviors/markdown-rendering.md.
16+
*
17+
* Module-level state is justified here over per-call threading: every
18+
* serializer currently routes through `renderMarkdown(source)` without
19+
* carrying an `app` or `FastifyInstance` reference, and a per-process
20+
* single binding matches the runtime's actual shape (one Fastify app,
21+
* one renderer config). Hot-reload preserves the state Maps in place so
22+
* the closure stays correct.
23+
*/
24+
let currentRender: (source: string) => RenderMarkdownResult = rawRenderMarkdown;
25+
26+
export function setRenderMarkdown(fn: (source: string) => RenderMarkdownResult): void {
27+
currentRender = fn;
28+
}
29+
30+
/** Render a markdown source through the boot-installed renderer. */
31+
export function renderMarkdown(source: string): RenderMarkdownResult {
32+
return currentRender(source);
33+
}
34+
735
/** PersonAvatar shape used in many nested contexts. */
836
export interface PersonAvatar {
937
readonly slug: string;
@@ -53,12 +81,6 @@ export function groupTagsByNamespace(
5381
return { topic, tech, event };
5482
}
5583

56-
/** Render markdown to HTML + an excerpt. Returns empty string for null/empty source. */
57-
export function renderField(source: string | null | undefined): { html: string; excerpt: string } {
58-
if (!source) return { html: '', excerpt: '' };
59-
const { html, excerpt } = renderMarkdown(source);
60-
return { html, excerpt };
61-
}
6284

6385
/** Truncate a plain-text string at a word boundary. */
6486
export function truncate(text: string, maxLength: number): string {

apps/api/src/services/serializers/help-wanted.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
* HelpWantedRole serializer.
33
*/
44
import type { HelpWantedRole, Person, Project, Tag, TagAssignment } from '@cfp/shared/schemas';
5-
import { renderMarkdown } from '@cfp/shared';
65
import type { HelpWantedPermissions } from '../permissions.js';
7-
import { groupTagsByNamespace, serializePersonAvatar, type TagItem } from './common.js';
6+
import { groupTagsByNamespace, renderMarkdown, serializePersonAvatar, type TagItem } from './common.js';
87

98
export interface HelpWantedRoleResponse {
109
readonly id: string;

apps/api/src/services/serializers/person.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import type {
99
Tag,
1010
TagAssignment,
1111
} from '@cfp/shared/schemas';
12-
import { renderMarkdown } from '@cfp/shared';
1312
import type { PersonPermissions } from '../permissions.js';
13+
import { renderMarkdown } from './common.js';
1414
import {
1515
groupTagsByNamespace,
1616
serializePersonAvatar,

apps/api/src/services/serializers/project-buzz.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
* ProjectBuzz serializer.
33
*/
44
import type { Person, Project, ProjectBuzz } from '@cfp/shared/schemas';
5-
import { renderMarkdown } from '@cfp/shared';
65
import type { BuzzPermissions } from '../permissions.js';
7-
import { serializePersonAvatar } from './common.js';
6+
import { renderMarkdown, serializePersonAvatar } from './common.js';
87

98
export interface ProjectBuzzResponse {
109
readonly id: string;

apps/api/src/services/serializers/project-update.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22
* ProjectUpdate serializer.
33
*/
44
import type { Person, Project, ProjectUpdate } from '@cfp/shared/schemas';
5-
import { renderMarkdown } from '@cfp/shared';
65
import type { UpdatePermissions } from '../permissions.js';
7-
import { serializePersonAvatar } from './common.js';
6+
import { renderMarkdown, serializePersonAvatar } from './common.js';
87

98
export interface ProjectUpdateResponse {
109
readonly id: string;

0 commit comments

Comments
 (0)