@rune-kit/rune 2.10.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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +8 -6
  3. package/commands/rune.md +168 -168
  4. package/contexts/dev.md +34 -34
  5. package/contexts/research.md +43 -43
  6. package/contexts/review.md +55 -55
  7. package/extensions/ai-ml/PACK.md +88 -88
  8. package/extensions/ai-ml/skills/ai-agents.md +172 -172
  9. package/extensions/ai-ml/skills/code-sandbox.md +187 -187
  10. package/extensions/ai-ml/skills/deep-research.md +146 -146
  11. package/extensions/ai-ml/skills/embedding-search.md +66 -66
  12. package/extensions/ai-ml/skills/fine-tuning-guide.md +74 -74
  13. package/extensions/ai-ml/skills/llm-architect.md +125 -125
  14. package/extensions/ai-ml/skills/llm-integration.md +64 -64
  15. package/extensions/ai-ml/skills/prompt-patterns.md +72 -72
  16. package/extensions/ai-ml/skills/rag-patterns.md +66 -66
  17. package/extensions/ai-ml/skills/web-extraction.md +114 -114
  18. package/extensions/analytics/PACK.md +92 -92
  19. package/extensions/analytics/skills/ab-testing.md +72 -72
  20. package/extensions/analytics/skills/dashboard-patterns.md +83 -83
  21. package/extensions/analytics/skills/data-validation.md +68 -68
  22. package/extensions/analytics/skills/funnel-analysis.md +81 -81
  23. package/extensions/analytics/skills/sql-patterns.md +57 -57
  24. package/extensions/analytics/skills/statistical-analysis.md +79 -79
  25. package/extensions/analytics/skills/tracking-setup.md +71 -71
  26. package/extensions/backend/PACK.md +104 -104
  27. package/extensions/backend/skills/api-patterns.md +84 -84
  28. package/extensions/backend/skills/async-pipeline.md +193 -193
  29. package/extensions/backend/skills/auth-patterns.md +97 -97
  30. package/extensions/backend/skills/background-jobs.md +133 -133
  31. package/extensions/backend/skills/caching-patterns.md +108 -108
  32. package/extensions/backend/skills/cli-generation.md +133 -133
  33. package/extensions/backend/skills/database-patterns.md +87 -87
  34. package/extensions/backend/skills/middleware-patterns.md +104 -104
  35. package/extensions/chrome-ext/PACK.md +93 -93
  36. package/extensions/chrome-ext/skills/cws-preflight.md +143 -143
  37. package/extensions/chrome-ext/skills/cws-publish.md +104 -104
  38. package/extensions/chrome-ext/skills/ext-ai-integration.md +251 -251
  39. package/extensions/chrome-ext/skills/ext-messaging.md +139 -139
  40. package/extensions/chrome-ext/skills/ext-storage.md +133 -133
  41. package/extensions/chrome-ext/skills/mv3-scaffold.md +164 -164
  42. package/extensions/content/PACK.md +96 -96
  43. package/extensions/content/skills/blog-patterns.md +88 -88
  44. package/extensions/content/skills/cms-integration.md +131 -131
  45. package/extensions/content/skills/content-scoring.md +107 -107
  46. package/extensions/content/skills/i18n.md +83 -83
  47. package/extensions/content/skills/mdx-authoring.md +137 -137
  48. package/extensions/content/skills/reference.md +1014 -1014
  49. package/extensions/content/skills/seo-patterns.md +67 -67
  50. package/extensions/content/skills/video-repurpose.md +153 -153
  51. package/extensions/devops/PACK.md +101 -101
  52. package/extensions/devops/skills/chaos-testing.md +67 -67
  53. package/extensions/devops/skills/ci-cd.md +75 -75
  54. package/extensions/devops/skills/docker.md +58 -58
  55. package/extensions/devops/skills/edge-serverless.md +163 -163
  56. package/extensions/devops/skills/infra-as-code.md +158 -158
  57. package/extensions/devops/skills/kubernetes.md +110 -110
  58. package/extensions/devops/skills/monitoring.md +57 -57
  59. package/extensions/devops/skills/server-setup.md +64 -64
  60. package/extensions/devops/skills/ssl-domain.md +42 -42
  61. package/extensions/ecommerce/PACK.md +116 -116
  62. package/extensions/ecommerce/skills/cart-system.md +79 -79
  63. package/extensions/ecommerce/skills/inventory-mgmt.md +102 -102
  64. package/extensions/ecommerce/skills/order-management.md +126 -126
  65. package/extensions/ecommerce/skills/payment-integration.md +472 -472
  66. package/extensions/ecommerce/skills/shopify-dev.md +69 -69
  67. package/extensions/ecommerce/skills/subscription-billing.md +93 -93
  68. package/extensions/ecommerce/skills/tax-compliance.md +117 -117
  69. package/extensions/gamedev/PACK.md +142 -142
  70. package/extensions/gamedev/skills/asset-pipeline.md +74 -74
  71. package/extensions/gamedev/skills/audio-system.md +129 -129
  72. package/extensions/gamedev/skills/camera-system.md +87 -87
  73. package/extensions/gamedev/skills/ecs.md +98 -98
  74. package/extensions/gamedev/skills/game-loops.md +72 -72
  75. package/extensions/gamedev/skills/input-system.md +199 -199
  76. package/extensions/gamedev/skills/multiplayer.md +180 -180
  77. package/extensions/gamedev/skills/particles.md +105 -105
  78. package/extensions/gamedev/skills/physics-engine.md +89 -89
  79. package/extensions/gamedev/skills/scene-management.md +146 -146
  80. package/extensions/gamedev/skills/threejs-patterns.md +90 -90
  81. package/extensions/gamedev/skills/webgl.md +71 -71
  82. package/extensions/mobile/PACK.md +106 -106
  83. package/extensions/mobile/skills/app-store-connect.md +152 -152
  84. package/extensions/mobile/skills/app-store-prep.md +66 -66
  85. package/extensions/mobile/skills/deep-linking.md +109 -109
  86. package/extensions/mobile/skills/flutter.md +60 -60
  87. package/extensions/mobile/skills/ios-build-pipeline.md +142 -142
  88. package/extensions/mobile/skills/native-bridge.md +66 -66
  89. package/extensions/mobile/skills/ota-updates.md +97 -97
  90. package/extensions/mobile/skills/push-notifications.md +111 -111
  91. package/extensions/mobile/skills/react-native.md +82 -82
  92. package/extensions/saas/PACK.md +116 -116
  93. package/extensions/saas/skills/billing-integration.md +200 -200
  94. package/extensions/saas/skills/feature-flags.md +130 -130
  95. package/extensions/saas/skills/multi-tenant.md +103 -103
  96. package/extensions/saas/skills/onboarding-flow.md +139 -139
  97. package/extensions/saas/skills/subscription-flow.md +95 -95
  98. package/extensions/saas/skills/team-management.md +144 -144
  99. package/extensions/security/PACK.md +99 -99
  100. package/extensions/security/skills/api-security.md +140 -140
  101. package/extensions/security/skills/compliance.md +68 -68
  102. package/extensions/security/skills/owasp-audit.md +64 -64
  103. package/extensions/security/skills/pentest-patterns.md +77 -77
  104. package/extensions/security/skills/secret-mgmt.md +65 -65
  105. package/extensions/security/skills/supply-chain.md +65 -65
  106. package/extensions/trading/PACK.md +80 -80
  107. package/extensions/trading/skills/chart-components.md +55 -55
  108. package/extensions/trading/skills/experiment-loop.md +125 -125
  109. package/extensions/trading/skills/fintech-patterns.md +47 -47
  110. package/extensions/trading/skills/indicator-library.md +58 -58
  111. package/extensions/trading/skills/quant-analysis.md +111 -111
  112. package/extensions/trading/skills/realtime-data.md +58 -58
  113. package/extensions/trading/skills/trade-logic.md +104 -104
  114. package/extensions/ui/PACK.md +130 -130
  115. package/extensions/ui/skills/a11y-audit.md +91 -91
  116. package/extensions/ui/skills/animation-patterns.md +127 -127
  117. package/extensions/ui/skills/component-patterns.md +100 -100
  118. package/extensions/ui/skills/design-decision.md +108 -108
  119. package/extensions/ui/skills/design-system.md +68 -68
  120. package/extensions/ui/skills/landing-patterns.md +155 -155
  121. package/extensions/ui/skills/palette-picker.md +173 -173
  122. package/extensions/ui/skills/react-health.md +90 -90
  123. package/extensions/ui/skills/type-system.md +125 -125
  124. package/extensions/ui/skills/web-vitals.md +153 -153
  125. package/extensions/zalo/PACK.md +145 -145
  126. package/extensions/zalo/skills/zalo-oa-mcp.md +317 -317
  127. package/extensions/zalo/skills/zalo-oa-messaging.md +429 -429
  128. package/extensions/zalo/skills/zalo-oa-setup.md +236 -236
  129. package/extensions/zalo/skills/zalo-oa-webhook.md +189 -189
  130. package/extensions/zalo/skills/zalo-personal-messaging.md +194 -194
  131. package/extensions/zalo/skills/zalo-personal-setup.md +153 -153
  132. package/extensions/zalo/skills/zalo-rate-guard.md +219 -219
  133. package/hooks/auto-format/index.cjs +48 -48
  134. package/hooks/hooks.json +111 -111
  135. package/hooks/post-session-reflect/index.cjs +189 -189
  136. package/hooks/pre-compact/index.cjs +95 -95
  137. package/hooks/run-hook.cmd +1 -1
  138. package/hooks/secrets-scan/index.cjs +100 -100
  139. package/hooks/session-start/index.cjs +71 -71
  140. package/hooks/typecheck/index.cjs +65 -65
  141. package/package.json +63 -63
  142. package/references/ui-pro-max-data/LICENSE-UI-PRO-MAX +21 -21
  143. package/references/ui-pro-max-data/charts.csv +26 -26
  144. package/references/ui-pro-max-data/colors.csv +161 -161
  145. package/references/ui-pro-max-data/styles.csv +68 -68
  146. package/references/ui-pro-max-data/typography.csv +74 -74
  147. package/references/ui-pro-max-data/ui-reasoning.csv +162 -162
  148. package/references/ui-pro-max-data/ux-guidelines.csv +99 -99
  149. package/skills/adversary/SKILL.md +283 -283
  150. package/skills/asset-creator/SKILL.md +157 -157
  151. package/skills/audit/SKILL.md +147 -2
  152. package/skills/autopsy/SKILL.md +335 -335
  153. package/skills/brainstorm/SKILL.md +342 -342
  154. package/skills/browser-pilot/SKILL.md +168 -168
  155. package/skills/constraint-check/SKILL.md +165 -165
  156. package/skills/context-engine/SKILL.md +404 -404
  157. package/skills/cook/SKILL.md +917 -863
  158. package/skills/db/SKILL.md +273 -273
  159. package/skills/debug/SKILL.md +465 -465
  160. package/skills/dependency-doctor/SKILL.md +265 -235
  161. package/skills/deploy/SKILL.md +274 -231
  162. package/skills/design/DESIGN-REFERENCE.md +365 -365
  163. package/skills/design/SKILL.md +589 -589
  164. package/skills/doc-processor/SKILL.md +254 -254
  165. package/skills/docs/SKILL.md +374 -374
  166. package/skills/docs-seeker/SKILL.md +177 -177
  167. package/skills/fix/SKILL.md +330 -330
  168. package/skills/git/SKILL.md +339 -339
  169. package/skills/hallucination-guard/SKILL.md +219 -219
  170. package/skills/incident/SKILL.md +254 -253
  171. package/skills/integrity-check/SKILL.md +169 -169
  172. package/skills/journal/SKILL.md +240 -240
  173. package/skills/launch/SKILL.md +344 -344
  174. package/skills/logic-guardian/SKILL.md +251 -251
  175. package/skills/marketing/SKILL.md +290 -289
  176. package/skills/mcp-builder/SKILL.md +425 -425
  177. package/skills/neural-memory/SKILL.md +362 -362
  178. package/skills/onboard/SKILL.md +404 -403
  179. package/skills/perf/SKILL.md +346 -346
  180. package/skills/plan/SKILL.md +433 -428
  181. package/skills/preflight/SKILL.md +415 -415
  182. package/skills/problem-solver/SKILL.md +380 -284
  183. package/skills/rescue/SKILL.md +474 -474
  184. package/skills/retro/SKILL.md +3 -1
  185. package/skills/review/SKILL.md +612 -588
  186. package/skills/review-intake/SKILL.md +249 -249
  187. package/skills/safeguard/SKILL.md +200 -200
  188. package/skills/sast/SKILL.md +190 -190
  189. package/skills/scaffold/SKILL.md +328 -287
  190. package/skills/scope-guard/SKILL.md +180 -180
  191. package/skills/scout/SKILL.md +263 -263
  192. package/skills/sentinel/SKILL.md +382 -381
  193. package/skills/sentinel-env/SKILL.md +254 -254
  194. package/skills/sequential-thinking/SKILL.md +234 -234
  195. package/skills/session-bridge/SKILL.md +543 -543
  196. package/skills/skill-forge/SKILL.md +581 -581
  197. package/skills/skill-router/SKILL.md +3 -0
  198. package/skills/surgeon/SKILL.md +215 -215
  199. package/skills/team/SKILL.md +556 -537
  200. package/skills/test/SKILL.md +614 -614
  201. package/skills/trend-scout/SKILL.md +145 -145
  202. package/skills/verification/SKILL.md +326 -326
  203. package/skills/video-creator/SKILL.md +201 -201
  204. package/skills/watchdog/SKILL.md +168 -168
  205. 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