@se-studio/skills 1.0.5 → 1.0.11

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.
@@ -0,0 +1,300 @@
1
+ ---
2
+ name: site-workflows-contentful-vercel-setup
3
+ description: Set up Contentful revalidation webhooks and live preview for a new SE Studio project on Vercel. Creates the Contentful webhook via the Management API and sets REVALIDATION_SECRET in Vercel. Run after site-workflows-new-project bootstrap when the Vercel project is deployed.
4
+ license: MIT
5
+ metadata:
6
+ author: se-studio
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Contentful & Vercel Webhook Setup
11
+
12
+ Wire up the Contentful revalidation webhook and live preview for an SE Studio project. The `/api/revalidate` route handler and `frame-ancestors` CSP are already in the template — this skill only configures them.
13
+
14
+ **Prerequisites:**
15
+ - `site-workflows-new-project` has been run (`.env.local` has `CONTENTFUL_SPACE_ID`, `CONTENTFUL_MANAGEMENT_TOKEN`, `CONTENTFUL_ENVIRONMENT_NAME`)
16
+ - The Vercel project is linked (`vercel whoami` succeeds) and has been deployed at least once
17
+ - `pnpm install` has been run
18
+
19
+ ---
20
+
21
+ ## Step 1 — Check prerequisites
22
+
23
+ Run these checks:
24
+
25
+ ```bash
26
+ # Confirm Vercel is linked
27
+ vercel whoami
28
+ cat .vercel/project.json 2>/dev/null || cat .vercel/repo.json 2>/dev/null
29
+
30
+ # Confirm required env vars are present in .env.local
31
+ grep -E "CONTENTFUL_SPACE_ID|CONTENTFUL_MANAGEMENT_TOKEN" .env.local
32
+ ```
33
+
34
+ If `CONTENTFUL_MANAGEMENT_TOKEN` is missing: tell the user to add it. It's found in Contentful → Settings → CMA Tokens → Generate personal token.
35
+
36
+ If the project isn't linked to Vercel: run the `vercel:deploy` skill first.
37
+
38
+ ---
39
+
40
+ ## Step 2 — Ask for the Vercel production URL
41
+
42
+ Ask the user: **"What is the Vercel production URL for this project?"**
43
+
44
+ This is the only value not already in environment files. Examples:
45
+ - `https://my-project.vercel.app`
46
+ - `https://myclient.com` (custom domain, if configured)
47
+
48
+ Store this as `SITE_URL` for use in subsequent steps.
49
+
50
+ ---
51
+
52
+ ## Step 3 — Create `scripts/setup-contentful-webhooks.ts`
53
+
54
+ Write this file to the project. It handles secret generation, webhook creation, and Vercel env var updates in one run.
55
+
56
+ ```typescript
57
+ /**
58
+ * Set up Contentful revalidation webhook and Vercel env vars.
59
+ *
60
+ * Usage:
61
+ * pnpm tsx scripts/setup-contentful-webhooks.ts https://your-site.vercel.app
62
+ *
63
+ * Prerequisites:
64
+ * - CONTENTFUL_SPACE_ID and CONTENTFUL_MANAGEMENT_TOKEN set in .env.local
65
+ * - Vercel CLI authenticated (vercel whoami)
66
+ *
67
+ * What it does:
68
+ * 1. Generates REVALIDATION_SECRET if not already set
69
+ * 2. Creates (or replaces) the Contentful revalidation webhook
70
+ * 3. Sets REVALIDATION_SECRET in Vercel (production + preview)
71
+ * 4. Updates .env.local with the new secret
72
+ */
73
+
74
+ import { createClient } from 'contentful-management';
75
+ import { randomBytes } from 'node:crypto';
76
+ import { spawnSync } from 'node:child_process';
77
+ import { readFileSync, writeFileSync } from 'node:fs';
78
+ import { resolve } from 'node:path';
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Validate args
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const siteUrl = process.argv[2];
85
+ if (!siteUrl || !siteUrl.startsWith('https://')) {
86
+ console.error('Usage: pnpm tsx scripts/setup-contentful-webhooks.ts https://your-site.vercel.app');
87
+ process.exit(1);
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Load .env.local
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const envPath = resolve('.env.local');
95
+ let envContent = readFileSync(envPath, 'utf-8');
96
+
97
+ function getEnv(key: string): string {
98
+ const value = envContent.match(new RegExp(`^${key}=(.*)`, 'm'))?.[1]?.trim() ?? process.env[key];
99
+ if (!value) throw new Error(`${key} not found in .env.local or environment`);
100
+ return value;
101
+ }
102
+
103
+ function setEnvLocal(key: string, value: string): void {
104
+ if (new RegExp(`^${key}=`, 'm').test(envContent)) {
105
+ envContent = envContent.replace(new RegExp(`^${key}=.*`, 'm'), `${key}=${value}`);
106
+ } else {
107
+ envContent += `\n${key}=${value}`;
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Set Vercel env var (pipe value via stdin to avoid shell escaping issues)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function vercelSetEnv(name: string, value: string, env: string): void {
116
+ spawnSync('vercel', ['env', 'rm', name, env, '--yes'], { stdio: 'inherit' });
117
+
118
+ const args =
119
+ env === 'preview'
120
+ ? ['env', 'add', name, env, '']
121
+ : ['env', 'add', name, env];
122
+
123
+ const result = spawnSync('vercel', args, {
124
+ input: value,
125
+ encoding: 'utf-8',
126
+ stdio: ['pipe', 'inherit', 'inherit'],
127
+ });
128
+
129
+ if (result.status !== 0) {
130
+ throw new Error(`Failed to set ${name} in Vercel ${env}`);
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Main
136
+ // ---------------------------------------------------------------------------
137
+
138
+ const WEBHOOK_NAME = 'Vercel Revalidation';
139
+
140
+ async function main(): Promise<void> {
141
+ const managementToken = getEnv('CONTENTFUL_MANAGEMENT_TOKEN');
142
+ const spaceId = getEnv('CONTENTFUL_SPACE_ID');
143
+
144
+ // Generate secret if not already set
145
+ let secret = envContent.match(/^REVALIDATION_SECRET=(.+)/m)?.[1]?.trim();
146
+ if (!secret) {
147
+ secret = randomBytes(32).toString('base64url');
148
+ console.log('Generated new REVALIDATION_SECRET');
149
+ } else {
150
+ console.log('Using existing REVALIDATION_SECRET from .env.local');
151
+ }
152
+
153
+ const webhookUrl = `${siteUrl}/api/revalidate?secret=${secret}`;
154
+
155
+ // -------------------------------------------------------------------------
156
+ // Create Contentful webhook
157
+ // -------------------------------------------------------------------------
158
+
159
+ console.log('Connecting to Contentful…');
160
+ const client = createClient({ accessToken: managementToken });
161
+
162
+ // Delete existing webhook with the same name (idempotent)
163
+ const existing = await client.webhook.getMany({ spaceId, query: { limit: 100 } });
164
+ const duplicate = existing.items.find((w) => w.name === WEBHOOK_NAME);
165
+ if (duplicate) {
166
+ console.log(`Removing existing webhook: ${duplicate.sys.id}`);
167
+ await client.webhook.delete({ spaceId, webhookDefinitionId: duplicate.sys.id });
168
+ }
169
+
170
+ const topics = [
171
+ 'Entry.publish',
172
+ 'Entry.unpublish',
173
+ 'Entry.delete',
174
+ 'Entry.archive',
175
+ 'Entry.unarchive',
176
+ 'Asset.publish',
177
+ 'Asset.unpublish',
178
+ 'Asset.delete',
179
+ 'Asset.archive',
180
+ 'Asset.unarchive',
181
+ ];
182
+
183
+ console.log(`Creating webhook → ${webhookUrl}`);
184
+ const webhook = await client.webhook.create(
185
+ { spaceId },
186
+ { name: WEBHOOK_NAME, url: webhookUrl, topics, active: true, headers: [] },
187
+ );
188
+ console.log(`Webhook created: ${webhook.sys.id}`);
189
+
190
+ // -------------------------------------------------------------------------
191
+ // Update Vercel env vars
192
+ // -------------------------------------------------------------------------
193
+
194
+ for (const env of ['production', 'preview'] as const) {
195
+ console.log(`Setting REVALIDATION_SECRET in Vercel ${env}…`);
196
+ vercelSetEnv('REVALIDATION_SECRET', secret, env);
197
+ }
198
+
199
+ // -------------------------------------------------------------------------
200
+ // Update .env.local
201
+ // -------------------------------------------------------------------------
202
+
203
+ setEnvLocal('REVALIDATION_SECRET', secret);
204
+ writeFileSync(envPath, envContent);
205
+ console.log('Updated .env.local');
206
+
207
+ // -------------------------------------------------------------------------
208
+ // Summary
209
+ // -------------------------------------------------------------------------
210
+
211
+ console.log('\nDone!');
212
+ console.log(` Webhook: ${WEBHOOK_NAME} (${webhook.sys.id})`);
213
+ console.log(` URL: ${webhookUrl}`);
214
+ console.log(` Secret: ${secret.slice(0, 8)}…`);
215
+ console.log('\nNext: set up Content Preview in Contentful (see skill Step 5)');
216
+ console.log('Then redeploy to pick up the new secret:');
217
+ console.log(' vercel deploy --prod');
218
+ }
219
+
220
+ main().catch((err) => {
221
+ console.error(err instanceof Error ? err.message : err);
222
+ process.exit(1);
223
+ });
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Step 4 — Run the script
229
+
230
+ ```bash
231
+ pnpm tsx scripts/setup-contentful-webhooks.ts <SITE_URL>
232
+ ```
233
+
234
+ Replace `<SITE_URL>` with the URL from Step 2.
235
+
236
+ Report the webhook ID and the truncated secret to the user so they can confirm the output looks correct.
237
+
238
+ If the script fails:
239
+ - `CONTENTFUL_MANAGEMENT_TOKEN not found` → token missing from `.env.local`
240
+ - `vercel: command not found` → install with `npm i -g vercel` then `vercel login`
241
+ - Contentful 403 → management token may be expired; regenerate in Contentful → Settings → CMA Tokens
242
+
243
+ ---
244
+
245
+ ## Step 5 — Set up Content Preview (Live Preview / Inspector mode)
246
+
247
+ This lets editors open the live site directly from a Contentful entry and use inspector mode (click a field to highlight it in the preview iframe).
248
+
249
+ Guide through: **Contentful → Settings → Content preview → Add content preview**
250
+
251
+ Settings:
252
+ - **Name**: `Vercel Preview`
253
+ - For each content type that maps to a page (usually "Page", "Article", "Person" — check the project's `src/lib/registrations.ts` for what's registered):
254
+ - Enable that content type
255
+ - Set the preview URL to: `https://<SITE_URL>/preview?id={entry.sys.id}`
256
+
257
+ The `/preview` route in the template resolves the entry ID to the correct page URL and redirects.
258
+
259
+ **Note:** If this project has a separate staging Vercel deployment with `DRAFT_ONLY=true` (so editors see unpublished content), use that deployment's URL for the preview URL instead of production.
260
+
261
+ ---
262
+
263
+ ## Step 6 — Redeploy and test
264
+
265
+ Redeploy so the new `REVALIDATION_SECRET` env var is live:
266
+
267
+ ```bash
268
+ vercel deploy --prod
269
+ ```
270
+
271
+ Or push to the production branch if the project uses git-based deploys.
272
+
273
+ #### Test the revalidation webhook
274
+
275
+ ```bash
276
+ curl -X POST "https://<SITE_URL>/api/revalidate?secret=<REVALIDATION_SECRET>"
277
+ ```
278
+
279
+ Expected: `200 OK`. A `401` means the secret in the URL doesn't match the deployed env var (check Vercel dashboard → Settings → Environment Variables).
280
+
281
+ #### Confirm webhook delivery in Contentful
282
+
283
+ 1. Publish any entry in Contentful
284
+ 2. Contentful → Settings → Webhooks → click the webhook → Activity Log
285
+ 3. Confirm the most recent call shows `200`
286
+
287
+ #### Test Content Preview
288
+
289
+ 1. Open any Page entry in Contentful
290
+ 2. Click the preview icon (eye / open in new tab)
291
+ 3. The site opens in an iframe showing that page
292
+ 4. Clicking a text field in the Contentful sidebar should highlight the corresponding element in the preview (inspector mode — enabled by the `frame-ancestors` CSP already in `next.config.ts`)
293
+
294
+ ---
295
+
296
+ ## What to do next
297
+
298
+ - **If inspector mode doesn't highlight fields**: check that `getPreviewFieldProps` is applied to field containers in the components. See `.cursorrules` for the pattern.
299
+ - **For draft content visibility**: set `DRAFT_ONLY=true` in Vercel on a preview/staging environment (not production) so editors can see unpublished content without affecting live users.
300
+ - **To rotate the secret later**: re-run `scripts/setup-contentful-webhooks.ts` — it will generate a new secret and update everything automatically.
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: site-workflows-copy-doc-snapshot
3
+ description: Syncs content/copy/*.md files from the Google Docs copy document. Run whenever the copy doc is updated to pull changes into git. Produces a diff-friendly merge — CMS annotations and sync-to-CMS state are never touched.
4
+ license: MIT
5
+ metadata:
6
+ author: se-studio
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Copy Doc Snapshot
11
+
12
+ Pulls the current state of the Google Docs copy document and merges it into the local `content/copy/*.md` files. Running the skill twice with no doc changes produces no diff — only real copy edits show up in git.
13
+
14
+ ## Usage
15
+
16
+ ```
17
+ /copy-doc-snapshot
18
+ ```
19
+
20
+ No arguments — reads config from `.copy-doc-sync.json` in the project root.
21
+
22
+ ---
23
+
24
+ ## Step 1 — Read config
25
+
26
+ Read `.copy-doc-sync.json` from the project root. Shape:
27
+
28
+ ```json
29
+ {
30
+ "googleDocId": "...",
31
+ "outputDir": "content/copy",
32
+ "pages": {
33
+ "Homepage": { "file": "homepage.md", "slug": "/", "title": "Site Homepage" }
34
+ }
35
+ }
36
+ ```
37
+
38
+ If the file does not exist, stop and tell the user: "`.copy-doc-sync.json` not found. Create it in the project root with a `googleDocId` and `pages` map."
39
+
40
+ ---
41
+
42
+ ## Step 2 — Fetch the Google Doc
43
+
44
+ Call `mcp__claude_ai_Google_Drive__read_file_content` with `fileId` set to `googleDocId` from config.
45
+
46
+ If the call fails, stop immediately — do not proceed with stale or partial data. Report the error.
47
+
48
+ Store the full document text as `docText`.
49
+
50
+ ---
51
+
52
+ ## Step 3 — Parse the doc into pages and sections
53
+
54
+ The Google Docs MCP returns the document as markdown-like plain text. The structure uses:
55
+
56
+ - `# Page Name` — H1 line = page boundary. The text after `#` is the page name.
57
+ - `**Section Name**` — A standalone bold line (nothing before or after the `**...**` on the line) = component/section heading.
58
+ - `**Field:** value` or `**Field:**` (labeled line) — field label. Value follows on the same line or on the next non-empty line.
59
+ - Plain text lines after a `**Copy:**` or `**Header:**` label = field value content.
60
+ - `\[placeholder\]` and `TBC` in field values = content not yet confirmed.
61
+
62
+ **Stop processing at `# Current website copy`** — everything from that H1 onwards is reference material and must be ignored.
63
+
64
+ ### Parsing pass
65
+
66
+ Split `docText` on H1 headings (`^# .+`) to get page segments. For each page segment:
67
+
68
+ 1. The page name is the H1 text (trimmed, without the `#`).
69
+ 2. Split the page body on standalone bold lines (`^\*\*[A-Z][^*:]+\*\*\s*$`) to get section segments.
70
+ - A "standalone bold line" is bold text with no other content on the same line and no colon inside it.
71
+ 3. For each section segment, extract:
72
+ - `name` — the bold line text (strip `**` markers)
73
+ - `heading` — value after `**Header:**` label (same line or next non-empty line); `null` if not present
74
+ - `subheading` — the first plain-text line after the heading line that is NOT a labeled field; `null` if not present. (Used to detect section-level subtitles like "Studio technology, evidence generation...")
75
+ - `body` — all lines between `**Copy:**` and the next labeled field or section header; joined as paragraphs
76
+ - `links` — all lines between `**CTAs:**` and the next labeled field or section header; one link per line
77
+ - `items` — if the body contains sub-section structure (nested bold names like `**Studio**`, `**Evidence**`), extract each as a sub-item with its own `name`, `subheading`, and `body`
78
+ - `isTBC` — `true` if the `heading` value is literally `TBC` AND the `body` is empty or also `TBC`
79
+
80
+ ### Recognising collection items within a section
81
+
82
+ Some sections (e.g. "What We Do", "Featured Research", "From The Blog") contain multiple items structured as:
83
+
84
+ ```
85
+ **Item Name**
86
+ Subtitle line (optional)
87
+ Body copy here.
88
+ ```
89
+
90
+ Detect this pattern: after the section-level `**Header:**` and `**Copy:**`, if the body contains lines that are standalone bold (`**...**`) followed by non-bold lines, treat each bold+following-text block as a separate item with:
91
+ - `name` = bold text
92
+ - `subheading` = first non-bold line (if short and no verb — likely a descriptor like "Technology & software")
93
+ - `body` = remaining non-bold lines before the next bold item
94
+
95
+ ---
96
+
97
+ ## Step 4 — For each page in config, merge doc content into the markdown file
98
+
99
+ Work through each entry in `config.pages`. For each page:
100
+
101
+ ### 4a. Locate doc content
102
+
103
+ Find the parsed page segment whose name matches the config key (case-insensitive). If not found, note it in the report as "not found in doc" and skip to the next page — do not modify the file.
104
+
105
+ ### 4b. Read the existing markdown file
106
+
107
+ Read `{outputDir}/{file}`. If it does not exist, create a minimal stub:
108
+
109
+ ```markdown
110
+ ---
111
+ slug: {slug}
112
+ title: {title}
113
+ description: TBC
114
+ indexed: true
115
+ ---
116
+
117
+ # {title}
118
+
119
+ > Last synced from Google Docs: never
120
+ > Last synced to CMS: never
121
+ ```
122
+
123
+ Then proceed to 4c using just the doc content (no existing sections to merge with).
124
+
125
+ If it does exist, parse it to identify:
126
+ - The YAML frontmatter block (lines between first `---` pair)
127
+ - The `> Last synced from Google Docs:` line (may be absent — treat as `never`)
128
+ - The `> Last synced to CMS:` line
129
+ - Each `## Section` block with its `<!-- ... -->` annotation line and all fields
130
+ - Each `### Item` block within collections, with their `<!-- item ... -->` annotation lines
131
+
132
+ ### 4c. Merge doc sections into existing file structure
133
+
134
+ For each `## Section` in the existing file:
135
+
136
+ **Case 1 — Section found in doc (name match, case-insensitive):**
137
+ - Update `**heading:**` with doc `heading` value (or remove the field if `null`)
138
+ - Update `**body:**` with doc `body` text, reflowed as markdown paragraphs
139
+ - Update `**links:**` with doc `links`, one bullet per line, preserving any `(internal: ...)` or `(external: ...)` annotations that already exist in the file for links with matching labels
140
+ - For collection sections with items: recursively apply the same logic to each `### Item` — match by item name, update `heading`, `preHeading` (from doc `subheading`), `body`
141
+ - **Preserve unchanged:** the `<!-- component: ... | cmsLabel: ... -->` annotation, the `<!-- item | cmsLabel: ... -->` annotations, any `**preHeading:**` fields not provided by the doc, and any `<!-- note: ... -->` lines already present
142
+
143
+ **Case 2 — Section is TBC in doc (`isTBC` is true):**
144
+ - Do NOT update any content fields — leave them exactly as they are
145
+ - Add `<!-- note: TBC in Google Doc as of {today} -->` on the line after the `<!-- component: ... -->` annotation, but only if such a note is not already present
146
+
147
+ **Case 3 — Section exists in file but is NOT in the doc:**
148
+ - Preserve the section and all its content unchanged
149
+ - Add `<!-- note: Not found in Google Doc as of {today} — verify still needed -->` after the section's annotation comment, but only if such a note is not already present
150
+ - Flag this section in the report
151
+
152
+ **Case 4 — Section exists in doc but NOT in the existing file:**
153
+ - Append a new `## Section` block at the end of the file (before any Footer section) using the doc content
154
+ - Use this annotation as placeholder: `<!-- component: Generic Component | cmsLabel: {SECTION_NAME} — {PAGE_TITLE} -->`
155
+ - Flag this in the report as "new section — annotation needs review"
156
+
157
+ ### 4d. Preserve these elements unchanged in all cases
158
+
159
+ - The entire YAML frontmatter block
160
+ - `> Last synced to CMS:` line (never touch this)
161
+ - `<!-- component: ... | cmsLabel: ... -->` annotation content
162
+ - `<!-- item | cmsLabel: ... -->` annotation content
163
+ - Any `<!-- note: ... -->` lines already in the file (do not duplicate them)
164
+ - The `## Footer` section (it is reference-only and managed separately via `cms-edit nav`)
165
+
166
+ ### 4e. Update the sync date
167
+
168
+ Get today's date: run `date -u +"%Y-%m-%d"` via Bash. Update the `> Last synced from Google Docs:` line to this date. If the line does not exist in the file, insert it immediately after the H1 heading line and before the first `---` separator.
169
+
170
+ ---
171
+
172
+ ## Step 5 — Write files
173
+
174
+ For each page, compare the updated content to the file on disk. If the content is identical (character-for-character), skip writing — do not produce a noisy no-op diff.
175
+
176
+ Otherwise, write the updated content to the file path. Do not reformat YAML frontmatter or alter indentation in sections that were not modified.
177
+
178
+ ---
179
+
180
+ ## Step 6 — Report
181
+
182
+ After all pages are processed, print a summary in this format:
183
+
184
+ ```
185
+ copy-doc-snapshot complete (YYYY-MM-DD)
186
+
187
+ homepage.md — updated: Hero body, Trusted By body
188
+ about-us.md — no changes (TBC in doc)
189
+ contact-us.md — no changes (TBC in doc)
190
+
191
+ Attention needed:
192
+ homepage.md › Footer: not found in Google Doc — verify still needed
193
+ homepage.md › Services: new section in doc — annotation placeholder added, review cmsLabel
194
+
195
+ New pages in doc not in config (add to .copy-doc-sync.json to enable syncing):
196
+ (none)
197
+ ```
198
+
199
+ If there are no attention items, omit the "Attention needed" block.
200
+
201
+ Finally, suggest the commit message: `git add content/copy && git commit -m "copy: sync from Google Doc (YYYY-MM-DD)"`
202
+
203
+ ---
204
+
205
+ ## Idempotency rules
206
+
207
+ Running the skill twice on the same day with no doc changes must produce identical files and an identical report:
208
+
209
+ - Never include anything in file content that changes between runs except the `> Last synced from Google Docs:` date line — and that only changes once per day
210
+ - Do not reorder sections, items, or links relative to their existing order in the file
211
+ - Preserve exact whitespace in sections that were not modified
212
+ - Do not add duplicate `<!-- note: ... -->` comments — check if the note already exists before inserting
213
+
214
+ ---
215
+
216
+ ## Notes
217
+
218
+ - The skill **never touches** `> Last synced to CMS:` — that line is owned by the `contentful-cms-core` workflow
219
+ - The skill **never creates** new page files unless they were already in config — a new page in the doc requires a human to add it to `.copy-doc-sync.json` with its slug
220
+ - The `# Current website copy` section in the doc is legacy reference material — always ignore it and everything after it
221
+ - Bold lines that are subtitles (e.g., `**Homepage copy**`) rather than section headings can be identified because they appear immediately after a page H1 and contain no matching section in the file — ignore them
222
+ - The `.copy-doc-sync.json` config should be committed to git so any team member can regenerate the snapshot