@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.
- package/LICENSE +21 -21
- package/README.md +68 -34
- package/agents/adversary.md +27 -0
- package/agents/architect.md +19 -29
- package/agents/asset-creator.md +18 -4
- package/agents/audit.md +25 -4
- package/agents/autopsy.md +19 -4
- package/agents/ba.md +35 -0
- package/agents/brainstorm.md +31 -4
- package/agents/browser-pilot.md +21 -4
- package/agents/coder.md +21 -29
- package/agents/completion-gate.md +20 -4
- package/agents/constraint-check.md +18 -4
- package/agents/context-engine.md +22 -4
- package/agents/context-pack.md +32 -0
- package/agents/cook.md +41 -4
- package/agents/db.md +19 -4
- package/agents/debug.md +33 -4
- package/agents/dependency-doctor.md +20 -4
- package/agents/deploy.md +27 -4
- package/agents/design.md +22 -4
- package/agents/doc-processor.md +27 -0
- package/agents/docs-seeker.md +19 -4
- package/agents/docs.md +31 -0
- package/agents/fix.md +37 -4
- package/agents/git.md +29 -0
- package/agents/hallucination-guard.md +20 -4
- package/agents/incident.md +21 -4
- package/agents/integrity-check.md +18 -4
- package/agents/journal.md +19 -4
- package/agents/launch.md +32 -4
- package/agents/logic-guardian.md +26 -11
- package/agents/marketing.md +23 -4
- package/agents/mcp-builder.md +26 -0
- package/agents/neural-memory.md +30 -0
- package/agents/onboard.md +22 -4
- package/agents/perf.md +21 -4
- package/agents/plan.md +29 -4
- package/agents/preflight.md +22 -4
- package/agents/problem-solver.md +20 -4
- package/agents/rescue.md +23 -4
- package/agents/research.md +19 -4
- package/agents/researcher.md +19 -29
- package/agents/retro.md +32 -0
- package/agents/review-intake.md +20 -4
- package/agents/review.md +32 -4
- package/agents/reviewer.md +20 -28
- package/agents/safeguard.md +19 -4
- package/agents/sast.md +18 -4
- package/agents/scaffold.md +41 -0
- package/agents/scanner.md +19 -28
- package/agents/scope-guard.md +18 -4
- package/agents/scout.md +23 -4
- package/agents/sentinel-env.md +26 -0
- package/agents/sentinel.md +33 -4
- package/agents/sequential-thinking.md +20 -4
- package/agents/session-bridge.md +24 -4
- package/agents/skill-forge.md +22 -4
- package/agents/skill-router.md +26 -4
- package/agents/slides.md +24 -0
- package/agents/surgeon.md +19 -4
- package/agents/team.md +30 -4
- package/agents/test.md +36 -4
- package/agents/trend-scout.md +17 -4
- package/agents/verification.md +20 -4
- package/agents/video-creator.md +20 -4
- package/agents/watchdog.md +19 -4
- package/agents/worktree.md +17 -4
- package/commands/rune.md +168 -168
- package/compiler/__tests__/analytics.test.js +370 -0
- package/compiler/adapters/openclaw.js +2 -2
- package/compiler/analytics.js +385 -0
- package/compiler/bin/rune.js +68 -2
- package/compiler/dashboard.js +883 -0
- package/compiler/transforms/branding.js +1 -1
- package/contexts/dev.md +34 -34
- package/contexts/research.md +43 -43
- package/contexts/review.md +55 -55
- package/extensions/ai-ml/PACK.md +88 -88
- package/extensions/ai-ml/skills/ai-agents.md +172 -172
- package/extensions/ai-ml/skills/code-sandbox.md +187 -187
- package/extensions/ai-ml/skills/deep-research.md +146 -146
- package/extensions/ai-ml/skills/embedding-search.md +66 -66
- package/extensions/ai-ml/skills/fine-tuning-guide.md +74 -74
- package/extensions/ai-ml/skills/llm-architect.md +125 -125
- package/extensions/ai-ml/skills/llm-integration.md +64 -64
- package/extensions/ai-ml/skills/prompt-patterns.md +72 -72
- package/extensions/ai-ml/skills/rag-patterns.md +66 -66
- package/extensions/ai-ml/skills/web-extraction.md +114 -114
- package/extensions/analytics/PACK.md +92 -92
- package/extensions/analytics/skills/ab-testing.md +72 -72
- package/extensions/analytics/skills/dashboard-patterns.md +83 -83
- package/extensions/analytics/skills/data-validation.md +68 -68
- package/extensions/analytics/skills/funnel-analysis.md +81 -81
- package/extensions/analytics/skills/sql-patterns.md +57 -57
- package/extensions/analytics/skills/statistical-analysis.md +79 -79
- package/extensions/analytics/skills/tracking-setup.md +71 -71
- package/extensions/backend/PACK.md +104 -104
- package/extensions/backend/skills/api-patterns.md +84 -84
- package/extensions/backend/skills/async-pipeline.md +193 -193
- package/extensions/backend/skills/auth-patterns.md +97 -97
- package/extensions/backend/skills/background-jobs.md +133 -133
- package/extensions/backend/skills/caching-patterns.md +108 -108
- package/extensions/backend/skills/cli-generation.md +133 -133
- package/extensions/backend/skills/database-patterns.md +87 -87
- package/extensions/backend/skills/middleware-patterns.md +104 -104
- package/extensions/chrome-ext/PACK.md +93 -93
- package/extensions/chrome-ext/skills/cws-preflight.md +143 -143
- package/extensions/chrome-ext/skills/cws-publish.md +104 -104
- package/extensions/chrome-ext/skills/ext-ai-integration.md +251 -251
- package/extensions/chrome-ext/skills/ext-messaging.md +139 -139
- package/extensions/chrome-ext/skills/ext-storage.md +133 -133
- package/extensions/chrome-ext/skills/mv3-scaffold.md +164 -164
- package/extensions/content/PACK.md +96 -96
- package/extensions/content/skills/blog-patterns.md +88 -88
- package/extensions/content/skills/cms-integration.md +131 -131
- package/extensions/content/skills/content-scoring.md +107 -107
- package/extensions/content/skills/i18n.md +83 -83
- package/extensions/content/skills/mdx-authoring.md +137 -137
- package/extensions/content/skills/reference.md +1014 -1014
- package/extensions/content/skills/seo-patterns.md +67 -67
- package/extensions/content/skills/video-repurpose.md +153 -153
- package/extensions/devops/PACK.md +101 -101
- package/extensions/devops/skills/chaos-testing.md +67 -67
- package/extensions/devops/skills/ci-cd.md +75 -75
- package/extensions/devops/skills/docker.md +58 -58
- package/extensions/devops/skills/edge-serverless.md +163 -163
- package/extensions/devops/skills/infra-as-code.md +158 -158
- package/extensions/devops/skills/kubernetes.md +110 -110
- package/extensions/devops/skills/monitoring.md +57 -57
- package/extensions/devops/skills/server-setup.md +64 -64
- package/extensions/devops/skills/ssl-domain.md +42 -42
- package/extensions/ecommerce/PACK.md +116 -116
- package/extensions/ecommerce/skills/cart-system.md +79 -79
- package/extensions/ecommerce/skills/inventory-mgmt.md +102 -102
- package/extensions/ecommerce/skills/order-management.md +126 -126
- package/extensions/ecommerce/skills/payment-integration.md +472 -472
- package/extensions/ecommerce/skills/shopify-dev.md +69 -69
- package/extensions/ecommerce/skills/subscription-billing.md +93 -93
- package/extensions/ecommerce/skills/tax-compliance.md +117 -117
- package/extensions/gamedev/PACK.md +142 -142
- package/extensions/gamedev/skills/asset-pipeline.md +74 -74
- package/extensions/gamedev/skills/audio-system.md +129 -129
- package/extensions/gamedev/skills/camera-system.md +87 -87
- package/extensions/gamedev/skills/ecs.md +98 -98
- package/extensions/gamedev/skills/game-loops.md +72 -72
- package/extensions/gamedev/skills/input-system.md +199 -199
- package/extensions/gamedev/skills/multiplayer.md +180 -180
- package/extensions/gamedev/skills/particles.md +105 -105
- package/extensions/gamedev/skills/physics-engine.md +89 -89
- package/extensions/gamedev/skills/scene-management.md +146 -146
- package/extensions/gamedev/skills/threejs-patterns.md +90 -90
- package/extensions/gamedev/skills/webgl.md +71 -71
- package/extensions/mobile/PACK.md +106 -106
- package/extensions/mobile/skills/app-store-connect.md +152 -152
- package/extensions/mobile/skills/app-store-prep.md +66 -66
- package/extensions/mobile/skills/deep-linking.md +109 -109
- package/extensions/mobile/skills/flutter.md +60 -60
- package/extensions/mobile/skills/ios-build-pipeline.md +142 -142
- package/extensions/mobile/skills/native-bridge.md +66 -66
- package/extensions/mobile/skills/ota-updates.md +97 -97
- package/extensions/mobile/skills/push-notifications.md +111 -111
- package/extensions/mobile/skills/react-native.md +82 -82
- package/extensions/saas/PACK.md +116 -116
- package/extensions/saas/skills/billing-integration.md +200 -200
- package/extensions/saas/skills/feature-flags.md +130 -130
- package/extensions/saas/skills/multi-tenant.md +103 -103
- package/extensions/saas/skills/onboarding-flow.md +139 -139
- package/extensions/saas/skills/subscription-flow.md +95 -95
- package/extensions/saas/skills/team-management.md +144 -144
- package/extensions/security/PACK.md +99 -99
- package/extensions/security/skills/api-security.md +140 -140
- package/extensions/security/skills/compliance.md +68 -68
- package/extensions/security/skills/owasp-audit.md +64 -64
- package/extensions/security/skills/pentest-patterns.md +77 -77
- package/extensions/security/skills/secret-mgmt.md +65 -65
- package/extensions/security/skills/supply-chain.md +65 -65
- package/extensions/trading/PACK.md +80 -80
- package/extensions/trading/skills/chart-components.md +55 -55
- package/extensions/trading/skills/experiment-loop.md +125 -125
- package/extensions/trading/skills/fintech-patterns.md +47 -47
- package/extensions/trading/skills/indicator-library.md +58 -58
- package/extensions/trading/skills/quant-analysis.md +111 -111
- package/extensions/trading/skills/realtime-data.md +58 -58
- package/extensions/trading/skills/trade-logic.md +104 -104
- package/extensions/ui/PACK.md +130 -130
- package/extensions/ui/skills/a11y-audit.md +91 -91
- package/extensions/ui/skills/animation-patterns.md +127 -106
- package/extensions/ui/skills/component-patterns.md +100 -75
- package/extensions/ui/skills/design-decision.md +108 -108
- package/extensions/ui/skills/design-system.md +68 -68
- package/extensions/ui/skills/landing-patterns.md +155 -155
- package/extensions/ui/skills/palette-picker.md +173 -173
- package/extensions/ui/skills/react-health.md +90 -90
- package/extensions/ui/skills/type-system.md +125 -125
- package/extensions/ui/skills/web-vitals.md +153 -153
- package/extensions/zalo/PACK.md +145 -145
- package/extensions/zalo/skills/zalo-oa-mcp.md +317 -317
- package/extensions/zalo/skills/zalo-oa-messaging.md +429 -429
- package/extensions/zalo/skills/zalo-oa-setup.md +236 -236
- package/extensions/zalo/skills/zalo-oa-webhook.md +189 -189
- package/extensions/zalo/skills/zalo-personal-messaging.md +194 -194
- package/extensions/zalo/skills/zalo-personal-setup.md +153 -153
- package/extensions/zalo/skills/zalo-rate-guard.md +219 -219
- package/hooks/auto-format/index.cjs +48 -48
- package/hooks/context-watch/index.cjs +95 -68
- package/hooks/hooks.json +111 -111
- package/hooks/metrics-collector/index.cjs +86 -42
- package/hooks/post-session-reflect/index.cjs +189 -153
- package/hooks/pre-compact/index.cjs +95 -95
- package/hooks/run-hook.cmd +1 -1
- package/hooks/secrets-scan/index.cjs +100 -100
- package/hooks/session-start/index.cjs +71 -65
- package/hooks/typecheck/index.cjs +65 -65
- package/package.json +63 -63
- package/references/ui-pro-max-data/LICENSE-UI-PRO-MAX +21 -21
- package/references/ui-pro-max-data/charts.csv +26 -26
- package/references/ui-pro-max-data/colors.csv +161 -161
- package/references/ui-pro-max-data/styles.csv +68 -68
- package/references/ui-pro-max-data/typography.csv +74 -74
- package/references/ui-pro-max-data/ui-reasoning.csv +162 -162
- package/references/ui-pro-max-data/ux-guidelines.csv +99 -99
- package/skills/adversary/SKILL.md +283 -283
- package/skills/asset-creator/SKILL.md +157 -157
- package/skills/audit/SKILL.md +148 -2
- package/skills/autopsy/SKILL.md +335 -259
- package/skills/autopsy/references/repo-analysis-patterns.md +113 -0
- package/skills/ba/SKILL.md +72 -2
- package/skills/brainstorm/SKILL.md +342 -341
- package/skills/browser-pilot/SKILL.md +168 -168
- package/skills/constraint-check/SKILL.md +165 -165
- package/skills/context-engine/SKILL.md +404 -404
- package/skills/cook/SKILL.md +917 -834
- package/skills/cook/references/output-format.md +33 -0
- package/skills/db/SKILL.md +273 -272
- package/skills/debug/SKILL.md +465 -443
- package/skills/dependency-doctor/SKILL.md +265 -235
- package/skills/deploy/SKILL.md +274 -231
- package/skills/design/DESIGN-REFERENCE.md +365 -365
- package/skills/design/SKILL.md +589 -482
- package/skills/doc-processor/SKILL.md +254 -254
- package/skills/docs/SKILL.md +374 -373
- package/skills/docs-seeker/SKILL.md +177 -177
- package/skills/fix/SKILL.md +330 -308
- package/skills/git/SKILL.md +339 -339
- package/skills/graft/SKILL.md +352 -0
- package/skills/graft/references/challenge-framework.md +98 -0
- package/skills/graft/references/mode-decision.md +44 -0
- package/skills/hallucination-guard/SKILL.md +219 -219
- package/skills/incident/SKILL.md +254 -251
- package/skills/integrity-check/SKILL.md +169 -169
- package/skills/journal/SKILL.md +240 -238
- package/skills/launch/SKILL.md +344 -342
- package/skills/logic-guardian/SKILL.md +251 -251
- package/skills/marketing/SKILL.md +290 -245
- package/skills/mcp-builder/SKILL.md +425 -423
- package/skills/mcp-builder/references/auto-discovery-pattern.md +169 -0
- package/skills/neural-memory/SKILL.md +362 -362
- package/skills/onboard/SKILL.md +404 -403
- package/skills/perf/SKILL.md +346 -346
- package/skills/plan/SKILL.md +433 -370
- package/skills/plan/references/feature-map.md +84 -0
- package/skills/preflight/SKILL.md +415 -396
- package/skills/problem-solver/SKILL.md +380 -284
- package/skills/rescue/SKILL.md +474 -450
- package/skills/retro/SKILL.md +5 -1
- package/skills/review/SKILL.md +612 -535
- package/skills/review-intake/SKILL.md +249 -249
- package/skills/safeguard/SKILL.md +200 -200
- package/skills/sast/SKILL.md +190 -190
- package/skills/scaffold/SKILL.md +328 -286
- package/skills/scope-guard/SKILL.md +180 -162
- package/skills/scout/SKILL.md +263 -263
- package/skills/sentinel/SKILL.md +382 -353
- package/skills/sentinel-env/SKILL.md +254 -254
- package/skills/sequential-thinking/SKILL.md +234 -234
- package/skills/session-bridge/SKILL.md +543 -397
- package/skills/skill-forge/SKILL.md +581 -539
- package/skills/skill-router/{skill.md → SKILL.md} +30 -2
- package/skills/surgeon/SKILL.md +215 -215
- package/skills/team/SKILL.md +556 -514
- package/skills/test/SKILL.md +614 -587
- package/skills/trend-scout/SKILL.md +145 -145
- package/skills/verification/SKILL.md +326 -325
- package/skills/video-creator/SKILL.md +201 -201
- package/skills/watchdog/SKILL.md +168 -168
- 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
|