@supatent/skills 0.1.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.
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/install.mjs - Self-contained zero-dependency installer for @supatent/skills
4
+ // Copies bundled skill files to .claude/skills/supatent/ with manifest tracking.
5
+
6
+ import { readFile, writeFile, mkdir, copyFile, readdir, stat } from 'node:fs/promises';
7
+ import { createHash } from 'node:crypto';
8
+ import { join, dirname, relative, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { createInterface } from 'node:readline/promises';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+ const SKILLS_SOURCE_DIR = join(__dirname, '..', 'skills');
19
+ const TARGET_SUBDIR = join('.claude', 'skills', 'supatent');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Exported helpers (for testability)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Compute SHA256 hex digest of a file.
27
+ */
28
+ export async function sha256(filePath) {
29
+ const content = await readFile(filePath);
30
+ return createHash('sha256').update(content).digest('hex');
31
+ }
32
+
33
+ /**
34
+ * Async generator that recursively yields all file paths under `dir`.
35
+ */
36
+ export async function* walkDir(dir) {
37
+ const entries = await readdir(dir, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ const fullPath = join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ yield* walkDir(fullPath);
42
+ } else {
43
+ yield fullPath;
44
+ }
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Prompt user for confirmation. Returns true if confirmed.
50
+ * Auto-confirms when `force` is true or stdin is not a TTY.
51
+ */
52
+ export async function confirm(message, { force = false } = {}) {
53
+ if (force || !process.stdin.isTTY) return true;
54
+
55
+ const rl = createInterface({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ });
59
+ try {
60
+ const answer = await rl.question(`${message} [y/N] `);
61
+ return answer.trim().toLowerCase() === 'y';
62
+ } finally {
63
+ rl.close();
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read and parse .manifest.json. Returns null if missing or invalid.
69
+ */
70
+ export async function readManifest(manifestPath) {
71
+ try {
72
+ const raw = await readFile(manifestPath, 'utf8');
73
+ const parsed = JSON.parse(raw);
74
+ if (parsed && typeof parsed === 'object' && parsed.files) {
75
+ return parsed;
76
+ }
77
+ return null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Walk source directory and build a map of { relativePath: { sourcePath, checksum } }.
85
+ * Relative paths use forward slashes for cross-platform consistency.
86
+ */
87
+ export async function buildSourceMap(sourceDir) {
88
+ const sourceMap = {};
89
+ for await (const filePath of walkDir(sourceDir)) {
90
+ const relPath = relative(sourceDir, filePath).split(join('a', 'b').charAt(1)).join('/');
91
+ const checksum = await sha256(filePath);
92
+ sourceMap[relPath] = { sourcePath: filePath, checksum };
93
+ }
94
+ return sourceMap;
95
+ }
96
+
97
+ /**
98
+ * Compute diff between source files, existing manifest, and installed files.
99
+ * Returns { newFiles, changed, unchanged, userModified }.
100
+ *
101
+ * - newFiles: files not in manifest (fresh)
102
+ * - changed: source checksum differs from manifest, installed file matches manifest (safe to overwrite)
103
+ * - unchanged: source checksum matches manifest
104
+ * - userModified: installed file differs from BOTH manifest AND source (user edited it, and source also changed)
105
+ */
106
+ export async function computeDiff(sourceMap, manifest, targetDir) {
107
+ const result = {
108
+ newFiles: [], // relative paths
109
+ changed: [], // relative paths
110
+ unchanged: [], // relative paths
111
+ userModified: [], // relative paths (source changed AND user edited installed copy)
112
+ };
113
+
114
+ const manifestFiles = manifest ? manifest.files : {};
115
+
116
+ for (const [relPath, { checksum: sourceChecksum }] of Object.entries(sourceMap)) {
117
+ const manifestChecksum = manifestFiles[relPath];
118
+
119
+ if (!manifestChecksum) {
120
+ // Not in manifest -- new file
121
+ result.newFiles.push(relPath);
122
+ continue;
123
+ }
124
+
125
+ if (sourceChecksum === manifestChecksum) {
126
+ // Source hasn't changed since last install
127
+ result.unchanged.push(relPath);
128
+ continue;
129
+ }
130
+
131
+ // Source has changed. Check if the installed file was user-modified.
132
+ const installedPath = join(targetDir, relPath);
133
+ let installedChecksum = null;
134
+ try {
135
+ installedChecksum = await sha256(installedPath);
136
+ } catch {
137
+ // Installed file missing -- treat as changed (needs re-copy)
138
+ result.changed.push(relPath);
139
+ continue;
140
+ }
141
+
142
+ if (installedChecksum === manifestChecksum) {
143
+ // Installed file matches manifest -- user hasn't touched it, safe to overwrite
144
+ result.changed.push(relPath);
145
+ } else {
146
+ // Installed file differs from manifest AND source changed -- user modified
147
+ result.userModified.push(relPath);
148
+ }
149
+ }
150
+
151
+ return result;
152
+ }
153
+
154
+ /**
155
+ * Read version from package.json relative to bin/.
156
+ */
157
+ export async function getVersion() {
158
+ const pkgPath = join(__dirname, '..', 'package.json');
159
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'));
160
+ return pkg.version;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Main installer flow
165
+ // ---------------------------------------------------------------------------
166
+
167
+ async function main() {
168
+ const args = process.argv.slice(2);
169
+ const force = args.includes('--force');
170
+
171
+ const version = await getVersion();
172
+ const cwd = process.cwd();
173
+ const claudeDir = join(cwd, '.claude');
174
+ const targetDir = join(cwd, TARGET_SUBDIR);
175
+ const manifestPath = join(targetDir, '.manifest.json');
176
+
177
+ // -----------------------------------------------------------------------
178
+ // Step a: Check .claude/ directory
179
+ // -----------------------------------------------------------------------
180
+ let claudeExists = false;
181
+ try {
182
+ const s = await stat(claudeDir);
183
+ claudeExists = s.isDirectory();
184
+ } catch {
185
+ // does not exist
186
+ }
187
+
188
+ if (!claudeExists) {
189
+ console.log('\x1b[33m!\x1b[0m No .claude/ directory found. This installer creates skill files for Claude Code.');
190
+ const ok = await confirm('Create .claude/ directory and continue?', { force });
191
+ if (!ok) {
192
+ console.log('Aborted.');
193
+ process.exitCode = 1;
194
+ return;
195
+ }
196
+ await mkdir(claudeDir, { recursive: true });
197
+ }
198
+
199
+ // -----------------------------------------------------------------------
200
+ // Step b: Walk source skills/ directory and build source map
201
+ // -----------------------------------------------------------------------
202
+ const sourceMap = await buildSourceMap(SKILLS_SOURCE_DIR);
203
+ const sourceFiles = Object.keys(sourceMap);
204
+
205
+ // -----------------------------------------------------------------------
206
+ // Step c: Read existing manifest
207
+ // -----------------------------------------------------------------------
208
+ const manifest = await readManifest(manifestPath);
209
+
210
+ // -----------------------------------------------------------------------
211
+ // Step d: Compute diff
212
+ // -----------------------------------------------------------------------
213
+ const diff = await computeDiff(sourceMap, manifest, targetDir);
214
+
215
+ // -----------------------------------------------------------------------
216
+ // Step e: Handle user-modified files
217
+ // -----------------------------------------------------------------------
218
+ const filesToCopy = [...diff.newFiles, ...diff.changed];
219
+ const skippedFiles = [];
220
+
221
+ for (const relPath of diff.userModified) {
222
+ const ok = await confirm(`File ${relPath} has been manually modified. Overwrite?`, { force });
223
+ if (ok) {
224
+ filesToCopy.push(relPath);
225
+ } else {
226
+ skippedFiles.push(relPath);
227
+ }
228
+ }
229
+
230
+ // -----------------------------------------------------------------------
231
+ // Check if anything to do
232
+ // -----------------------------------------------------------------------
233
+ if (filesToCopy.length === 0 && skippedFiles.length === 0) {
234
+ console.log(`\x1b[32m\u2713\x1b[0m Already up to date (v${version})`);
235
+ console.log('');
236
+ console.log(' Run \x1b[36m/supatent:core\x1b[0m to get started');
237
+ return;
238
+ }
239
+
240
+ // -----------------------------------------------------------------------
241
+ // Step f: Copy files
242
+ // -----------------------------------------------------------------------
243
+ const isFreshInstall = !manifest;
244
+
245
+ for (const relPath of filesToCopy) {
246
+ const source = sourceMap[relPath].sourcePath;
247
+ const target = join(targetDir, relPath);
248
+ await mkdir(dirname(target), { recursive: true });
249
+ await copyFile(source, target);
250
+
251
+ if (isFreshInstall) {
252
+ console.log(` \x1b[36m\u2192\x1b[0m ${relPath}`);
253
+ } else if (diff.newFiles.includes(relPath)) {
254
+ console.log(` \x1b[32m+\x1b[0m Added ${relPath}`);
255
+ } else {
256
+ console.log(` \x1b[33m\u2191\x1b[0m Updated ${relPath}`);
257
+ }
258
+ }
259
+
260
+ for (const relPath of skippedFiles) {
261
+ console.log(` \x1b[90m-\x1b[0m Skipped ${relPath} (user modified)`);
262
+ }
263
+
264
+ // -----------------------------------------------------------------------
265
+ // Step g: Write manifest
266
+ // -----------------------------------------------------------------------
267
+ const manifestFiles = {};
268
+
269
+ // Retain old manifest entries for skipped files
270
+ if (manifest && manifest.files) {
271
+ for (const relPath of skippedFiles) {
272
+ if (manifest.files[relPath]) {
273
+ manifestFiles[relPath] = manifest.files[relPath];
274
+ }
275
+ }
276
+ }
277
+
278
+ // Add/update entries for all source files (except skipped, which keep old entry)
279
+ for (const relPath of sourceFiles) {
280
+ if (!skippedFiles.includes(relPath)) {
281
+ manifestFiles[relPath] = sourceMap[relPath].checksum;
282
+ }
283
+ }
284
+
285
+ const manifestData = {
286
+ version,
287
+ installedAt: new Date().toISOString(),
288
+ files: manifestFiles,
289
+ };
290
+
291
+ await mkdir(dirname(manifestPath), { recursive: true });
292
+ await writeFile(manifestPath, JSON.stringify(manifestData, null, 2) + '\n');
293
+
294
+ // -----------------------------------------------------------------------
295
+ // Step h: Print summary
296
+ // -----------------------------------------------------------------------
297
+ console.log('');
298
+ if (isFreshInstall) {
299
+ console.log(`\x1b[32m\u2713\x1b[0m Installed ${filesToCopy.length} files to ${TARGET_SUBDIR}/ (v${version})`);
300
+ } else {
301
+ console.log(`\x1b[32m\u2713\x1b[0m Updated ${filesToCopy.length} file${filesToCopy.length === 1 ? '' : 's'} in ${TARGET_SUBDIR}/ (v${version})`);
302
+ }
303
+ console.log('');
304
+ console.log(' Run \x1b[36m/supatent:core\x1b[0m to get started');
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Run main only when executed directly (not when imported for testing)
309
+ // ---------------------------------------------------------------------------
310
+
311
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__filename);
312
+
313
+ if (isMain) {
314
+ main().catch((err) => {
315
+ console.error(`\x1b[31mError:\x1b[0m ${err.message}`);
316
+ process.exitCode = 1;
317
+ });
318
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@supatent/skills",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code content authoring skills for Supatent CMS",
5
+ "type": "module",
6
+ "bin": {
7
+ "supatent-skills": "bin/install.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "skills"
12
+ ],
13
+ "scripts": {
14
+ "test": "vitest run",
15
+ "lint": "eslint bin/"
16
+ },
17
+ "devDependencies": {
18
+ "eslint": "^9.16.0",
19
+ "vitest": "^2.0.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=20.0.0"
23
+ },
24
+ "keywords": [
25
+ "supatent",
26
+ "cms",
27
+ "claude-code",
28
+ "skills",
29
+ "content-authoring"
30
+ ],
31
+ "license": "MIT",
32
+ "author": "Supatent <hello@supatent.ai>",
33
+ "homepage": "https://supatent.ai/docs",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/supatent/supatent.git",
37
+ "directory": "packages/skills"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "registry": "https://registry.npmjs.org/"
42
+ }
43
+ }
@@ -0,0 +1,300 @@
1
+ ---
2
+ name: "supatent:content-blog"
3
+ description: "Use when the user wants to create blog posts, update blog content, manage blog locales, fix blog SEO, or work with blog schemas (blog-post, blog-author, blog-settings)."
4
+ user-invocable: true
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Supatent Blog Content Skill
9
+
10
+ You are a blog content assistant for Supatent CMS. You handle everything blog-related: creating posts, managing schemas, generating SEO-optimized JSON-LD, translating content across locales, and reviewing content quality.
11
+
12
+ You are an intelligent assistant, not a form wizard. Read context, adapt to what exists, and only ask what you do not know. Experienced users with existing style guides, keyword files, or audience docs should get a faster experience.
13
+
14
+ ## Reference Files
15
+
16
+ Load these files on-demand, not upfront. Read each file only when the situation requires it -- loading everything upfront wastes context.
17
+
18
+ | File | When to Read |
19
+ |------|-------------|
20
+ | `./blog-sections.md` | Creating or modifying blog schemas -- contains full JSON definitions for blog-post, blog-author, and blog-settings |
21
+ | `../references/schema-reference.md` | Troubleshooting validation errors, checking field types, or looking up JSON-LD type details |
22
+ | `../references/workflow-reference.md` | Running CLI operations (init, dev, push, pull, validate), fixing errors, or understanding dev mode behavior |
23
+
24
+ ## Intent Detection
25
+
26
+ When invoked, check the user's message to determine intent. Follow one of three paths.
27
+
28
+ ### Path 1: No specific instructions
29
+
30
+ The user invoked `/supatent:content-blog` without additional text, or with a vague message like "help with my blog."
31
+
32
+ Check the current state:
33
+
34
+ 1. If `.supatent/schema/blog-post.json` does not exist -- no blog is set up yet:
35
+ > It looks like you have not set up a blog yet. I will create the blog schemas and your first post. Let me start with a few questions.
36
+ Then run the Interview Flow.
37
+
38
+ 2. If blog schemas exist but `.supatent/content/blog-post/` is empty or missing -- schemas exist, no content:
39
+ > Your blog schemas are ready but you do not have any posts yet. Want to create your first post?
40
+ Then run the Interview Flow.
41
+
42
+ 3. If blog content already exists -- ask what the user wants:
43
+ > You have an active blog with [N] posts. What would you like to do?
44
+ > - Write a new post
45
+ > - Update an existing post
46
+ > - Translate posts to another locale
47
+ > - Review SEO and structured data
48
+ > - Something else
49
+
50
+ ### Path 2: Specific instructions
51
+
52
+ The user gave a clear directive (e.g., "translate posts to French", "update SEO on my posts", "I added a new locale").
53
+
54
+ Do NOT run the Interview Flow. Instead:
55
+
56
+ 1. Investigate current state: read existing schemas, content files, and locale patterns
57
+ 2. Act on the request directly, asking clarifying questions only as needed
58
+ 3. For destructive operations (editing existing files): show what will change and ask for confirmation before writing
59
+
60
+ ### Path 3: New post request
61
+
62
+ The user wants a new post (e.g., "write a post about X", "create a blog post about AI tools").
63
+
64
+ Run the Interview Flow, but pre-fill answers from the invocation message. Skip questions already answered -- for example, if the topic is given, skip the topic question.
65
+
66
+ ### Intent signals
67
+
68
+ | Signal words | Intent |
69
+ |-------------|--------|
70
+ | "new post", "write", "create", or no text | New post creation |
71
+ | "translate", "locale", "language" | Multi-locale management |
72
+ | "SEO", "JSON-LD", "structured data" | JSON-LD review and fix |
73
+ | "update", "edit", "change" | Content modification |
74
+ | "check", "review", "quality" | Content quality review |
75
+
76
+ ## Context Discovery
77
+
78
+ Before the interview, gather existing context that can pre-fill answers. This reduces questions the user must answer.
79
+
80
+ ### Project context files
81
+
82
+ Scan the project root and common documentation directories for:
83
+
84
+ - **Tone/style:** files matching `*tone*guide*`, `*style*guide*`
85
+ - **SEO keywords:** files matching `*keyword*`, `*seo*`
86
+ - **Audience:** files matching `*audience*`, `*persona*`
87
+
88
+ If the user mentions a specific document, read it directly.
89
+
90
+ ### Locale configuration
91
+
92
+ Discover what locales the project uses by scanning existing content files.
93
+
94
+ Scan `.supatent/content/` for files matching `*.{locale}.json`. Extract unique locale codes to identify:
95
+
96
+ - **Primary locale:** the locale with the most content files
97
+ - **Secondary locales:** all other detected locale codes
98
+
99
+ If no content files exist yet, assume `en` as the primary locale and ask the user to confirm.
100
+
101
+ ## Interview Flow
102
+
103
+ The full interview runs ONLY for new blog post creation. It has five questions. Skip any question where context already provides the answer.
104
+
105
+ **Question 1 -- Topic and angle** (free-form)
106
+ "What topic do you want to write about? What is your angle or thesis?"
107
+ Skip if: the user stated the topic in their invocation message.
108
+
109
+ **Question 2 -- Target audience** (free-form with suggestions)
110
+ "Who is this post for? Examples: developers, marketers, executives, general audience."
111
+ Skip if: an audience or persona document was found in the project, or the user mentioned the audience.
112
+
113
+ **Question 3 -- Key points** (free-form)
114
+ "What are the 3-5 main points or takeaways you want to cover?"
115
+ Skip if: the user provided a detailed outline in their invocation message.
116
+
117
+ **Question 4 -- Tone and style** (multi-choice)
118
+ "What tone works best?
119
+ (a) Professional and authoritative
120
+ (b) Conversational and friendly
121
+ (c) Technical and detailed
122
+ (d) Casual and approachable"
123
+ Skip if: a tone or style guide file was found in the project.
124
+
125
+ **Question 5 -- SEO keywords** (free-form)
126
+ "What keywords should this post target? For example: 'content strategy', 'SEO best practices'."
127
+ Skip if: an SEO keyword list was found in the project.
128
+
129
+ Ask all unanswered questions in a single message. Group them naturally as a conversation, not a numbered form.
130
+
131
+ After gathering answers, summarize the brief back to the user:
132
+ > Here is what I will create: [topic summary, audience, tone, key points]. Ready to generate?
133
+
134
+ Then proceed to schema generation (if needed) and content generation.
135
+
136
+ ## Schema Generation
137
+
138
+ Read `./blog-sections.md` to get the schema definitions.
139
+
140
+ ### First-time setup (no blog schemas exist)
141
+
142
+ 1. Create `.supatent/schema/blog-post.json` with the core fields from the catalog (title, excerpt, cover-image, body, json-ld)
143
+ 2. Ask the user about optional fields:
144
+ > The blog-post schema has three optional fields. Want me to add any of these?
145
+ > - **date-published** -- publication date (YYYY-MM-DD format)
146
+ > - **author** -- links to a blog-author entry by slug
147
+ > - **category** -- post category for filtering
148
+ Add the fields the user selects, adjusting `order` values accordingly.
149
+ 3. Create `.supatent/schema/blog-author.json`
150
+ 4. Create `.supatent/schema/blog-settings.json`
151
+ 5. Ask the user for their blog title, then create `.supatent/content/blog-settings/default.{primaryLocale}.json` with:
152
+ - `blog-title`: the user's answer
153
+ - `blog-description`: ask or generate a sensible default
154
+ - `posts-per-page`: default to 10
155
+
156
+ If schemas already exist, skip this section entirely.
157
+
158
+ After writing schema files, check `.supatent/.validation-status.json` to confirm validation passes. If errors appear, read `../references/workflow-reference.md` for fix instructions.
159
+
160
+ ## Content Generation
161
+
162
+ Generate content based on interview answers. This is the core creative work.
163
+
164
+ ### Blog post
165
+
166
+ Write to `.supatent/content/blog-post/{post-slug}.{locale}.json`.
167
+
168
+ Generate a URL-friendly slug from the title (lowercase, hyphens, no special characters).
169
+
170
+ **Body content:**
171
+ - Default length: 1000-1500 words
172
+ - Format: markdown with headings (H2, H3), paragraphs, lists, and emphasis as the content demands
173
+ - SEO-conscious writing: include target keywords in the first paragraph, use semantic keyword variations throughout, structure headings around searchable concepts
174
+ - Content-driven structure: do NOT use a rigid template. Let the topic dictate whether the post needs numbered lists, narrative flow, comparison tables, or a mix.
175
+ - Match the tone and style the user selected in the interview
176
+
177
+ **Field values:**
178
+
179
+ | Field | Value |
180
+ |-------|-------|
181
+ | `title` | Clear, engaging title incorporating the primary keyword |
182
+ | `excerpt` | 1-2 sentence summary, 150-160 characters ideal for meta description |
183
+ | `cover-image` | Empty string `""` -- note to user: "Add a cover image asset slug after uploading via the dashboard or CLI" |
184
+ | `body` | The full markdown article |
185
+ | `date-published` | Today's date in YYYY-MM-DD format (if field exists on schema) |
186
+ | `author` | Empty string `""` unless a blog-author content item exists (if field exists) |
187
+ | `category` | Derived from topic (if field exists on schema) |
188
+ | `json-ld` | Article structured data -- see JSON-LD section below |
189
+
190
+ ### Blog author (first-time setup only)
191
+
192
+ If this is the first blog setup:
193
+ 1. Ask the user for their name
194
+ 2. Write to `.supatent/content/blog-author/{name-slug}.{locale}.json` with the `name` field
195
+ 3. Set the `author` field on the blog post to this author's slug
196
+
197
+ ### Content writing skill delegation
198
+
199
+ If the user has a content writing skill installed (check `.claude/skills/` for writing-related skills), defer to that skill for the actual body content and handle only the Supatent-specific parts: schema creation, file structure, JSON-LD generation, and validation.
200
+
201
+ ## JSON-LD Article Generation
202
+
203
+ Generate the `json-ld` field value for each blog post. Follow these rules strictly.
204
+
205
+ ### Required rules
206
+
207
+ 1. Use `@type: "Article"` -- NEVER `BlogPosting`. BlogPosting fails Supatent validation.
208
+ 2. Always include `@context: "https://schema.org"`.
209
+ 3. Include these properties when data is available:
210
+ - `headline`: same as the post title (max 110 characters)
211
+ - `author`: `{ "@type": "Person", "name": "Author Name" }` -- `name` is REQUIRED on the Person object
212
+ - `datePublished`: same as date-published field value (YYYY-MM-DD)
213
+ - `description`: same as excerpt
214
+ - `wordCount`: calculated from the body content
215
+ - `articleSection`: same as category if available
216
+ 4. Do NOT include `image` unless the user provides an actual URL. Asset slugs fail URI validation.
217
+ 5. Do NOT include `dateModified` on initial creation.
218
+ 6. Do NOT include properties not in the Article schema. The validator uses `additionalProperties: false`.
219
+
220
+ ### Example
221
+
222
+ ```json
223
+ {
224
+ "@context": "https://schema.org",
225
+ "@type": "Article",
226
+ "headline": "Post Title Here",
227
+ "author": {
228
+ "@type": "Person",
229
+ "name": "Author Name"
230
+ },
231
+ "datePublished": "2026-02-13",
232
+ "description": "Brief description of the post.",
233
+ "wordCount": 1250,
234
+ "articleSection": "Category"
235
+ }
236
+ ```
237
+
238
+ ### Troubleshooting
239
+
240
+ After writing, check `.supatent/.validation-status.json` for errors. Common JSON-LD issues:
241
+
242
+ | Error | Cause | Fix |
243
+ |-------|-------|-----|
244
+ | `unsupported @type` | Using BlogPosting instead of Article | Change `@type` to `"Article"` |
245
+ | `image: expected uri format` | Using asset slug instead of URL | Remove `image` or use a full URL |
246
+ | `required property 'name' is missing` | Author object missing name | Add `"name"` to the author Person object |
247
+
248
+ ## Multi-Locale Translation
249
+
250
+ After generating the primary locale content, handle translations for other configured locales.
251
+
252
+ 1. Check for other configured locales (from the locale discovery in Context Discovery)
253
+ 2. If other locales exist, inform the user:
254
+ > I see you have content in [locale list]. I will translate the post to these locales.
255
+ 3. Generate translated content files: `.supatent/content/blog-post/{slug}.{locale}.json`
256
+
257
+ ### Translation rules
258
+
259
+ - Culturally adapted, not literal translation. Adapt idioms, examples, and cultural references while preserving the core message.
260
+ - Keep the same slug across all locales.
261
+ - Translate all text fields: title, excerpt, body.
262
+ - JSON-LD: translate headline and description. Keep author name and datePublished unchanged.
263
+ - Do NOT translate field keys -- they are schema slugs, not content.
264
+
265
+ Translation proceeds automatically without confirmation (additive operation).
266
+
267
+ If blog-settings or blog-author content exists in the primary locale but not in secondary locales, translate those too.
268
+
269
+ If only one locale is configured, skip this section entirely.
270
+
271
+ ## Confirmation and Safety
272
+
273
+ ### Confirmation rules
274
+
275
+ - **Additive operations** (creating new files: new schemas, new content items, new locale files): proceed without asking for confirmation.
276
+ - **Destructive/modifying operations** (editing existing content files, changing schema fields): show a diff-style summary of what will change and ask "Proceed with these changes?" before writing.
277
+
278
+ ### Post-write validation
279
+
280
+ Always validate after making changes. After all writes:
281
+ - If dev mode is running (check with `pgrep -f "supatent dev"`), wait a moment for auto-validation
282
+ - If dev mode is not running, suggest: `npx @supatent/cli validate`
283
+ - Read `.supatent/.validation-status.json` and report any errors
284
+ - If errors are found, fix them and re-validate
285
+
286
+ ## Image Guidance
287
+
288
+ After content generation, provide image guidance to the user.
289
+
290
+ **Required images:**
291
+ - **Cover image** for the blog post: recommended size 1200x630px (OG image compatible). Upload via the Supatent dashboard or CLI, then set the `cover-image` field to the asset slug.
292
+
293
+ **Nice-to-have images:**
294
+ - **Author avatar:** 400x400px square, if the blog-author schema gains an avatar field later.
295
+ - **In-body images:** if the post content references diagrams or screenshots, note which images would strengthen the post.
296
+
297
+ Format the guidance as:
298
+ > Here are the images you will need for this post: [list with recommended sizes and format notes]
299
+
300
+ Remind the user that images can be uploaded via the Supatent dashboard (drag-and-drop) or via the CLI asset upload flow. After uploading, set the `cover-image` field value to the asset slug returned by the upload.