Imported from tktk7l9/service-anatomy (
AGENTS.md). Install upstream withnpx skills add tktk7l9/service-anatomy. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
service-anatomy development conventions (for AI/Claude)
An analysis blog that dissects popular services (Japanese and international, across genres). One article = one service, covering (1) service overview (2) UX analysis (3) tech stack (4) business model. Fully bilingual ja/en, tech editorial (magazine-style) design.
Architectural backbone
- CSP uses static headers in next.config.ts (source of truth:
src/lib/csp.ts).script-srcis'self' 'unsafe-inline'. Never add'strict-dynamic'— in CSP Level 3, strict-dynamic makes both'self'and'unsafe-inline'be ignored, and with no nonce and no hashes in this setup every script stops (src/lib/csp.test.tsblocks it). Migrated from the per-request nonce approach on 2026-09-12. The reason: Next 16's proxy runs only on the Node runtime and OpenNext (Cloudflare Workers) does not support Node middleware, so the site could not move to Workers. The cost is losing inline XSS protection and Observatory A+ (an intentional decision). For theimg-srcof the official link card at the end of articles,next.config.tsreadscontent/og-image-hosts.jsonand passes it tocontentSecurityPolicy({ extraImgSrc }). Pages can be static. Callingheaders()forces dynamic rendering, so do not call it on pages that should be cached. Inline<script>needs no nonce (ld+json is a data block and outside script-src). - Every route is SSG (
generateStaticParams+dynamicParams = false). 861 pages are generated at build time. Read the Markdown incontent/only at build time — reading it at runtime depends on howprocess.cwd()resolves in the Worker runtime. Route handlers must setforce-staticexplicitly. Since Next 15, GET route handlers are dynamic by default, so just removingforce-dynamicleaves them asƒ(this applies torss.xmlandapi/anatomy.json). If even oneƒshows up in the build table, fix it. - Never remove
incrementalCachefromopen-next.config.ts(staticAssetsIncrementalCache). Without it the prerendered output cannot be read from anywhere, and combined withdynamicParams = falseevery article, tag, tech and category page returns 404. Worse,/ja,/en,rss.xmlandsitemap.xmlare plain static routes and keep returning 200, so the site looks alive. After deploying, always verify by picking real URLs fromsitemap.xmland hitting the dynamic segments. A 200 from the top page guarantees nothing. - i18n uses a
[locale]segment +Localized<T> = Record<"ja"|"en", T>. No locale detection in middleware. Missing translations are caught as type errors — do not escape withPartial. - Articles live in
content/articles/<slug>/{ja.md, en.md}. The directory name is the authoritative slug (not in frontmatter). Frontmatter is validated bysrc/engine/articles/schema.ts(a hand-written validator). Always write dates as quoted strings (schema.ts rejects gray-matter's automatic YAML Date conversion as invalid). - Body Markdown is converted to HTML server-side by
src/engine/markdown/render.ts(unified + remark-directive). MDX is forbidden. remark-rehype ignores raw HTML by default (safe) — keep this property.::scorecard/::techstackare swapped for React components via HTML comment markers (split.tssplits them as a pure function). - Because content is read with fs at runtime, next.config.ts's
outputFileTracingIncludesbundles content/. After deploying, always check that article pages actually work (no 500s). - Structured data for all articles is published at
/api/anatomy.json(src/engine/articles/export.ts, CORS fully open). If you change the frontmatter schema, update export too.
Testing policy
src/engine/**andsrc/i18n/**are at 100% coverage (the thresholds in vitest.config.ts are the gate, enforced in CI).- Content consistency is checked across the board by
content.test.ts(both ja/en files exist, language-neutral fields are equal, sources≥1, scores in range, confirmed requires evidenceUrl, at least 4 h2s,::techstackpresent). Keep the design where tests automatically include newly added articles. - React components are the presentation layer and outside coverage (write smoke tests).
Article writing rules (legal and quality ground rules)
- Observed facts go in
:::factwith a required source (sources / evidenceUrl). Guesses are marked explicitly with:::guessand must not use assertive wording (use phrasing like 「〜とみられる」「〜と推測される」 — "appears to", "is presumed to"). - The confidence of tech stack entries (techStack) has three levels: confirmed (primary source exists) / likely (strong circumstantial evidence) / speculative (guess). confirmed requires evidenceUrl (enforced by tests).
- Avoid negative assertions about companies or individuals (defamation risk). Base critique on facts and offer alternative interpretations.
- Do not use screenshots or logo images (copyright, trademark). Visuals are self-made generative SVG art only.
The only exception = the official link card at the end of articles: show the serviceUrl's OGP as a "link preview to the official site"
(within the same practice as link cards on social media). Images are not copied to our server but shown directly from each company's
server, and
img-srcallows only the origins incontent/og-image-hosts.json. Fetch withnpm run og-cards(scripts/fetch-og-cards.mjs) → commit the output. Re-run it when adding articles. Do not use OGP images for anything other than link previews, such as thumbnails or heroes. - Freshness:
lastVerified(ISO date) is required. Always verify with web search and real observation (curl -sI etc.) when writing (do not trust the LLM's training knowledge).npm run freshnesslists articles whose lastVerified is over 90 days old — run it in the weekly review, and re-verify overdue articles and update lastVerified, or make them candidates for periodic re-anatomy (定点観測). - Broken links:
npm run check-linkssends real requests to serviceUrl / sources[].url / techStack[].evidenceUrl / OGP image URLs (content/og-cards.json) to check whether they are alive. Run it in the weekly review (about 1 minute). Each URL is tried with HEAD first, then once more with a browser-like GET (some servers reject HEAD or requests without Sec-Fetch-* headers); a 429/503 is retried once honouring Retry-After. Results are grouped: Dead (404/410/DNS/TLS) — fix these by replacing or removing the URL, or for OGP images by re-runningnpm run og-cards. Other errors — 5xx/timeouts, check manually. Rate limited — run again later. Blocked by bot protection (Cloudflare challenge, "Attention Required", 401/403/406/419/999) — cannot be checked from a script, open them in a browser only when something looks off. Skipped —sec.govrequires a personal contact in the User-Agent, which this project never sends, so it is never contacted.-- --ciexits 1 only on dead or other errors. - Write ja first and sync en in the same commit (never leave a change in only one language).
Development commands
npm run dev/npm run build/npm startnpm run typecheck/npm test/npm run coverage(100% gate)npm run og-cards(refetch link cards) /npm run freshness(lastVerified freshness list) /npm run check-links(external link liveness check)
Commit granularity
- 1 commit = 1 self-contained change (one article, one component, etc.). Commit with tests green.
Before publishing
- Starts private. Publish only via publish-check (gitleaks 0 /
node scripts/audit-gate.mjspasses, i.e. no advisory outsideaudit-allowlist.json/ no PII). Observatory dropped from A+ to B (75, 10/12) (measured on the Workers production URL on 2026-09-14). Both failing items are accepted trade-offs, so this score does not block publishing —content-security-policy−20 is the'unsafe-inline'from the CSP migration, andsubresource-integrity−5 is the Cloudflare Web Analytics beacon. Never add SRI to the beacon: Cloudflare swaps the content behind the unversionedbeacon.min.jsURL, so pinningintegritysilently stops just the beacon on the next update. - CI runs
node scripts/audit-gate.mjsinstead of a barenpm audit. It fails on any advisory not listed inaudit-allowlist.json. An entry needs a reason and anexpiresdate (keep it about a month out), anddevOnly: truestops matching once the package becomes reachable from production dependencies. The gate also fails when an allowlisted advisory gets a fix, so the entry is removed by updating rather than forgotten. - Keep the "unofficial, analysis based on public information" disclaimer in the footer and on about at all times (never remove it).
- Advertising disclosure (Japan stealth-marketing rule, 2023-10-01). Every affiliate
disclosure surface keys off
hasAffiliate()/affiliateOf()insrc/engine/articles/disclosure.ts: the notice above the article body (AffiliateNotice), the PR card after it (AffiliateCard), and the "PR" label on listing cards (ArticleCard). Do not add a new affiliate surface with its own condition. Comparison pages take the links of their two articles throughaffiliateSlots()in the same file: the same notice above the body, and after it oneAffiliateCardper affiliated side under the service name (ComparisonAffiliates). Use the ASP's ad code as provided (A8.net and Moshimo Affiliate forbid modifying it). Their code is a link plus a 1x1 impression image, so copy both: the<a href>goes toaffiliate.urland the<img src>to the optionalaffiliate.impressionUrl(https only; rewrite Moshimo's protocol-relative//i.moshimo.com/...tohttps://; ja/en must match, parity.ts checks it). Networks without a pixel (e.g. Shopify via Impact) leave it unset. The ad text is part of the code: A8.net forbids rewording a text ad or using only its link part. For every ASP link, copy the material's text verbatim intoaffiliate.label(one line, max 120 chars, identical in ja/en — parity.ts checks it; keep brackets like 【】, do not translate or shorten).AffiliateCardthen shows exactly that string as the link text, withlang="ja"on the English page; the "opens in a new tab" cue (↗) sits outside the<a>. If you do not know the material's text, ask the owner — never invent a label. Withoutlabelthe card falls back to the site's own CTA from the dictionary (fine for non-ASP programs such as Shopify via Impact).AffiliateCardrenders the pixel after the link and gives the linkrel="sponsored nofollow noopener"+referrerPolicy="no-referrer-when-downgrade"— nonoreferrer, because the networks' code sends the Referer. Other external links keepnoopener noreferrer. The pixel's origin reaches the CSPimg-srcautomatically:next.config.tsderives it from the articles viasrc/lib/impression-origins.ts(exact origins, never a wildcard). The pixel is third-party tracking that loads only on articles withimpressionUrl; the policy page says so — keep that text true. The policy page is/[locale]/disclosure(linked from the footer, about, and the notice); bump itsPOLICY_UPDATED_ATwhen the policy text changes. Keep the notice above the body, at body-like size and in regular ink — the CAA operational standards treat end-only, small, or faint labels as unclear.
