@rune-kit/rune 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (287) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +68 -34
  3. package/agents/adversary.md +27 -0
  4. package/agents/architect.md +19 -29
  5. package/agents/asset-creator.md +18 -4
  6. package/agents/audit.md +25 -4
  7. package/agents/autopsy.md +19 -4
  8. package/agents/ba.md +35 -0
  9. package/agents/brainstorm.md +31 -4
  10. package/agents/browser-pilot.md +21 -4
  11. package/agents/coder.md +21 -29
  12. package/agents/completion-gate.md +20 -4
  13. package/agents/constraint-check.md +18 -4
  14. package/agents/context-engine.md +22 -4
  15. package/agents/context-pack.md +32 -0
  16. package/agents/cook.md +41 -4
  17. package/agents/db.md +19 -4
  18. package/agents/debug.md +33 -4
  19. package/agents/dependency-doctor.md +20 -4
  20. package/agents/deploy.md +27 -4
  21. package/agents/design.md +22 -4
  22. package/agents/doc-processor.md +27 -0
  23. package/agents/docs-seeker.md +19 -4
  24. package/agents/docs.md +31 -0
  25. package/agents/fix.md +37 -4
  26. package/agents/git.md +29 -0
  27. package/agents/hallucination-guard.md +20 -4
  28. package/agents/incident.md +21 -4
  29. package/agents/integrity-check.md +18 -4
  30. package/agents/journal.md +19 -4
  31. package/agents/launch.md +32 -4
  32. package/agents/logic-guardian.md +26 -11
  33. package/agents/marketing.md +23 -4
  34. package/agents/mcp-builder.md +26 -0
  35. package/agents/neural-memory.md +30 -0
  36. package/agents/onboard.md +22 -4
  37. package/agents/perf.md +21 -4
  38. package/agents/plan.md +29 -4
  39. package/agents/preflight.md +22 -4
  40. package/agents/problem-solver.md +20 -4
  41. package/agents/rescue.md +23 -4
  42. package/agents/research.md +19 -4
  43. package/agents/researcher.md +19 -29
  44. package/agents/retro.md +32 -0
  45. package/agents/review-intake.md +20 -4
  46. package/agents/review.md +32 -4
  47. package/agents/reviewer.md +20 -28
  48. package/agents/safeguard.md +19 -4
  49. package/agents/sast.md +18 -4
  50. package/agents/scaffold.md +41 -0
  51. package/agents/scanner.md +19 -28
  52. package/agents/scope-guard.md +18 -4
  53. package/agents/scout.md +23 -4
  54. package/agents/sentinel-env.md +26 -0
  55. package/agents/sentinel.md +33 -4
  56. package/agents/sequential-thinking.md +20 -4
  57. package/agents/session-bridge.md +24 -4
  58. package/agents/skill-forge.md +22 -4
  59. package/agents/skill-router.md +26 -4
  60. package/agents/slides.md +24 -0
  61. package/agents/surgeon.md +19 -4
  62. package/agents/team.md +30 -4
  63. package/agents/test.md +36 -4
  64. package/agents/trend-scout.md +17 -4
  65. package/agents/verification.md +20 -4
  66. package/agents/video-creator.md +20 -4
  67. package/agents/watchdog.md +19 -4
  68. package/agents/worktree.md +17 -4
  69. package/commands/rune.md +168 -168
  70. package/compiler/__tests__/analytics.test.js +370 -0
  71. package/compiler/adapters/openclaw.js +2 -2
  72. package/compiler/analytics.js +385 -0
  73. package/compiler/bin/rune.js +68 -2
  74. package/compiler/dashboard.js +883 -0
  75. package/compiler/transforms/branding.js +1 -1
  76. package/contexts/dev.md +34 -34
  77. package/contexts/research.md +43 -43
  78. package/contexts/review.md +55 -55
  79. package/extensions/ai-ml/PACK.md +88 -88
  80. package/extensions/ai-ml/skills/ai-agents.md +172 -172
  81. package/extensions/ai-ml/skills/code-sandbox.md +187 -187
  82. package/extensions/ai-ml/skills/deep-research.md +146 -146
  83. package/extensions/ai-ml/skills/embedding-search.md +66 -66
  84. package/extensions/ai-ml/skills/fine-tuning-guide.md +74 -74
  85. package/extensions/ai-ml/skills/llm-architect.md +125 -125
  86. package/extensions/ai-ml/skills/llm-integration.md +64 -64
  87. package/extensions/ai-ml/skills/prompt-patterns.md +72 -72
  88. package/extensions/ai-ml/skills/rag-patterns.md +66 -66
  89. package/extensions/ai-ml/skills/web-extraction.md +114 -114
  90. package/extensions/analytics/PACK.md +92 -92
  91. package/extensions/analytics/skills/ab-testing.md +72 -72
  92. package/extensions/analytics/skills/dashboard-patterns.md +83 -83
  93. package/extensions/analytics/skills/data-validation.md +68 -68
  94. package/extensions/analytics/skills/funnel-analysis.md +81 -81
  95. package/extensions/analytics/skills/sql-patterns.md +57 -57
  96. package/extensions/analytics/skills/statistical-analysis.md +79 -79
  97. package/extensions/analytics/skills/tracking-setup.md +71 -71
  98. package/extensions/backend/PACK.md +104 -104
  99. package/extensions/backend/skills/api-patterns.md +84 -84
  100. package/extensions/backend/skills/async-pipeline.md +193 -193
  101. package/extensions/backend/skills/auth-patterns.md +97 -97
  102. package/extensions/backend/skills/background-jobs.md +133 -133
  103. package/extensions/backend/skills/caching-patterns.md +108 -108
  104. package/extensions/backend/skills/cli-generation.md +133 -133
  105. package/extensions/backend/skills/database-patterns.md +87 -87
  106. package/extensions/backend/skills/middleware-patterns.md +104 -104
  107. package/extensions/chrome-ext/PACK.md +93 -93
  108. package/extensions/chrome-ext/skills/cws-preflight.md +143 -143
  109. package/extensions/chrome-ext/skills/cws-publish.md +104 -104
  110. package/extensions/chrome-ext/skills/ext-ai-integration.md +251 -251
  111. package/extensions/chrome-ext/skills/ext-messaging.md +139 -139
  112. package/extensions/chrome-ext/skills/ext-storage.md +133 -133
  113. package/extensions/chrome-ext/skills/mv3-scaffold.md +164 -164
  114. package/extensions/content/PACK.md +96 -96
  115. package/extensions/content/skills/blog-patterns.md +88 -88
  116. package/extensions/content/skills/cms-integration.md +131 -131
  117. package/extensions/content/skills/content-scoring.md +107 -107
  118. package/extensions/content/skills/i18n.md +83 -83
  119. package/extensions/content/skills/mdx-authoring.md +137 -137
  120. package/extensions/content/skills/reference.md +1014 -1014
  121. package/extensions/content/skills/seo-patterns.md +67 -67
  122. package/extensions/content/skills/video-repurpose.md +153 -153
  123. package/extensions/devops/PACK.md +101 -101
  124. package/extensions/devops/skills/chaos-testing.md +67 -67
  125. package/extensions/devops/skills/ci-cd.md +75 -75
  126. package/extensions/devops/skills/docker.md +58 -58
  127. package/extensions/devops/skills/edge-serverless.md +163 -163
  128. package/extensions/devops/skills/infra-as-code.md +158 -158
  129. package/extensions/devops/skills/kubernetes.md +110 -110
  130. package/extensions/devops/skills/monitoring.md +57 -57
  131. package/extensions/devops/skills/server-setup.md +64 -64
  132. package/extensions/devops/skills/ssl-domain.md +42 -42
  133. package/extensions/ecommerce/PACK.md +116 -116
  134. package/extensions/ecommerce/skills/cart-system.md +79 -79
  135. package/extensions/ecommerce/skills/inventory-mgmt.md +102 -102
  136. package/extensions/ecommerce/skills/order-management.md +126 -126
  137. package/extensions/ecommerce/skills/payment-integration.md +472 -472
  138. package/extensions/ecommerce/skills/shopify-dev.md +69 -69
  139. package/extensions/ecommerce/skills/subscription-billing.md +93 -93
  140. package/extensions/ecommerce/skills/tax-compliance.md +117 -117
  141. package/extensions/gamedev/PACK.md +142 -142
  142. package/extensions/gamedev/skills/asset-pipeline.md +74 -74
  143. package/extensions/gamedev/skills/audio-system.md +129 -129
  144. package/extensions/gamedev/skills/camera-system.md +87 -87
  145. package/extensions/gamedev/skills/ecs.md +98 -98
  146. package/extensions/gamedev/skills/game-loops.md +72 -72
  147. package/extensions/gamedev/skills/input-system.md +199 -199
  148. package/extensions/gamedev/skills/multiplayer.md +180 -180
  149. package/extensions/gamedev/skills/particles.md +105 -105
  150. package/extensions/gamedev/skills/physics-engine.md +89 -89
  151. package/extensions/gamedev/skills/scene-management.md +146 -146
  152. package/extensions/gamedev/skills/threejs-patterns.md +90 -90
  153. package/extensions/gamedev/skills/webgl.md +71 -71
  154. package/extensions/mobile/PACK.md +106 -106
  155. package/extensions/mobile/skills/app-store-connect.md +152 -152
  156. package/extensions/mobile/skills/app-store-prep.md +66 -66
  157. package/extensions/mobile/skills/deep-linking.md +109 -109
  158. package/extensions/mobile/skills/flutter.md +60 -60
  159. package/extensions/mobile/skills/ios-build-pipeline.md +142 -142
  160. package/extensions/mobile/skills/native-bridge.md +66 -66
  161. package/extensions/mobile/skills/ota-updates.md +97 -97
  162. package/extensions/mobile/skills/push-notifications.md +111 -111
  163. package/extensions/mobile/skills/react-native.md +82 -82
  164. package/extensions/saas/PACK.md +116 -116
  165. package/extensions/saas/skills/billing-integration.md +200 -200
  166. package/extensions/saas/skills/feature-flags.md +130 -130
  167. package/extensions/saas/skills/multi-tenant.md +103 -103
  168. package/extensions/saas/skills/onboarding-flow.md +139 -139
  169. package/extensions/saas/skills/subscription-flow.md +95 -95
  170. package/extensions/saas/skills/team-management.md +144 -144
  171. package/extensions/security/PACK.md +99 -99
  172. package/extensions/security/skills/api-security.md +140 -140
  173. package/extensions/security/skills/compliance.md +68 -68
  174. package/extensions/security/skills/owasp-audit.md +64 -64
  175. package/extensions/security/skills/pentest-patterns.md +77 -77
  176. package/extensions/security/skills/secret-mgmt.md +65 -65
  177. package/extensions/security/skills/supply-chain.md +65 -65
  178. package/extensions/trading/PACK.md +80 -80
  179. package/extensions/trading/skills/chart-components.md +55 -55
  180. package/extensions/trading/skills/experiment-loop.md +125 -125
  181. package/extensions/trading/skills/fintech-patterns.md +47 -47
  182. package/extensions/trading/skills/indicator-library.md +58 -58
  183. package/extensions/trading/skills/quant-analysis.md +111 -111
  184. package/extensions/trading/skills/realtime-data.md +58 -58
  185. package/extensions/trading/skills/trade-logic.md +104 -104
  186. package/extensions/ui/PACK.md +130 -130
  187. package/extensions/ui/skills/a11y-audit.md +91 -91
  188. package/extensions/ui/skills/animation-patterns.md +127 -106
  189. package/extensions/ui/skills/component-patterns.md +100 -75
  190. package/extensions/ui/skills/design-decision.md +108 -108
  191. package/extensions/ui/skills/design-system.md +68 -68
  192. package/extensions/ui/skills/landing-patterns.md +155 -155
  193. package/extensions/ui/skills/palette-picker.md +173 -173
  194. package/extensions/ui/skills/react-health.md +90 -90
  195. package/extensions/ui/skills/type-system.md +125 -125
  196. package/extensions/ui/skills/web-vitals.md +153 -153
  197. package/extensions/zalo/PACK.md +145 -145
  198. package/extensions/zalo/skills/zalo-oa-mcp.md +317 -317
  199. package/extensions/zalo/skills/zalo-oa-messaging.md +429 -429
  200. package/extensions/zalo/skills/zalo-oa-setup.md +236 -236
  201. package/extensions/zalo/skills/zalo-oa-webhook.md +189 -189
  202. package/extensions/zalo/skills/zalo-personal-messaging.md +194 -194
  203. package/extensions/zalo/skills/zalo-personal-setup.md +153 -153
  204. package/extensions/zalo/skills/zalo-rate-guard.md +219 -219
  205. package/hooks/auto-format/index.cjs +48 -48
  206. package/hooks/context-watch/index.cjs +95 -68
  207. package/hooks/hooks.json +111 -111
  208. package/hooks/metrics-collector/index.cjs +86 -42
  209. package/hooks/post-session-reflect/index.cjs +189 -153
  210. package/hooks/pre-compact/index.cjs +95 -95
  211. package/hooks/run-hook.cmd +1 -1
  212. package/hooks/secrets-scan/index.cjs +100 -100
  213. package/hooks/session-start/index.cjs +71 -65
  214. package/hooks/typecheck/index.cjs +65 -65
  215. package/package.json +63 -63
  216. package/references/ui-pro-max-data/LICENSE-UI-PRO-MAX +21 -21
  217. package/references/ui-pro-max-data/charts.csv +26 -26
  218. package/references/ui-pro-max-data/colors.csv +161 -161
  219. package/references/ui-pro-max-data/styles.csv +68 -68
  220. package/references/ui-pro-max-data/typography.csv +74 -74
  221. package/references/ui-pro-max-data/ui-reasoning.csv +162 -162
  222. package/references/ui-pro-max-data/ux-guidelines.csv +99 -99
  223. package/skills/adversary/SKILL.md +283 -283
  224. package/skills/asset-creator/SKILL.md +157 -157
  225. package/skills/audit/SKILL.md +148 -2
  226. package/skills/autopsy/SKILL.md +335 -259
  227. package/skills/autopsy/references/repo-analysis-patterns.md +113 -0
  228. package/skills/ba/SKILL.md +72 -2
  229. package/skills/brainstorm/SKILL.md +342 -341
  230. package/skills/browser-pilot/SKILL.md +168 -168
  231. package/skills/constraint-check/SKILL.md +165 -165
  232. package/skills/context-engine/SKILL.md +404 -404
  233. package/skills/cook/SKILL.md +917 -834
  234. package/skills/cook/references/output-format.md +33 -0
  235. package/skills/db/SKILL.md +273 -272
  236. package/skills/debug/SKILL.md +465 -443
  237. package/skills/dependency-doctor/SKILL.md +265 -235
  238. package/skills/deploy/SKILL.md +274 -231
  239. package/skills/design/DESIGN-REFERENCE.md +365 -365
  240. package/skills/design/SKILL.md +589 -482
  241. package/skills/doc-processor/SKILL.md +254 -254
  242. package/skills/docs/SKILL.md +374 -373
  243. package/skills/docs-seeker/SKILL.md +177 -177
  244. package/skills/fix/SKILL.md +330 -308
  245. package/skills/git/SKILL.md +339 -339
  246. package/skills/graft/SKILL.md +352 -0
  247. package/skills/graft/references/challenge-framework.md +98 -0
  248. package/skills/graft/references/mode-decision.md +44 -0
  249. package/skills/hallucination-guard/SKILL.md +219 -219
  250. package/skills/incident/SKILL.md +254 -251
  251. package/skills/integrity-check/SKILL.md +169 -169
  252. package/skills/journal/SKILL.md +240 -238
  253. package/skills/launch/SKILL.md +344 -342
  254. package/skills/logic-guardian/SKILL.md +251 -251
  255. package/skills/marketing/SKILL.md +290 -245
  256. package/skills/mcp-builder/SKILL.md +425 -423
  257. package/skills/mcp-builder/references/auto-discovery-pattern.md +169 -0
  258. package/skills/neural-memory/SKILL.md +362 -362
  259. package/skills/onboard/SKILL.md +404 -403
  260. package/skills/perf/SKILL.md +346 -346
  261. package/skills/plan/SKILL.md +433 -370
  262. package/skills/plan/references/feature-map.md +84 -0
  263. package/skills/preflight/SKILL.md +415 -396
  264. package/skills/problem-solver/SKILL.md +380 -284
  265. package/skills/rescue/SKILL.md +474 -450
  266. package/skills/retro/SKILL.md +5 -1
  267. package/skills/review/SKILL.md +612 -535
  268. package/skills/review-intake/SKILL.md +249 -249
  269. package/skills/safeguard/SKILL.md +200 -200
  270. package/skills/sast/SKILL.md +190 -190
  271. package/skills/scaffold/SKILL.md +328 -286
  272. package/skills/scope-guard/SKILL.md +180 -162
  273. package/skills/scout/SKILL.md +263 -263
  274. package/skills/sentinel/SKILL.md +382 -353
  275. package/skills/sentinel-env/SKILL.md +254 -254
  276. package/skills/sequential-thinking/SKILL.md +234 -234
  277. package/skills/session-bridge/SKILL.md +543 -397
  278. package/skills/skill-forge/SKILL.md +581 -539
  279. package/skills/skill-router/{skill.md → SKILL.md} +30 -2
  280. package/skills/surgeon/SKILL.md +215 -215
  281. package/skills/team/SKILL.md +556 -514
  282. package/skills/test/SKILL.md +614 -587
  283. package/skills/trend-scout/SKILL.md +145 -145
  284. package/skills/verification/SKILL.md +326 -325
  285. package/skills/video-creator/SKILL.md +201 -201
  286. package/skills/watchdog/SKILL.md +168 -168
  287. package/skills/worktree/SKILL.md +140 -140
@@ -1,1014 +1,1014 @@
1
- ---
2
- name: "reference"
3
- pack: "@rune/content"
4
- description: "Shared reference patterns: content migration, search integration, newsletter/email, performance optimization, analytics, scheduling, accessibility, and rich media."
5
- model: sonnet
6
- tools: [Read, Edit, Write, Grep, Glob, Bash]
7
- ---
8
-
9
- # @rune/content — Shared Reference Patterns
10
-
11
- Supplementary patterns shared across multiple skills in this pack.
12
-
13
- ---
14
-
15
- ## Content Migration Checklist
16
-
17
- Use when moving content between CMS platforms (e.g., WordPress → Sanity, Contentful → Strapi).
18
-
19
- ### Pre-Migration
20
-
21
- - [ ] Export full content inventory — slugs, titles, dates, authors, categories, tags
22
- - [ ] Map old content types to new schema — document every field mapping
23
- - [ ] Identify broken or orphaned content before migrating (not worth moving)
24
- - [ ] Capture all existing URLs for redirect mapping (critical for SEO)
25
- - [ ] Screenshot or snapshot top-10 pages for visual regression after migration
26
- - [ ] Check for custom fields or plugins in old CMS — equivalent needed in new CMS
27
-
28
- ### URL Redirect Strategy
29
-
30
- ```typescript
31
- // Next.js next.config.ts — static redirect map from old CMS slugs
32
- const redirects: { source: string; destination: string; permanent: boolean }[] = [
33
- { source: '/2023/01/my-old-post', destination: '/blog/my-old-post', permanent: true },
34
- { source: '/category/tech', destination: '/blog?category=tech', permanent: true },
35
- // WordPress date-based URLs → clean slugs
36
- { source: '/\\d{4}/\\d{2}/\\d{2}/:slug', destination: '/blog/:slug', permanent: true },
37
- ];
38
-
39
- // For large sites: load from JSON file
40
- import redirectMap from './redirects.json';
41
-
42
- export default {
43
- async redirects() {
44
- return redirectMap.map(({ from, to }) => ({
45
- source: from,
46
- destination: to,
47
- permanent: true,
48
- }));
49
- },
50
- };
51
-
52
- // Validate no 404s after migration — scripts/check-redirects.ts
53
- async function checkRedirects(redirects: Array<{ source: string; destination: string }>) {
54
- const results = await Promise.allSettled(
55
- redirects.map(async ({ source }) => {
56
- const res = await fetch(`${process.env.SITE_URL}${source}`, { redirect: 'manual' });
57
- if (res.status !== 301 && res.status !== 308) {
58
- throw new Error(`${source} returned ${res.status}`);
59
- }
60
- })
61
- );
62
- const failures = results.filter(r => r.status === 'rejected');
63
- if (failures.length) console.error('Redirect failures:', failures);
64
- }
65
- ```
66
-
67
- ### Data Mapping
68
-
69
- ```typescript
70
- // WordPress XML → Sanity migration script (outline)
71
- import { parse } from 'node-html-parser';
72
- import { createClient } from '@sanity/client';
73
-
74
- interface WpPost {
75
- title: string;
76
- slug: string;
77
- content: string;
78
- date: string;
79
- categories: string[];
80
- status: 'publish' | 'draft';
81
- }
82
-
83
- async function migratePost(wp: WpPost, client: ReturnType<typeof createClient>) {
84
- return client.create({
85
- _type: 'post',
86
- title: wp.title,
87
- slug: { _type: 'slug', current: wp.slug },
88
- publishedAt: new Date(wp.date).toISOString(),
89
- status: wp.status === 'publish' ? 'published' : 'draft',
90
- // Convert HTML body to Portable Text via @sanity/block-content-to-hyperscript
91
- body: htmlToPortableText(wp.content),
92
- });
93
- }
94
- ```
95
-
96
- ### SEO Preservation
97
-
98
- - [ ] Verify all old URLs return 301 (permanent redirect) not 302
99
- - [ ] Check canonical tags update to new URLs after migration
100
- - [ ] Re-submit sitemap to Google Search Console after go-live
101
- - [ ] Monitor Google Search Console for coverage errors for 30 days post-migration
102
- - [ ] Preserve `<meta name="description">` content — reuse from old CMS export
103
- - [ ] Keep same `<title>` patterns where possible — Google re-evaluates after changes
104
-
105
- ---
106
-
107
- ## Search Integration
108
-
109
- ### Algolia
110
-
111
- ```typescript
112
- // lib/search/algolia.ts — index content on publish
113
- import algoliasearch from 'algoliasearch';
114
-
115
- const client = algoliasearch(
116
- process.env.ALGOLIA_APP_ID!,
117
- process.env.ALGOLIA_ADMIN_KEY! // admin key for write; search key for frontend
118
- );
119
- const index = client.initIndex('posts');
120
-
121
- export interface SearchRecord {
122
- objectID: string;
123
- title: string;
124
- excerpt: string;
125
- slug: string;
126
- category: string;
127
- tags: string[];
128
- publishedAt: number; // unix timestamp for range filtering
129
- }
130
-
131
- export async function indexPost(post: Post) {
132
- await index.saveObject({
133
- objectID: post.slug,
134
- title: post.title,
135
- excerpt: post.excerpt,
136
- slug: post.slug,
137
- category: post.category,
138
- tags: post.tags,
139
- publishedAt: new Date(post.publishedAt).getTime() / 1000,
140
- } satisfies SearchRecord);
141
- }
142
-
143
- export async function removePost(slug: string) {
144
- await index.deleteObject(slug);
145
- }
146
-
147
- // Frontend search component with InstantSearch
148
- import { InstantSearch, SearchBox, Hits, Highlight, Configure } from 'react-instantsearch';
149
- import algoliasearch from 'algoliasearch/lite';
150
-
151
- const searchClient = algoliasearch(
152
- process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
153
- process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY! // read-only key only
154
- );
155
-
156
- function BlogSearch() {
157
- return (
158
- <InstantSearch searchClient={searchClient} indexName="posts">
159
- <Configure hitsPerPage={8} />
160
- <SearchBox placeholder="Search posts..." />
161
- <Hits hitComponent={({ hit }) => (
162
- <a href={`/blog/${hit.slug}`}>
163
- <Highlight attribute="title" hit={hit} />
164
- <Highlight attribute="excerpt" hit={hit} />
165
- </a>
166
- )} />
167
- </InstantSearch>
168
- );
169
- }
170
- ```
171
-
172
- ### Meilisearch
173
-
174
- ```typescript
175
- // lib/search/meilisearch.ts — self-hosted, zero API cost
176
- import { MeiliSearch } from 'meilisearch';
177
-
178
- const client = new MeiliSearch({
179
- host: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
180
- apiKey: process.env.MEILISEARCH_MASTER_KEY,
181
- });
182
-
183
- const postsIndex = client.index('posts');
184
-
185
- // Configure searchable and filterable attributes
186
- await postsIndex.updateSettings({
187
- searchableAttributes: ['title', 'excerpt', 'tags', 'content'],
188
- filterableAttributes: ['category', 'tags', 'status'],
189
- sortableAttributes: ['publishedAt'],
190
- rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
191
- });
192
-
193
- // Search with filters
194
- export async function searchPosts(query: string, category?: string) {
195
- return postsIndex.search(query, {
196
- filter: category ? `category = "${category}" AND status = "published"` : 'status = "published"',
197
- limit: 10,
198
- attributesToHighlight: ['title', 'excerpt'],
199
- });
200
- }
201
- ```
202
-
203
- ### Typesense
204
-
205
- ```typescript
206
- // lib/search/typesense.ts — typo-tolerant, fast, self-hosted
207
- import Typesense from 'typesense';
208
-
209
- const client = new Typesense.Client({
210
- nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: 'https' }],
211
- apiKey: process.env.TYPESENSE_API_KEY!,
212
- connectionTimeoutSeconds: 2,
213
- });
214
-
215
- const SCHEMA = {
216
- name: 'posts',
217
- fields: [
218
- { name: 'id', type: 'string' as const },
219
- { name: 'title', type: 'string' as const },
220
- { name: 'excerpt', type: 'string' as const },
221
- { name: 'tags', type: 'string[]' as const, facet: true },
222
- { name: 'category', type: 'string' as const, facet: true },
223
- { name: 'publishedAt', type: 'int64' as const, sort: true },
224
- ],
225
- default_sorting_field: 'publishedAt',
226
- };
227
-
228
- export async function upsertPost(post: Post) {
229
- await client.collections('posts').documents().upsert({
230
- id: post.slug,
231
- title: post.title,
232
- excerpt: post.excerpt ?? '',
233
- tags: post.tags ?? [],
234
- category: post.category ?? 'uncategorized',
235
- publishedAt: Math.floor(new Date(post.publishedAt).getTime() / 1000),
236
- });
237
- }
238
- ```
239
-
240
- ---
241
-
242
- ## Newsletter & Email Integration
243
-
244
- ### Resend — Transactional + Drip
245
-
246
- ```typescript
247
- // lib/email/resend.ts
248
- import { Resend } from 'resend';
249
-
250
- const resend = new Resend(process.env.RESEND_API_KEY!);
251
-
252
- // Add subscriber to audience
253
- export async function subscribeToNewsletter(email: string, name?: string) {
254
- await resend.contacts.create({
255
- email,
256
- firstName: name?.split(' ')[0],
257
- audienceId: process.env.RESEND_AUDIENCE_ID!,
258
- unsubscribed: false,
259
- });
260
- }
261
-
262
- // Send new post notification
263
- export async function sendNewPostEmail(post: Post, subscribers: string[]) {
264
- await resend.batch.send(
265
- subscribers.map(to => ({
266
- from: 'blog@yourdomain.com',
267
- to,
268
- subject: `New post: ${post.title}`,
269
- react: NewPostEmail({ post }),
270
- }))
271
- );
272
- }
273
-
274
- // Email capture form — app/api/subscribe/route.ts
275
- export async function POST(req: Request) {
276
- const { email } = await req.json();
277
- if (!email || !email.includes('@')) {
278
- return Response.json({ error: 'Invalid email' }, { status: 400 });
279
- }
280
- await subscribeToNewsletter(email);
281
- return Response.json({ success: true });
282
- }
283
- ```
284
-
285
- ### RSS-to-Email (Mailchimp)
286
-
287
- ```typescript
288
- // scripts/rss-to-email.ts — run via cron after new post published
289
- import Parser from 'rss-parser';
290
- import mailchimp from '@mailchimp/mailchimp_marketing';
291
-
292
- mailchimp.setConfig({ apiKey: process.env.MAILCHIMP_API_KEY!, server: process.env.MAILCHIMP_SERVER! });
293
-
294
- async function sendLatestPost() {
295
- const parser = new Parser();
296
- const feed = await parser.parseURL(`${process.env.SITE_URL}/feed.xml`);
297
- const latest = feed.items[0];
298
- if (!latest) return;
299
-
300
- // Check if we already sent this post (store last sent GUID)
301
- const lastSent = process.env.LAST_SENT_GUID;
302
- if (latest.guid === lastSent) return;
303
-
304
- await mailchimp.campaigns.create({
305
- type: 'regular',
306
- recipients: { list_id: process.env.MAILCHIMP_LIST_ID! },
307
- settings: {
308
- subject_line: latest.title ?? 'New post',
309
- from_name: 'Your Blog',
310
- reply_to: 'blog@yourdomain.com',
311
- },
312
- });
313
- }
314
- ```
315
-
316
- ### Drip Sequence Pattern
317
-
318
- ```typescript
319
- // lib/email/drip.ts — trigger drip on signup
320
- const DRIP_SEQUENCE = [
321
- { delayDays: 0, subject: 'Welcome! Start here', template: 'welcome' },
322
- { delayDays: 3, subject: 'Our most popular posts', template: 'best-of' },
323
- { delayDays: 7, subject: 'Tips for getting started', template: 'tips' },
324
- { delayDays: 14, subject: 'Here\'s what\'s new', template: 'digest' },
325
- ];
326
-
327
- export async function startDripSequence(email: string) {
328
- for (const step of DRIP_SEQUENCE) {
329
- await resend.emails.send({
330
- from: 'hello@yourdomain.com',
331
- to: email,
332
- subject: step.subject,
333
- react: getDripTemplate(step.template),
334
- scheduledAt: new Date(Date.now() + step.delayDays * 86400_000).toISOString(),
335
- });
336
- }
337
- }
338
- ```
339
-
340
- ---
341
-
342
- ## Content Performance Optimization
343
-
344
- ### Image Optimization
345
-
346
- ```typescript
347
- // next.config.ts — image optimization config
348
- const config = {
349
- images: {
350
- formats: ['image/avif', 'image/webp'],
351
- deviceSizes: [640, 750, 828, 1080, 1200, 1920],
352
- imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
353
- remotePatterns: [
354
- { protocol: 'https', hostname: 'cdn.sanity.io' },
355
- { protocol: 'https', hostname: 'images.ctfassets.net' },
356
- ],
357
- minimumCacheTTL: 60 * 60 * 24 * 7, // 1 week
358
- },
359
- };
360
-
361
- // Sharp preprocessing for CMS images
362
- import sharp from 'sharp';
363
- import { writeFile } from 'fs/promises';
364
- import { join } from 'path';
365
-
366
- async function optimizeCmsImage(url: string, slug: string): Promise<string> {
367
- const res = await fetch(url);
368
- const buffer = Buffer.from(await res.arrayBuffer());
369
- const outputPath = join('public', 'images', `${slug}.webp`);
370
- await sharp(buffer)
371
- .resize(1200, 630, { fit: 'cover', position: 'attention' }) // smart crop for OG
372
- .webp({ quality: 85 })
373
- .toFile(outputPath);
374
- return `/images/${slug}.webp`;
375
- }
376
-
377
- // BlurDataURL for all CMS images — prevents layout shift
378
- async function getBlurDataUrl(url: string): Promise<string> {
379
- const res = await fetch(url);
380
- const buffer = Buffer.from(await res.arrayBuffer());
381
- const { data, info } = await sharp(buffer)
382
- .resize(8, 8, { fit: 'inside' })
383
- .toBuffer({ resolveWithObject: true });
384
- return `data:image/${info.format};base64,${data.toString('base64')}`;
385
- }
386
- ```
387
-
388
- ### ISR / SSG Strategy
389
-
390
- ```typescript
391
- // ISR with smart revalidation windows
392
- // High-traffic pages: short TTL. Archive pages: long TTL.
393
- export async function generateStaticParams() {
394
- const posts = await getAllPublishedPosts();
395
- // Pre-render recent 50 posts; rest generated on-demand
396
- return posts.slice(0, 50).map(p => ({ slug: p.slug }));
397
- }
398
-
399
- export const revalidate = 3600; // 1h default — override per page
400
-
401
- // app/blog/[slug]/page.tsx — dynamic revalidation based on post age
402
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
403
- const post = await getPost(params.slug);
404
- const ageInDays = (Date.now() - new Date(post.publishedAt).getTime()) / 86400_000;
405
- // Older posts change less — handled via headers or route segment config
406
- return createMetadata({ title: post.title, description: post.excerpt, path: `/blog/${post.slug}` });
407
- }
408
-
409
- // On-demand revalidation endpoint (works with any CMS webhook)
410
- // app/api/revalidate/route.ts
411
- export async function POST(req: Request) {
412
- const { secret, paths } = await req.json();
413
- if (secret !== process.env.REVALIDATE_SECRET) {
414
- return Response.json({ error: 'Invalid secret' }, { status: 401 });
415
- }
416
- const { revalidatePath } = await import('next/cache');
417
- for (const path of paths as string[]) {
418
- revalidatePath(path);
419
- }
420
- return Response.json({ revalidated: paths });
421
- }
422
- ```
423
-
424
- ### Core Web Vitals for Content Sites
425
-
426
- ```typescript
427
- // lib/vitals.ts — report to analytics
428
- import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from 'web-vitals';
429
-
430
- function sendToAnalytics(metric: Metric) {
431
- navigator.sendBeacon('/api/vitals', JSON.stringify({
432
- name: metric.name,
433
- value: metric.value,
434
- rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
435
- path: window.location.pathname,
436
- }));
437
- }
438
-
439
- export function initVitals() {
440
- onLCP(sendToAnalytics); // Largest Contentful Paint — target < 2.5s
441
- onINP(sendToAnalytics); // Interaction to Next Paint — target < 200ms
442
- onCLS(sendToAnalytics); // Cumulative Layout Shift — target < 0.1
443
- onFCP(sendToAnalytics);
444
- onTTFB(sendToAnalytics);
445
- }
446
-
447
- // Common CLS fixes for content sites:
448
- // 1. Reserve space for images: always set width + height on <img> or use aspect-ratio
449
- // 2. Font loading: font-display: optional or swap + preload critical fonts
450
- // 3. Ad slots: min-height: <expected-height>px before ad loads
451
- // 4. Avoid inserting DOM nodes above fold after page load
452
- ```
453
-
454
- ---
455
-
456
- ## Content Analytics Integration
457
-
458
- ### Page Views + Read Time
459
-
460
- ```typescript
461
- // lib/analytics/content.ts — track engagement without bloating bundle
462
- export interface ContentEvent {
463
- type: 'view' | 'read_complete' | 'scroll_depth' | 'share';
464
- slug: string;
465
- value?: number; // scroll % for scroll_depth, read seconds for read_complete
466
- }
467
-
468
- // app/api/analytics/route.ts — lightweight ingestion endpoint
469
- export async function POST(req: Request) {
470
- const event: ContentEvent = await req.json();
471
- // Write to your analytics DB (PocketBase, Supabase, Tinybird, etc.)
472
- await db.collection('content_events').create({
473
- ...event,
474
- ip: req.headers.get('x-forwarded-for')?.split(',')[0],
475
- ua: req.headers.get('user-agent'),
476
- timestamp: new Date().toISOString(),
477
- });
478
- return new Response(null, { status: 204 });
479
- }
480
-
481
- // components/analytics/ReadTracker.tsx — client component
482
- 'use client';
483
- import { useEffect, useRef } from 'react';
484
-
485
- export function ReadTracker({ slug }: { slug: string }) {
486
- const startedAt = useRef(Date.now());
487
- const reported = useRef(false);
488
-
489
- useEffect(() => {
490
- // Fire view on mount
491
- navigator.sendBeacon('/api/analytics', JSON.stringify({ type: 'view', slug }));
492
-
493
- // Fire read_complete after 60% of estimated reading time on page
494
- return () => {
495
- if (!reported.current) {
496
- const seconds = Math.floor((Date.now() - startedAt.current) / 1000);
497
- navigator.sendBeacon('/api/analytics', JSON.stringify({ type: 'read_complete', slug, value: seconds }));
498
- reported.current = true;
499
- }
500
- };
501
- }, [slug]);
502
-
503
- return null;
504
- }
505
- ```
506
-
507
- ### Scroll Depth Tracking
508
-
509
- ```typescript
510
- // hooks/useScrollDepth.ts
511
- 'use client';
512
- import { useEffect, useRef } from 'react';
513
-
514
- const CHECKPOINTS = [25, 50, 75, 90, 100];
515
-
516
- export function useScrollDepth(slug: string) {
517
- const reached = useRef(new Set<number>());
518
-
519
- useEffect(() => {
520
- function onScroll() {
521
- const el = document.documentElement;
522
- const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100);
523
- for (const checkpoint of CHECKPOINTS) {
524
- if (pct >= checkpoint && !reached.current.has(checkpoint)) {
525
- reached.current.add(checkpoint);
526
- navigator.sendBeacon('/api/analytics', JSON.stringify({
527
- type: 'scroll_depth', slug, value: checkpoint,
528
- }));
529
- }
530
- }
531
- }
532
-
533
- window.addEventListener('scroll', onScroll, { passive: true });
534
- return () => window.removeEventListener('scroll', onScroll);
535
- }, [slug]);
536
- }
537
- ```
538
-
539
- ### Post View Counter
540
-
541
- ```typescript
542
- // Display view counts — cached to avoid N+1 queries
543
- // app/blog/[slug]/ViewCounter.tsx
544
- import { unstable_cache } from 'next/cache';
545
-
546
- const getViewCount = unstable_cache(
547
- async (slug: string) => {
548
- const result = await db.collection('content_events')
549
- .filter(`slug = "${slug}" && type = "view"`)
550
- .count();
551
- return result;
552
- },
553
- ['view-count'],
554
- { revalidate: 300 } // refresh every 5 minutes
555
- );
556
-
557
- export async function ViewCounter({ slug }: { slug: string }) {
558
- const count = await getViewCount(slug);
559
- return (
560
- <span className="text-sm text-gray-500">
561
- {new Intl.NumberFormat('en-US').format(count)} views
562
- </span>
563
- );
564
- }
565
- ```
566
-
567
- ---
568
-
569
- ## Content Scheduling & Workflows
570
-
571
- ### Draft / Review / Publish Pipeline
572
-
573
- ```typescript
574
- // Contentlayer — status field drives pipeline
575
- // Statuses: draft → in-review → approved → scheduled → published → archived
576
-
577
- // lib/content-workflow.ts
578
- type ContentStatus = 'draft' | 'in-review' | 'approved' | 'scheduled' | 'published' | 'archived';
579
-
580
- interface WorkflowTransition {
581
- from: ContentStatus;
582
- to: ContentStatus;
583
- requiredRole: 'author' | 'editor' | 'admin';
584
- }
585
-
586
- const ALLOWED_TRANSITIONS: WorkflowTransition[] = [
587
- { from: 'draft', to: 'in-review', requiredRole: 'author' },
588
- { from: 'in-review', to: 'approved', requiredRole: 'editor' },
589
- { from: 'in-review', to: 'draft', requiredRole: 'editor' }, // request changes
590
- { from: 'approved', to: 'scheduled', requiredRole: 'editor' },
591
- { from: 'approved', to: 'published', requiredRole: 'editor' },
592
- { from: 'scheduled', to: 'published', requiredRole: 'admin' }, // cron triggers this
593
- { from: 'published', to: 'archived', requiredRole: 'admin' },
594
- ];
595
-
596
- export function canTransition(from: ContentStatus, to: ContentStatus, role: string): boolean {
597
- return ALLOWED_TRANSITIONS.some(t => t.from === from && t.to === to && t.requiredRole === role);
598
- }
599
- ```
600
-
601
- ### Scheduled Publishing
602
-
603
- ```typescript
604
- // app/api/cron/publish-scheduled/route.ts — trigger via Vercel Cron or GitHub Actions
605
- export async function GET(req: Request) {
606
- const authHeader = req.headers.get('authorization');
607
- if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
608
- return new Response('Unauthorized', { status: 401 });
609
- }
610
-
611
- const now = new Date().toISOString();
612
- // Find posts scheduled to publish before now
613
- const due = await db.getScheduledPostsDue(now);
614
-
615
- const results = await Promise.allSettled(
616
- due.map(async post => {
617
- await db.updatePostStatus(post.id, 'published');
618
- await indexPost(post); // add to search index
619
- await revalidatePath('/blog'); // clear ISR cache
620
- await revalidatePath(`/blog/${post.slug}`);
621
- await notifySubscribers(post); // optional email blast
622
- })
623
- );
624
-
625
- return Response.json({ published: due.length, results: results.map(r => r.status) });
626
- }
627
-
628
- // vercel.json — schedule the cron
629
- // { "crons": [{ "path": "/api/cron/publish-scheduled", "schedule": "*/15 * * * *" }] }
630
- ```
631
-
632
- ### Content Calendar (Minimal)
633
-
634
- ```typescript
635
- // lib/content-calendar.ts — read from CMS, render calendar view
636
- interface CalendarEntry {
637
- title: string;
638
- slug: string;
639
- scheduledAt: Date;
640
- status: ContentStatus;
641
- author: string;
642
- }
643
-
644
- export async function getContentCalendar(startDate: Date, endDate: Date): Promise<CalendarEntry[]> {
645
- const posts = await db.getPosts({
646
- status: ['draft', 'in-review', 'approved', 'scheduled', 'published'],
647
- dateRange: { start: startDate, end: endDate },
648
- });
649
- return posts.map(p => ({
650
- title: p.title,
651
- slug: p.slug,
652
- scheduledAt: new Date(p.scheduledAt ?? p.publishedAt),
653
- status: p.status,
654
- author: p.author.name,
655
- }));
656
- }
657
- ```
658
-
659
- ---
660
-
661
- ## Accessibility for Content
662
-
663
- ### Alt Text Automation
664
-
665
- ```typescript
666
- // scripts/audit-alt-text.ts — find images missing alt in MDX files
667
- import { glob } from 'glob';
668
- import { readFile } from 'fs/promises';
669
-
670
- const IMG_REGEX = /!\[([^\]]*)\]\([^)]+\)|<img[^>]+>/g;
671
-
672
- async function auditAltText(dir: string) {
673
- const files = await glob(`${dir}/**/*.mdx`);
674
- const issues: { file: string; line: number; src: string }[] = [];
675
-
676
- for (const file of files) {
677
- const content = await readFile(file, 'utf-8');
678
- const lines = content.split('\n');
679
- lines.forEach((line, i) => {
680
- const matches = line.matchAll(IMG_REGEX);
681
- for (const match of matches) {
682
- const isMarkdown = match[0].startsWith('![');
683
- const isEmpty = isMarkdown ? match[1].trim() === '' : !match[0].includes('alt=') || match[0].includes('alt=""');
684
- if (isEmpty) issues.push({ file, line: i + 1, src: match[0].slice(0, 60) });
685
- }
686
- });
687
- }
688
-
689
- if (issues.length) {
690
- console.error(`Found ${issues.length} images with missing/empty alt text:`);
691
- issues.forEach(i => console.error(` ${i.file}:${i.line} → ${i.src}`));
692
- process.exit(1);
693
- }
694
- }
695
-
696
- // Auto-generate alt text using AI (optional, for CMS images without alt)
697
- async function suggestAltText(imageUrl: string): Promise<string> {
698
- // Call Claude claude-haiku-4-5 — fast, cheap for image description
699
- const res = await fetch('https://api.anthropic.com/v1/messages', {
700
- method: 'POST',
701
- headers: { 'x-api-key': process.env.ANTHROPIC_API_KEY!, 'content-type': 'application/json', 'anthropic-version': '2023-06-01' },
702
- body: JSON.stringify({
703
- model: 'claude-haiku-4-5',
704
- max_tokens: 100,
705
- messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'url', url: imageUrl } }, { type: 'text', text: 'Write a concise alt text for this image (max 125 chars, no "image of").' }] }],
706
- }),
707
- });
708
- const data = await res.json();
709
- return data.content[0].text.trim();
710
- }
711
- ```
712
-
713
- ### Reading Level Analysis
714
-
715
- ```typescript
716
- // lib/content/readability.ts — Flesch-Kincaid reading ease
717
- export function fleschKincaid(text: string): { score: number; level: string } {
718
- const sentences = text.split(/[.!?]+/).filter(Boolean).length;
719
- const words = text.trim().split(/\s+/).length;
720
- const syllables = countSyllables(text);
721
-
722
- if (words === 0 || sentences === 0) return { score: 0, level: 'unknown' };
723
-
724
- const score = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
725
- const level =
726
- score >= 70 ? 'Easy (6th grade)' :
727
- score >= 50 ? 'Moderate (10th grade)' :
728
- score >= 30 ? 'Difficult (College)' : 'Very Difficult (Professional)';
729
-
730
- return { score: Math.round(score), level };
731
- }
732
-
733
- function countSyllables(text: string): number {
734
- return text
735
- .toLowerCase()
736
- .replace(/[^a-z]/g, ' ')
737
- .split(/\s+/)
738
- .reduce((acc, word) => {
739
- const count = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '')
740
- .replace(/^y/, '')
741
- .match(/[aeiouy]{1,2}/g)?.length ?? 1;
742
- return acc + count;
743
- }, 0);
744
- }
745
- ```
746
-
747
- ### Semantic Markup for Articles
748
-
749
- ```tsx
750
- // components/Article.tsx — correct semantic structure
751
- export function Article({ post }: { post: Post }) {
752
- return (
753
- <article itemScope itemType="https://schema.org/BlogPosting">
754
- <header>
755
- <h1 itemProp="headline">{post.title}</h1>
756
- <p>
757
- By{' '}
758
- <span itemProp="author" itemScope itemType="https://schema.org/Person">
759
- <span itemProp="name">{post.author.name}</span>
760
- </span>
761
- {' · '}
762
- <time itemProp="datePublished" dateTime={post.publishedAt}>
763
- {new Date(post.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
764
- </time>
765
- {' · '}
766
- <span>{post.readingTime}</span>
767
- </p>
768
- </header>
769
-
770
- <nav aria-label="Table of contents">
771
- <ol>
772
- {post.toc.map(item => (
773
- <li key={item.id} style={{ paddingLeft: `${(item.level - 2) * 16}px` }}>
774
- <a href={`#${item.id}`}>{item.text}</a>
775
- </li>
776
- ))}
777
- </ol>
778
- </nav>
779
-
780
- <section itemProp="articleBody" aria-label="Article content">
781
- {post.content}
782
- </section>
783
-
784
- <footer>
785
- <nav aria-label="Post tags">
786
- {post.tags.map(tag => (
787
- <a key={tag} href={`/blog?tag=${tag}`} rel="tag">{tag}</a>
788
- ))}
789
- </nav>
790
- </footer>
791
- </article>
792
- );
793
- }
794
- ```
795
-
796
- ---
797
-
798
- ## Rich Media Embedding
799
-
800
- ### Video Embeds in MDX
801
-
802
- ```tsx
803
- // components/mdx/VideoEmbed.tsx — lazy, privacy-respecting YouTube embed
804
- 'use client';
805
- import { useState } from 'react';
806
- import Image from 'next/image';
807
-
808
- interface VideoEmbedProps {
809
- id: string;
810
- title: string;
811
- provider?: 'youtube' | 'vimeo';
812
- }
813
-
814
- export function VideoEmbed({ id, title, provider = 'youtube' }: VideoEmbedProps) {
815
- const [loaded, setLoaded] = useState(false);
816
-
817
- const thumb = `https://img.youtube.com/vi/${id}/maxresdefault.jpg`;
818
- const src =
819
- provider === 'youtube'
820
- ? `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0`
821
- : `https://player.vimeo.com/video/${id}?autoplay=1`;
822
-
823
- return (
824
- <div className="relative aspect-video rounded-lg overflow-hidden bg-gray-900 my-6">
825
- {!loaded ? (
826
- <button
827
- className="w-full h-full group"
828
- aria-label={`Play video: ${title}`}
829
- onClick={() => setLoaded(true)}
830
- >
831
- <Image src={thumb} alt={title} fill className="object-cover opacity-80 group-hover:opacity-100 transition-opacity" />
832
- <div className="absolute inset-0 flex items-center justify-center">
833
- <div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
834
- <svg viewBox="0 0 24 24" fill="white" className="w-6 h-6 ml-1" aria-hidden="true">
835
- <path d="M8 5v14l11-7z" />
836
- </svg>
837
- </div>
838
- </div>
839
- </button>
840
- ) : (
841
- <iframe
842
- src={src}
843
- title={title}
844
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
845
- allowFullScreen
846
- className="absolute inset-0 w-full h-full"
847
- />
848
- )}
849
- </div>
850
- );
851
- }
852
-
853
- // Usage in MDX:
854
- // <VideoEmbed id="dQw4w9WgXcQ" title="Getting started with Next.js" />
855
- ```
856
-
857
- ### Image Gallery
858
-
859
- ```tsx
860
- // components/mdx/Gallery.tsx — lightbox image gallery
861
- 'use client';
862
- import { useState } from 'react';
863
- import Image from 'next/image';
864
-
865
- interface GalleryImage { src: string; alt: string; caption?: string }
866
-
867
- export function Gallery({ images }: { images: GalleryImage[] }) {
868
- const [selected, setSelected] = useState<number | null>(null);
869
-
870
- return (
871
- <>
872
- <div className="grid grid-cols-2 md:grid-cols-3 gap-2 my-6">
873
- {images.map((img, i) => (
874
- <button
875
- key={i}
876
- onClick={() => setSelected(i)}
877
- className="relative aspect-square rounded overflow-hidden group"
878
- aria-label={`View ${img.alt}`}
879
- >
880
- <Image src={img.src} alt={img.alt} fill className="object-cover group-hover:scale-105 transition-transform" />
881
- </button>
882
- ))}
883
- </div>
884
-
885
- {selected !== null && (
886
- <div
887
- role="dialog"
888
- aria-modal="true"
889
- aria-label="Image lightbox"
890
- className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
891
- onClick={() => setSelected(null)}
892
- >
893
- <div className="relative max-w-4xl w-full" onClick={e => e.stopPropagation()}>
894
- <Image
895
- src={images[selected].src}
896
- alt={images[selected].alt}
897
- width={1200}
898
- height={800}
899
- className="rounded-lg object-contain"
900
- />
901
- {images[selected].caption && (
902
- <p className="text-white/70 text-sm text-center mt-2">{images[selected].caption}</p>
903
- )}
904
- <button
905
- onClick={() => setSelected(null)}
906
- className="absolute top-2 right-2 text-white bg-black/50 rounded-full w-8 h-8 flex items-center justify-center"
907
- aria-label="Close lightbox"
908
- >
909
-
910
- </button>
911
- </div>
912
- </div>
913
- )}
914
- </>
915
- );
916
- }
917
- ```
918
-
919
- ### Code Playground (Interactive)
920
-
921
- ```tsx
922
- // components/mdx/CodePlayground.tsx — Sandpack integration
923
- import { Sandpack } from '@codesandbox/sandpack-react';
924
- import { githubLight } from '@codesandbox/sandpack-themes';
925
-
926
- interface PlaygroundProps {
927
- files: Record<string, string>;
928
- entry?: string;
929
- template?: 'react' | 'react-ts' | 'vanilla' | 'nextjs';
930
- }
931
-
932
- export function CodePlayground({ files, entry = '/App.tsx', template = 'react-ts' }: PlaygroundProps) {
933
- return (
934
- <div className="my-6 rounded-lg overflow-hidden border border-gray-200">
935
- <Sandpack
936
- template={template}
937
- files={files}
938
- options={{
939
- showNavigator: false,
940
- showTabs: Object.keys(files).length > 1,
941
- editorHeight: 320,
942
- activeFile: entry,
943
- }}
944
- theme={githubLight}
945
- />
946
- </div>
947
- );
948
- }
949
-
950
- // Usage in MDX:
951
- // <CodePlayground
952
- // files={{ '/App.tsx': "export default function App() { return <h1>Hello!</h1> }" }}
953
- // />
954
- ```
955
-
956
- ---
957
-
958
- ## Integration Patterns
959
-
960
- **content + analytics**: Fire `content_view`, `scroll_depth`, and `read_complete` events from content pages into the analytics warehouse. Use `@rune/analytics` sql-patterns skill to build read-time dashboards.
961
-
962
- **content + ui**: Share design tokens and typography scale. MDX custom components (Callout, CodeBlock, Gallery) follow the same design system as app UI components — import from shared `@/components/ui` rather than duplicating.
963
-
964
- **content + saas**: Gate premium posts behind subscription check middleware. Redirect unauthenticated users to upgrade page. Use `@rune/saas` auth patterns for session validation in server components.
965
-
966
- **content + ecommerce**: Inject product cards into MDX via `<ProductCard sku="...">` component that pulls live inventory data. Track affiliate link clicks as conversion events.
967
-
968
- ---
969
-
970
- ## Tech Stack Support
971
-
972
- | Area | Options | Notes |
973
- |------|---------|-------|
974
- | Blog Framework | Contentlayer, MDX, Velite | Contentlayer most mature for Next.js |
975
- | Headless CMS | Sanity, Contentful, Strapi, PocketBase | Sanity best DX; PocketBase self-hosted |
976
- | MDX | next-mdx-remote, mdx-bundler, @next/mdx | next-mdx-remote for dynamic content |
977
- | i18n | next-intl, i18next, Paraglide | next-intl for App Router |
978
- | SEO | Next.js Metadata API, next-seo | Metadata API built-in since Next.js 13 |
979
- | Search | Algolia, Meilisearch, Typesense | Meilisearch for self-hosted; Algolia for managed |
980
- | Email | Resend, Mailchimp, ConvertKit | Resend for dev DX; Mailchimp for large lists |
981
- | Images | sharp, next/image, Cloudinary | sharp for pre-processing; next/image for runtime |
982
- | Analytics | Plausible, Tinybird, custom | Plausible for privacy-first; Tinybird for scale |
983
- | Syntax | Shiki, Prism | Shiki recommended — themes match VS Code |
984
- | Playground | Sandpack, CodeMirror | Sandpack for full browser environments |
985
-
986
- ---
987
-
988
- ## Constraints
989
-
990
- 1. MUST validate all CMS content against a schema before rendering — malformed data from CMS should not crash pages.
991
- 2. MUST include `hreflang` tags on all locale-specific pages — missing hreflang hurts international SEO ranking.
992
- 3. MUST NOT hardcode strings in components when i18n is configured — every user-visible string goes through the translation system.
993
- 4. MUST generate sitemap dynamically from actual content — static sitemaps go stale and list nonexistent pages.
994
- 5. MUST provide fallback for missing MDX components — a missing custom component should render a warning, not crash the build.
995
- 6. MUST set `width` + `height` on all images to prevent CLS — layout shift is a Core Web Vitals failure and SEO penalty.
996
- 7. MUST redirect old CMS URLs permanently (301) before go-live — 302 redirects are not followed by search engines for link equity.
997
- 8. MUST NOT expose Algolia/Meilisearch admin/write keys to the client — use separate search-only keys in frontend code.
998
-
999
- ---
1000
-
1001
- ## Done When
1002
-
1003
- - Blog system serves paginated posts with RSS feed and reading time
1004
- - CMS integration has preview mode, webhook revalidation, and content validation
1005
- - MDX pipeline renders custom components with fallback for missing ones
1006
- - All user-facing strings go through i18n with fallback chain configured
1007
- - Every public page has unique title, description, OG tags, canonical URL, and JSON-LD
1008
- - Search index stays in sync via publish webhook
1009
- - Newsletter capture and email delivery configured and tested
1010
- - Images optimized to WebP/AVIF with correct dimensions (no CLS)
1011
- - Core Web Vitals reporter active and LCP < 2.5s on key pages
1012
- - Video repurposing pipeline producing platform-ready vertical clips with captions
1013
- - Content scoring providing actionable improvement suggestions per dimension
1014
- - Structured report emitted for each skill invoked
1
+ ---
2
+ name: "reference"
3
+ pack: "@rune/content"
4
+ description: "Shared reference patterns: content migration, search integration, newsletter/email, performance optimization, analytics, scheduling, accessibility, and rich media."
5
+ model: sonnet
6
+ tools: [Read, Edit, Write, Grep, Glob, Bash]
7
+ ---
8
+
9
+ # @rune/content — Shared Reference Patterns
10
+
11
+ Supplementary patterns shared across multiple skills in this pack.
12
+
13
+ ---
14
+
15
+ ## Content Migration Checklist
16
+
17
+ Use when moving content between CMS platforms (e.g., WordPress → Sanity, Contentful → Strapi).
18
+
19
+ ### Pre-Migration
20
+
21
+ - [ ] Export full content inventory — slugs, titles, dates, authors, categories, tags
22
+ - [ ] Map old content types to new schema — document every field mapping
23
+ - [ ] Identify broken or orphaned content before migrating (not worth moving)
24
+ - [ ] Capture all existing URLs for redirect mapping (critical for SEO)
25
+ - [ ] Screenshot or snapshot top-10 pages for visual regression after migration
26
+ - [ ] Check for custom fields or plugins in old CMS — equivalent needed in new CMS
27
+
28
+ ### URL Redirect Strategy
29
+
30
+ ```typescript
31
+ // Next.js next.config.ts — static redirect map from old CMS slugs
32
+ const redirects: { source: string; destination: string; permanent: boolean }[] = [
33
+ { source: '/2023/01/my-old-post', destination: '/blog/my-old-post', permanent: true },
34
+ { source: '/category/tech', destination: '/blog?category=tech', permanent: true },
35
+ // WordPress date-based URLs → clean slugs
36
+ { source: '/\\d{4}/\\d{2}/\\d{2}/:slug', destination: '/blog/:slug', permanent: true },
37
+ ];
38
+
39
+ // For large sites: load from JSON file
40
+ import redirectMap from './redirects.json';
41
+
42
+ export default {
43
+ async redirects() {
44
+ return redirectMap.map(({ from, to }) => ({
45
+ source: from,
46
+ destination: to,
47
+ permanent: true,
48
+ }));
49
+ },
50
+ };
51
+
52
+ // Validate no 404s after migration — scripts/check-redirects.ts
53
+ async function checkRedirects(redirects: Array<{ source: string; destination: string }>) {
54
+ const results = await Promise.allSettled(
55
+ redirects.map(async ({ source }) => {
56
+ const res = await fetch(`${process.env.SITE_URL}${source}`, { redirect: 'manual' });
57
+ if (res.status !== 301 && res.status !== 308) {
58
+ throw new Error(`${source} returned ${res.status}`);
59
+ }
60
+ })
61
+ );
62
+ const failures = results.filter(r => r.status === 'rejected');
63
+ if (failures.length) console.error('Redirect failures:', failures);
64
+ }
65
+ ```
66
+
67
+ ### Data Mapping
68
+
69
+ ```typescript
70
+ // WordPress XML → Sanity migration script (outline)
71
+ import { parse } from 'node-html-parser';
72
+ import { createClient } from '@sanity/client';
73
+
74
+ interface WpPost {
75
+ title: string;
76
+ slug: string;
77
+ content: string;
78
+ date: string;
79
+ categories: string[];
80
+ status: 'publish' | 'draft';
81
+ }
82
+
83
+ async function migratePost(wp: WpPost, client: ReturnType<typeof createClient>) {
84
+ return client.create({
85
+ _type: 'post',
86
+ title: wp.title,
87
+ slug: { _type: 'slug', current: wp.slug },
88
+ publishedAt: new Date(wp.date).toISOString(),
89
+ status: wp.status === 'publish' ? 'published' : 'draft',
90
+ // Convert HTML body to Portable Text via @sanity/block-content-to-hyperscript
91
+ body: htmlToPortableText(wp.content),
92
+ });
93
+ }
94
+ ```
95
+
96
+ ### SEO Preservation
97
+
98
+ - [ ] Verify all old URLs return 301 (permanent redirect) not 302
99
+ - [ ] Check canonical tags update to new URLs after migration
100
+ - [ ] Re-submit sitemap to Google Search Console after go-live
101
+ - [ ] Monitor Google Search Console for coverage errors for 30 days post-migration
102
+ - [ ] Preserve `<meta name="description">` content — reuse from old CMS export
103
+ - [ ] Keep same `<title>` patterns where possible — Google re-evaluates after changes
104
+
105
+ ---
106
+
107
+ ## Search Integration
108
+
109
+ ### Algolia
110
+
111
+ ```typescript
112
+ // lib/search/algolia.ts — index content on publish
113
+ import algoliasearch from 'algoliasearch';
114
+
115
+ const client = algoliasearch(
116
+ process.env.ALGOLIA_APP_ID!,
117
+ process.env.ALGOLIA_ADMIN_KEY! // admin key for write; search key for frontend
118
+ );
119
+ const index = client.initIndex('posts');
120
+
121
+ export interface SearchRecord {
122
+ objectID: string;
123
+ title: string;
124
+ excerpt: string;
125
+ slug: string;
126
+ category: string;
127
+ tags: string[];
128
+ publishedAt: number; // unix timestamp for range filtering
129
+ }
130
+
131
+ export async function indexPost(post: Post) {
132
+ await index.saveObject({
133
+ objectID: post.slug,
134
+ title: post.title,
135
+ excerpt: post.excerpt,
136
+ slug: post.slug,
137
+ category: post.category,
138
+ tags: post.tags,
139
+ publishedAt: new Date(post.publishedAt).getTime() / 1000,
140
+ } satisfies SearchRecord);
141
+ }
142
+
143
+ export async function removePost(slug: string) {
144
+ await index.deleteObject(slug);
145
+ }
146
+
147
+ // Frontend search component with InstantSearch
148
+ import { InstantSearch, SearchBox, Hits, Highlight, Configure } from 'react-instantsearch';
149
+ import algoliasearch from 'algoliasearch/lite';
150
+
151
+ const searchClient = algoliasearch(
152
+ process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
153
+ process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY! // read-only key only
154
+ );
155
+
156
+ function BlogSearch() {
157
+ return (
158
+ <InstantSearch searchClient={searchClient} indexName="posts">
159
+ <Configure hitsPerPage={8} />
160
+ <SearchBox placeholder="Search posts..." />
161
+ <Hits hitComponent={({ hit }) => (
162
+ <a href={`/blog/${hit.slug}`}>
163
+ <Highlight attribute="title" hit={hit} />
164
+ <Highlight attribute="excerpt" hit={hit} />
165
+ </a>
166
+ )} />
167
+ </InstantSearch>
168
+ );
169
+ }
170
+ ```
171
+
172
+ ### Meilisearch
173
+
174
+ ```typescript
175
+ // lib/search/meilisearch.ts — self-hosted, zero API cost
176
+ import { MeiliSearch } from 'meilisearch';
177
+
178
+ const client = new MeiliSearch({
179
+ host: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
180
+ apiKey: process.env.MEILISEARCH_MASTER_KEY,
181
+ });
182
+
183
+ const postsIndex = client.index('posts');
184
+
185
+ // Configure searchable and filterable attributes
186
+ await postsIndex.updateSettings({
187
+ searchableAttributes: ['title', 'excerpt', 'tags', 'content'],
188
+ filterableAttributes: ['category', 'tags', 'status'],
189
+ sortableAttributes: ['publishedAt'],
190
+ rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
191
+ });
192
+
193
+ // Search with filters
194
+ export async function searchPosts(query: string, category?: string) {
195
+ return postsIndex.search(query, {
196
+ filter: category ? `category = "${category}" AND status = "published"` : 'status = "published"',
197
+ limit: 10,
198
+ attributesToHighlight: ['title', 'excerpt'],
199
+ });
200
+ }
201
+ ```
202
+
203
+ ### Typesense
204
+
205
+ ```typescript
206
+ // lib/search/typesense.ts — typo-tolerant, fast, self-hosted
207
+ import Typesense from 'typesense';
208
+
209
+ const client = new Typesense.Client({
210
+ nodes: [{ host: process.env.TYPESENSE_HOST!, port: 443, protocol: 'https' }],
211
+ apiKey: process.env.TYPESENSE_API_KEY!,
212
+ connectionTimeoutSeconds: 2,
213
+ });
214
+
215
+ const SCHEMA = {
216
+ name: 'posts',
217
+ fields: [
218
+ { name: 'id', type: 'string' as const },
219
+ { name: 'title', type: 'string' as const },
220
+ { name: 'excerpt', type: 'string' as const },
221
+ { name: 'tags', type: 'string[]' as const, facet: true },
222
+ { name: 'category', type: 'string' as const, facet: true },
223
+ { name: 'publishedAt', type: 'int64' as const, sort: true },
224
+ ],
225
+ default_sorting_field: 'publishedAt',
226
+ };
227
+
228
+ export async function upsertPost(post: Post) {
229
+ await client.collections('posts').documents().upsert({
230
+ id: post.slug,
231
+ title: post.title,
232
+ excerpt: post.excerpt ?? '',
233
+ tags: post.tags ?? [],
234
+ category: post.category ?? 'uncategorized',
235
+ publishedAt: Math.floor(new Date(post.publishedAt).getTime() / 1000),
236
+ });
237
+ }
238
+ ```
239
+
240
+ ---
241
+
242
+ ## Newsletter & Email Integration
243
+
244
+ ### Resend — Transactional + Drip
245
+
246
+ ```typescript
247
+ // lib/email/resend.ts
248
+ import { Resend } from 'resend';
249
+
250
+ const resend = new Resend(process.env.RESEND_API_KEY!);
251
+
252
+ // Add subscriber to audience
253
+ export async function subscribeToNewsletter(email: string, name?: string) {
254
+ await resend.contacts.create({
255
+ email,
256
+ firstName: name?.split(' ')[0],
257
+ audienceId: process.env.RESEND_AUDIENCE_ID!,
258
+ unsubscribed: false,
259
+ });
260
+ }
261
+
262
+ // Send new post notification
263
+ export async function sendNewPostEmail(post: Post, subscribers: string[]) {
264
+ await resend.batch.send(
265
+ subscribers.map(to => ({
266
+ from: 'blog@yourdomain.com',
267
+ to,
268
+ subject: `New post: ${post.title}`,
269
+ react: NewPostEmail({ post }),
270
+ }))
271
+ );
272
+ }
273
+
274
+ // Email capture form — app/api/subscribe/route.ts
275
+ export async function POST(req: Request) {
276
+ const { email } = await req.json();
277
+ if (!email || !email.includes('@')) {
278
+ return Response.json({ error: 'Invalid email' }, { status: 400 });
279
+ }
280
+ await subscribeToNewsletter(email);
281
+ return Response.json({ success: true });
282
+ }
283
+ ```
284
+
285
+ ### RSS-to-Email (Mailchimp)
286
+
287
+ ```typescript
288
+ // scripts/rss-to-email.ts — run via cron after new post published
289
+ import Parser from 'rss-parser';
290
+ import mailchimp from '@mailchimp/mailchimp_marketing';
291
+
292
+ mailchimp.setConfig({ apiKey: process.env.MAILCHIMP_API_KEY!, server: process.env.MAILCHIMP_SERVER! });
293
+
294
+ async function sendLatestPost() {
295
+ const parser = new Parser();
296
+ const feed = await parser.parseURL(`${process.env.SITE_URL}/feed.xml`);
297
+ const latest = feed.items[0];
298
+ if (!latest) return;
299
+
300
+ // Check if we already sent this post (store last sent GUID)
301
+ const lastSent = process.env.LAST_SENT_GUID;
302
+ if (latest.guid === lastSent) return;
303
+
304
+ await mailchimp.campaigns.create({
305
+ type: 'regular',
306
+ recipients: { list_id: process.env.MAILCHIMP_LIST_ID! },
307
+ settings: {
308
+ subject_line: latest.title ?? 'New post',
309
+ from_name: 'Your Blog',
310
+ reply_to: 'blog@yourdomain.com',
311
+ },
312
+ });
313
+ }
314
+ ```
315
+
316
+ ### Drip Sequence Pattern
317
+
318
+ ```typescript
319
+ // lib/email/drip.ts — trigger drip on signup
320
+ const DRIP_SEQUENCE = [
321
+ { delayDays: 0, subject: 'Welcome! Start here', template: 'welcome' },
322
+ { delayDays: 3, subject: 'Our most popular posts', template: 'best-of' },
323
+ { delayDays: 7, subject: 'Tips for getting started', template: 'tips' },
324
+ { delayDays: 14, subject: 'Here\'s what\'s new', template: 'digest' },
325
+ ];
326
+
327
+ export async function startDripSequence(email: string) {
328
+ for (const step of DRIP_SEQUENCE) {
329
+ await resend.emails.send({
330
+ from: 'hello@yourdomain.com',
331
+ to: email,
332
+ subject: step.subject,
333
+ react: getDripTemplate(step.template),
334
+ scheduledAt: new Date(Date.now() + step.delayDays * 86400_000).toISOString(),
335
+ });
336
+ }
337
+ }
338
+ ```
339
+
340
+ ---
341
+
342
+ ## Content Performance Optimization
343
+
344
+ ### Image Optimization
345
+
346
+ ```typescript
347
+ // next.config.ts — image optimization config
348
+ const config = {
349
+ images: {
350
+ formats: ['image/avif', 'image/webp'],
351
+ deviceSizes: [640, 750, 828, 1080, 1200, 1920],
352
+ imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
353
+ remotePatterns: [
354
+ { protocol: 'https', hostname: 'cdn.sanity.io' },
355
+ { protocol: 'https', hostname: 'images.ctfassets.net' },
356
+ ],
357
+ minimumCacheTTL: 60 * 60 * 24 * 7, // 1 week
358
+ },
359
+ };
360
+
361
+ // Sharp preprocessing for CMS images
362
+ import sharp from 'sharp';
363
+ import { writeFile } from 'fs/promises';
364
+ import { join } from 'path';
365
+
366
+ async function optimizeCmsImage(url: string, slug: string): Promise<string> {
367
+ const res = await fetch(url);
368
+ const buffer = Buffer.from(await res.arrayBuffer());
369
+ const outputPath = join('public', 'images', `${slug}.webp`);
370
+ await sharp(buffer)
371
+ .resize(1200, 630, { fit: 'cover', position: 'attention' }) // smart crop for OG
372
+ .webp({ quality: 85 })
373
+ .toFile(outputPath);
374
+ return `/images/${slug}.webp`;
375
+ }
376
+
377
+ // BlurDataURL for all CMS images — prevents layout shift
378
+ async function getBlurDataUrl(url: string): Promise<string> {
379
+ const res = await fetch(url);
380
+ const buffer = Buffer.from(await res.arrayBuffer());
381
+ const { data, info } = await sharp(buffer)
382
+ .resize(8, 8, { fit: 'inside' })
383
+ .toBuffer({ resolveWithObject: true });
384
+ return `data:image/${info.format};base64,${data.toString('base64')}`;
385
+ }
386
+ ```
387
+
388
+ ### ISR / SSG Strategy
389
+
390
+ ```typescript
391
+ // ISR with smart revalidation windows
392
+ // High-traffic pages: short TTL. Archive pages: long TTL.
393
+ export async function generateStaticParams() {
394
+ const posts = await getAllPublishedPosts();
395
+ // Pre-render recent 50 posts; rest generated on-demand
396
+ return posts.slice(0, 50).map(p => ({ slug: p.slug }));
397
+ }
398
+
399
+ export const revalidate = 3600; // 1h default — override per page
400
+
401
+ // app/blog/[slug]/page.tsx — dynamic revalidation based on post age
402
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
403
+ const post = await getPost(params.slug);
404
+ const ageInDays = (Date.now() - new Date(post.publishedAt).getTime()) / 86400_000;
405
+ // Older posts change less — handled via headers or route segment config
406
+ return createMetadata({ title: post.title, description: post.excerpt, path: `/blog/${post.slug}` });
407
+ }
408
+
409
+ // On-demand revalidation endpoint (works with any CMS webhook)
410
+ // app/api/revalidate/route.ts
411
+ export async function POST(req: Request) {
412
+ const { secret, paths } = await req.json();
413
+ if (secret !== process.env.REVALIDATE_SECRET) {
414
+ return Response.json({ error: 'Invalid secret' }, { status: 401 });
415
+ }
416
+ const { revalidatePath } = await import('next/cache');
417
+ for (const path of paths as string[]) {
418
+ revalidatePath(path);
419
+ }
420
+ return Response.json({ revalidated: paths });
421
+ }
422
+ ```
423
+
424
+ ### Core Web Vitals for Content Sites
425
+
426
+ ```typescript
427
+ // lib/vitals.ts — report to analytics
428
+ import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from 'web-vitals';
429
+
430
+ function sendToAnalytics(metric: Metric) {
431
+ navigator.sendBeacon('/api/vitals', JSON.stringify({
432
+ name: metric.name,
433
+ value: metric.value,
434
+ rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
435
+ path: window.location.pathname,
436
+ }));
437
+ }
438
+
439
+ export function initVitals() {
440
+ onLCP(sendToAnalytics); // Largest Contentful Paint — target < 2.5s
441
+ onINP(sendToAnalytics); // Interaction to Next Paint — target < 200ms
442
+ onCLS(sendToAnalytics); // Cumulative Layout Shift — target < 0.1
443
+ onFCP(sendToAnalytics);
444
+ onTTFB(sendToAnalytics);
445
+ }
446
+
447
+ // Common CLS fixes for content sites:
448
+ // 1. Reserve space for images: always set width + height on <img> or use aspect-ratio
449
+ // 2. Font loading: font-display: optional or swap + preload critical fonts
450
+ // 3. Ad slots: min-height: <expected-height>px before ad loads
451
+ // 4. Avoid inserting DOM nodes above fold after page load
452
+ ```
453
+
454
+ ---
455
+
456
+ ## Content Analytics Integration
457
+
458
+ ### Page Views + Read Time
459
+
460
+ ```typescript
461
+ // lib/analytics/content.ts — track engagement without bloating bundle
462
+ export interface ContentEvent {
463
+ type: 'view' | 'read_complete' | 'scroll_depth' | 'share';
464
+ slug: string;
465
+ value?: number; // scroll % for scroll_depth, read seconds for read_complete
466
+ }
467
+
468
+ // app/api/analytics/route.ts — lightweight ingestion endpoint
469
+ export async function POST(req: Request) {
470
+ const event: ContentEvent = await req.json();
471
+ // Write to your analytics DB (PocketBase, Supabase, Tinybird, etc.)
472
+ await db.collection('content_events').create({
473
+ ...event,
474
+ ip: req.headers.get('x-forwarded-for')?.split(',')[0],
475
+ ua: req.headers.get('user-agent'),
476
+ timestamp: new Date().toISOString(),
477
+ });
478
+ return new Response(null, { status: 204 });
479
+ }
480
+
481
+ // components/analytics/ReadTracker.tsx — client component
482
+ 'use client';
483
+ import { useEffect, useRef } from 'react';
484
+
485
+ export function ReadTracker({ slug }: { slug: string }) {
486
+ const startedAt = useRef(Date.now());
487
+ const reported = useRef(false);
488
+
489
+ useEffect(() => {
490
+ // Fire view on mount
491
+ navigator.sendBeacon('/api/analytics', JSON.stringify({ type: 'view', slug }));
492
+
493
+ // Fire read_complete after 60% of estimated reading time on page
494
+ return () => {
495
+ if (!reported.current) {
496
+ const seconds = Math.floor((Date.now() - startedAt.current) / 1000);
497
+ navigator.sendBeacon('/api/analytics', JSON.stringify({ type: 'read_complete', slug, value: seconds }));
498
+ reported.current = true;
499
+ }
500
+ };
501
+ }, [slug]);
502
+
503
+ return null;
504
+ }
505
+ ```
506
+
507
+ ### Scroll Depth Tracking
508
+
509
+ ```typescript
510
+ // hooks/useScrollDepth.ts
511
+ 'use client';
512
+ import { useEffect, useRef } from 'react';
513
+
514
+ const CHECKPOINTS = [25, 50, 75, 90, 100];
515
+
516
+ export function useScrollDepth(slug: string) {
517
+ const reached = useRef(new Set<number>());
518
+
519
+ useEffect(() => {
520
+ function onScroll() {
521
+ const el = document.documentElement;
522
+ const pct = Math.round((el.scrollTop / (el.scrollHeight - el.clientHeight)) * 100);
523
+ for (const checkpoint of CHECKPOINTS) {
524
+ if (pct >= checkpoint && !reached.current.has(checkpoint)) {
525
+ reached.current.add(checkpoint);
526
+ navigator.sendBeacon('/api/analytics', JSON.stringify({
527
+ type: 'scroll_depth', slug, value: checkpoint,
528
+ }));
529
+ }
530
+ }
531
+ }
532
+
533
+ window.addEventListener('scroll', onScroll, { passive: true });
534
+ return () => window.removeEventListener('scroll', onScroll);
535
+ }, [slug]);
536
+ }
537
+ ```
538
+
539
+ ### Post View Counter
540
+
541
+ ```typescript
542
+ // Display view counts — cached to avoid N+1 queries
543
+ // app/blog/[slug]/ViewCounter.tsx
544
+ import { unstable_cache } from 'next/cache';
545
+
546
+ const getViewCount = unstable_cache(
547
+ async (slug: string) => {
548
+ const result = await db.collection('content_events')
549
+ .filter(`slug = "${slug}" && type = "view"`)
550
+ .count();
551
+ return result;
552
+ },
553
+ ['view-count'],
554
+ { revalidate: 300 } // refresh every 5 minutes
555
+ );
556
+
557
+ export async function ViewCounter({ slug }: { slug: string }) {
558
+ const count = await getViewCount(slug);
559
+ return (
560
+ <span className="text-sm text-gray-500">
561
+ {new Intl.NumberFormat('en-US').format(count)} views
562
+ </span>
563
+ );
564
+ }
565
+ ```
566
+
567
+ ---
568
+
569
+ ## Content Scheduling & Workflows
570
+
571
+ ### Draft / Review / Publish Pipeline
572
+
573
+ ```typescript
574
+ // Contentlayer — status field drives pipeline
575
+ // Statuses: draft → in-review → approved → scheduled → published → archived
576
+
577
+ // lib/content-workflow.ts
578
+ type ContentStatus = 'draft' | 'in-review' | 'approved' | 'scheduled' | 'published' | 'archived';
579
+
580
+ interface WorkflowTransition {
581
+ from: ContentStatus;
582
+ to: ContentStatus;
583
+ requiredRole: 'author' | 'editor' | 'admin';
584
+ }
585
+
586
+ const ALLOWED_TRANSITIONS: WorkflowTransition[] = [
587
+ { from: 'draft', to: 'in-review', requiredRole: 'author' },
588
+ { from: 'in-review', to: 'approved', requiredRole: 'editor' },
589
+ { from: 'in-review', to: 'draft', requiredRole: 'editor' }, // request changes
590
+ { from: 'approved', to: 'scheduled', requiredRole: 'editor' },
591
+ { from: 'approved', to: 'published', requiredRole: 'editor' },
592
+ { from: 'scheduled', to: 'published', requiredRole: 'admin' }, // cron triggers this
593
+ { from: 'published', to: 'archived', requiredRole: 'admin' },
594
+ ];
595
+
596
+ export function canTransition(from: ContentStatus, to: ContentStatus, role: string): boolean {
597
+ return ALLOWED_TRANSITIONS.some(t => t.from === from && t.to === to && t.requiredRole === role);
598
+ }
599
+ ```
600
+
601
+ ### Scheduled Publishing
602
+
603
+ ```typescript
604
+ // app/api/cron/publish-scheduled/route.ts — trigger via Vercel Cron or GitHub Actions
605
+ export async function GET(req: Request) {
606
+ const authHeader = req.headers.get('authorization');
607
+ if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
608
+ return new Response('Unauthorized', { status: 401 });
609
+ }
610
+
611
+ const now = new Date().toISOString();
612
+ // Find posts scheduled to publish before now
613
+ const due = await db.getScheduledPostsDue(now);
614
+
615
+ const results = await Promise.allSettled(
616
+ due.map(async post => {
617
+ await db.updatePostStatus(post.id, 'published');
618
+ await indexPost(post); // add to search index
619
+ await revalidatePath('/blog'); // clear ISR cache
620
+ await revalidatePath(`/blog/${post.slug}`);
621
+ await notifySubscribers(post); // optional email blast
622
+ })
623
+ );
624
+
625
+ return Response.json({ published: due.length, results: results.map(r => r.status) });
626
+ }
627
+
628
+ // vercel.json — schedule the cron
629
+ // { "crons": [{ "path": "/api/cron/publish-scheduled", "schedule": "*/15 * * * *" }] }
630
+ ```
631
+
632
+ ### Content Calendar (Minimal)
633
+
634
+ ```typescript
635
+ // lib/content-calendar.ts — read from CMS, render calendar view
636
+ interface CalendarEntry {
637
+ title: string;
638
+ slug: string;
639
+ scheduledAt: Date;
640
+ status: ContentStatus;
641
+ author: string;
642
+ }
643
+
644
+ export async function getContentCalendar(startDate: Date, endDate: Date): Promise<CalendarEntry[]> {
645
+ const posts = await db.getPosts({
646
+ status: ['draft', 'in-review', 'approved', 'scheduled', 'published'],
647
+ dateRange: { start: startDate, end: endDate },
648
+ });
649
+ return posts.map(p => ({
650
+ title: p.title,
651
+ slug: p.slug,
652
+ scheduledAt: new Date(p.scheduledAt ?? p.publishedAt),
653
+ status: p.status,
654
+ author: p.author.name,
655
+ }));
656
+ }
657
+ ```
658
+
659
+ ---
660
+
661
+ ## Accessibility for Content
662
+
663
+ ### Alt Text Automation
664
+
665
+ ```typescript
666
+ // scripts/audit-alt-text.ts — find images missing alt in MDX files
667
+ import { glob } from 'glob';
668
+ import { readFile } from 'fs/promises';
669
+
670
+ const IMG_REGEX = /!\[([^\]]*)\]\([^)]+\)|<img[^>]+>/g;
671
+
672
+ async function auditAltText(dir: string) {
673
+ const files = await glob(`${dir}/**/*.mdx`);
674
+ const issues: { file: string; line: number; src: string }[] = [];
675
+
676
+ for (const file of files) {
677
+ const content = await readFile(file, 'utf-8');
678
+ const lines = content.split('\n');
679
+ lines.forEach((line, i) => {
680
+ const matches = line.matchAll(IMG_REGEX);
681
+ for (const match of matches) {
682
+ const isMarkdown = match[0].startsWith('![');
683
+ const isEmpty = isMarkdown ? match[1].trim() === '' : !match[0].includes('alt=') || match[0].includes('alt=""');
684
+ if (isEmpty) issues.push({ file, line: i + 1, src: match[0].slice(0, 60) });
685
+ }
686
+ });
687
+ }
688
+
689
+ if (issues.length) {
690
+ console.error(`Found ${issues.length} images with missing/empty alt text:`);
691
+ issues.forEach(i => console.error(` ${i.file}:${i.line} → ${i.src}`));
692
+ process.exit(1);
693
+ }
694
+ }
695
+
696
+ // Auto-generate alt text using AI (optional, for CMS images without alt)
697
+ async function suggestAltText(imageUrl: string): Promise<string> {
698
+ // Call Claude claude-haiku-4-5 — fast, cheap for image description
699
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
700
+ method: 'POST',
701
+ headers: { 'x-api-key': process.env.ANTHROPIC_API_KEY!, 'content-type': 'application/json', 'anthropic-version': '2023-06-01' },
702
+ body: JSON.stringify({
703
+ model: 'claude-haiku-4-5',
704
+ max_tokens: 100,
705
+ messages: [{ role: 'user', content: [{ type: 'image', source: { type: 'url', url: imageUrl } }, { type: 'text', text: 'Write a concise alt text for this image (max 125 chars, no "image of").' }] }],
706
+ }),
707
+ });
708
+ const data = await res.json();
709
+ return data.content[0].text.trim();
710
+ }
711
+ ```
712
+
713
+ ### Reading Level Analysis
714
+
715
+ ```typescript
716
+ // lib/content/readability.ts — Flesch-Kincaid reading ease
717
+ export function fleschKincaid(text: string): { score: number; level: string } {
718
+ const sentences = text.split(/[.!?]+/).filter(Boolean).length;
719
+ const words = text.trim().split(/\s+/).length;
720
+ const syllables = countSyllables(text);
721
+
722
+ if (words === 0 || sentences === 0) return { score: 0, level: 'unknown' };
723
+
724
+ const score = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
725
+ const level =
726
+ score >= 70 ? 'Easy (6th grade)' :
727
+ score >= 50 ? 'Moderate (10th grade)' :
728
+ score >= 30 ? 'Difficult (College)' : 'Very Difficult (Professional)';
729
+
730
+ return { score: Math.round(score), level };
731
+ }
732
+
733
+ function countSyllables(text: string): number {
734
+ return text
735
+ .toLowerCase()
736
+ .replace(/[^a-z]/g, ' ')
737
+ .split(/\s+/)
738
+ .reduce((acc, word) => {
739
+ const count = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '')
740
+ .replace(/^y/, '')
741
+ .match(/[aeiouy]{1,2}/g)?.length ?? 1;
742
+ return acc + count;
743
+ }, 0);
744
+ }
745
+ ```
746
+
747
+ ### Semantic Markup for Articles
748
+
749
+ ```tsx
750
+ // components/Article.tsx — correct semantic structure
751
+ export function Article({ post }: { post: Post }) {
752
+ return (
753
+ <article itemScope itemType="https://schema.org/BlogPosting">
754
+ <header>
755
+ <h1 itemProp="headline">{post.title}</h1>
756
+ <p>
757
+ By{' '}
758
+ <span itemProp="author" itemScope itemType="https://schema.org/Person">
759
+ <span itemProp="name">{post.author.name}</span>
760
+ </span>
761
+ {' · '}
762
+ <time itemProp="datePublished" dateTime={post.publishedAt}>
763
+ {new Date(post.publishedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
764
+ </time>
765
+ {' · '}
766
+ <span>{post.readingTime}</span>
767
+ </p>
768
+ </header>
769
+
770
+ <nav aria-label="Table of contents">
771
+ <ol>
772
+ {post.toc.map(item => (
773
+ <li key={item.id} style={{ paddingLeft: `${(item.level - 2) * 16}px` }}>
774
+ <a href={`#${item.id}`}>{item.text}</a>
775
+ </li>
776
+ ))}
777
+ </ol>
778
+ </nav>
779
+
780
+ <section itemProp="articleBody" aria-label="Article content">
781
+ {post.content}
782
+ </section>
783
+
784
+ <footer>
785
+ <nav aria-label="Post tags">
786
+ {post.tags.map(tag => (
787
+ <a key={tag} href={`/blog?tag=${tag}`} rel="tag">{tag}</a>
788
+ ))}
789
+ </nav>
790
+ </footer>
791
+ </article>
792
+ );
793
+ }
794
+ ```
795
+
796
+ ---
797
+
798
+ ## Rich Media Embedding
799
+
800
+ ### Video Embeds in MDX
801
+
802
+ ```tsx
803
+ // components/mdx/VideoEmbed.tsx — lazy, privacy-respecting YouTube embed
804
+ 'use client';
805
+ import { useState } from 'react';
806
+ import Image from 'next/image';
807
+
808
+ interface VideoEmbedProps {
809
+ id: string;
810
+ title: string;
811
+ provider?: 'youtube' | 'vimeo';
812
+ }
813
+
814
+ export function VideoEmbed({ id, title, provider = 'youtube' }: VideoEmbedProps) {
815
+ const [loaded, setLoaded] = useState(false);
816
+
817
+ const thumb = `https://img.youtube.com/vi/${id}/maxresdefault.jpg`;
818
+ const src =
819
+ provider === 'youtube'
820
+ ? `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0`
821
+ : `https://player.vimeo.com/video/${id}?autoplay=1`;
822
+
823
+ return (
824
+ <div className="relative aspect-video rounded-lg overflow-hidden bg-gray-900 my-6">
825
+ {!loaded ? (
826
+ <button
827
+ className="w-full h-full group"
828
+ aria-label={`Play video: ${title}`}
829
+ onClick={() => setLoaded(true)}
830
+ >
831
+ <Image src={thumb} alt={title} fill className="object-cover opacity-80 group-hover:opacity-100 transition-opacity" />
832
+ <div className="absolute inset-0 flex items-center justify-center">
833
+ <div className="w-16 h-16 bg-red-600 rounded-full flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
834
+ <svg viewBox="0 0 24 24" fill="white" className="w-6 h-6 ml-1" aria-hidden="true">
835
+ <path d="M8 5v14l11-7z" />
836
+ </svg>
837
+ </div>
838
+ </div>
839
+ </button>
840
+ ) : (
841
+ <iframe
842
+ src={src}
843
+ title={title}
844
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
845
+ allowFullScreen
846
+ className="absolute inset-0 w-full h-full"
847
+ />
848
+ )}
849
+ </div>
850
+ );
851
+ }
852
+
853
+ // Usage in MDX:
854
+ // <VideoEmbed id="dQw4w9WgXcQ" title="Getting started with Next.js" />
855
+ ```
856
+
857
+ ### Image Gallery
858
+
859
+ ```tsx
860
+ // components/mdx/Gallery.tsx — lightbox image gallery
861
+ 'use client';
862
+ import { useState } from 'react';
863
+ import Image from 'next/image';
864
+
865
+ interface GalleryImage { src: string; alt: string; caption?: string }
866
+
867
+ export function Gallery({ images }: { images: GalleryImage[] }) {
868
+ const [selected, setSelected] = useState<number | null>(null);
869
+
870
+ return (
871
+ <>
872
+ <div className="grid grid-cols-2 md:grid-cols-3 gap-2 my-6">
873
+ {images.map((img, i) => (
874
+ <button
875
+ key={i}
876
+ onClick={() => setSelected(i)}
877
+ className="relative aspect-square rounded overflow-hidden group"
878
+ aria-label={`View ${img.alt}`}
879
+ >
880
+ <Image src={img.src} alt={img.alt} fill className="object-cover group-hover:scale-105 transition-transform" />
881
+ </button>
882
+ ))}
883
+ </div>
884
+
885
+ {selected !== null && (
886
+ <div
887
+ role="dialog"
888
+ aria-modal="true"
889
+ aria-label="Image lightbox"
890
+ className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
891
+ onClick={() => setSelected(null)}
892
+ >
893
+ <div className="relative max-w-4xl w-full" onClick={e => e.stopPropagation()}>
894
+ <Image
895
+ src={images[selected].src}
896
+ alt={images[selected].alt}
897
+ width={1200}
898
+ height={800}
899
+ className="rounded-lg object-contain"
900
+ />
901
+ {images[selected].caption && (
902
+ <p className="text-white/70 text-sm text-center mt-2">{images[selected].caption}</p>
903
+ )}
904
+ <button
905
+ onClick={() => setSelected(null)}
906
+ className="absolute top-2 right-2 text-white bg-black/50 rounded-full w-8 h-8 flex items-center justify-center"
907
+ aria-label="Close lightbox"
908
+ >
909
+
910
+ </button>
911
+ </div>
912
+ </div>
913
+ )}
914
+ </>
915
+ );
916
+ }
917
+ ```
918
+
919
+ ### Code Playground (Interactive)
920
+
921
+ ```tsx
922
+ // components/mdx/CodePlayground.tsx — Sandpack integration
923
+ import { Sandpack } from '@codesandbox/sandpack-react';
924
+ import { githubLight } from '@codesandbox/sandpack-themes';
925
+
926
+ interface PlaygroundProps {
927
+ files: Record<string, string>;
928
+ entry?: string;
929
+ template?: 'react' | 'react-ts' | 'vanilla' | 'nextjs';
930
+ }
931
+
932
+ export function CodePlayground({ files, entry = '/App.tsx', template = 'react-ts' }: PlaygroundProps) {
933
+ return (
934
+ <div className="my-6 rounded-lg overflow-hidden border border-gray-200">
935
+ <Sandpack
936
+ template={template}
937
+ files={files}
938
+ options={{
939
+ showNavigator: false,
940
+ showTabs: Object.keys(files).length > 1,
941
+ editorHeight: 320,
942
+ activeFile: entry,
943
+ }}
944
+ theme={githubLight}
945
+ />
946
+ </div>
947
+ );
948
+ }
949
+
950
+ // Usage in MDX:
951
+ // <CodePlayground
952
+ // files={{ '/App.tsx': "export default function App() { return <h1>Hello!</h1> }" }}
953
+ // />
954
+ ```
955
+
956
+ ---
957
+
958
+ ## Integration Patterns
959
+
960
+ **content + analytics**: Fire `content_view`, `scroll_depth`, and `read_complete` events from content pages into the analytics warehouse. Use `@rune/analytics` sql-patterns skill to build read-time dashboards.
961
+
962
+ **content + ui**: Share design tokens and typography scale. MDX custom components (Callout, CodeBlock, Gallery) follow the same design system as app UI components — import from shared `@/components/ui` rather than duplicating.
963
+
964
+ **content + saas**: Gate premium posts behind subscription check middleware. Redirect unauthenticated users to upgrade page. Use `@rune/saas` auth patterns for session validation in server components.
965
+
966
+ **content + ecommerce**: Inject product cards into MDX via `<ProductCard sku="...">` component that pulls live inventory data. Track affiliate link clicks as conversion events.
967
+
968
+ ---
969
+
970
+ ## Tech Stack Support
971
+
972
+ | Area | Options | Notes |
973
+ |------|---------|-------|
974
+ | Blog Framework | Contentlayer, MDX, Velite | Contentlayer most mature for Next.js |
975
+ | Headless CMS | Sanity, Contentful, Strapi, PocketBase | Sanity best DX; PocketBase self-hosted |
976
+ | MDX | next-mdx-remote, mdx-bundler, @next/mdx | next-mdx-remote for dynamic content |
977
+ | i18n | next-intl, i18next, Paraglide | next-intl for App Router |
978
+ | SEO | Next.js Metadata API, next-seo | Metadata API built-in since Next.js 13 |
979
+ | Search | Algolia, Meilisearch, Typesense | Meilisearch for self-hosted; Algolia for managed |
980
+ | Email | Resend, Mailchimp, ConvertKit | Resend for dev DX; Mailchimp for large lists |
981
+ | Images | sharp, next/image, Cloudinary | sharp for pre-processing; next/image for runtime |
982
+ | Analytics | Plausible, Tinybird, custom | Plausible for privacy-first; Tinybird for scale |
983
+ | Syntax | Shiki, Prism | Shiki recommended — themes match VS Code |
984
+ | Playground | Sandpack, CodeMirror | Sandpack for full browser environments |
985
+
986
+ ---
987
+
988
+ ## Constraints
989
+
990
+ 1. MUST validate all CMS content against a schema before rendering — malformed data from CMS should not crash pages.
991
+ 2. MUST include `hreflang` tags on all locale-specific pages — missing hreflang hurts international SEO ranking.
992
+ 3. MUST NOT hardcode strings in components when i18n is configured — every user-visible string goes through the translation system.
993
+ 4. MUST generate sitemap dynamically from actual content — static sitemaps go stale and list nonexistent pages.
994
+ 5. MUST provide fallback for missing MDX components — a missing custom component should render a warning, not crash the build.
995
+ 6. MUST set `width` + `height` on all images to prevent CLS — layout shift is a Core Web Vitals failure and SEO penalty.
996
+ 7. MUST redirect old CMS URLs permanently (301) before go-live — 302 redirects are not followed by search engines for link equity.
997
+ 8. MUST NOT expose Algolia/Meilisearch admin/write keys to the client — use separate search-only keys in frontend code.
998
+
999
+ ---
1000
+
1001
+ ## Done When
1002
+
1003
+ - Blog system serves paginated posts with RSS feed and reading time
1004
+ - CMS integration has preview mode, webhook revalidation, and content validation
1005
+ - MDX pipeline renders custom components with fallback for missing ones
1006
+ - All user-facing strings go through i18n with fallback chain configured
1007
+ - Every public page has unique title, description, OG tags, canonical URL, and JSON-LD
1008
+ - Search index stays in sync via publish webhook
1009
+ - Newsletter capture and email delivery configured and tested
1010
+ - Images optimized to WebP/AVIF with correct dimensions (no CLS)
1011
+ - Core Web Vitals reporter active and LCP < 2.5s on key pages
1012
+ - Video repurposing pipeline producing platform-ready vertical clips with captions
1013
+ - Content scoring providing actionable improvement suggestions per dimension
1014
+ - Structured report emitted for each skill invoked