myaidev-method 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude-plugin/plugin.json +0 -1
  2. package/.env.example +5 -4
  3. package/CHANGELOG.md +2 -2
  4. package/CONTENT_CREATION_GUIDE.md +489 -3211
  5. package/DEVELOPER_USE_CASES.md +1 -1
  6. package/MODULAR_INSTALLATION.md +2 -2
  7. package/README.md +39 -33
  8. package/TECHNICAL_ARCHITECTURE.md +1 -1
  9. package/USER_GUIDE.md +242 -190
  10. package/agents/content-editor-agent.md +90 -0
  11. package/agents/content-planner-agent.md +97 -0
  12. package/agents/content-research-agent.md +62 -0
  13. package/agents/content-seo-agent.md +101 -0
  14. package/agents/content-writer-agent.md +69 -0
  15. package/agents/infographic-analyzer-agent.md +63 -0
  16. package/agents/infographic-designer-agent.md +72 -0
  17. package/bin/cli.js +776 -422
  18. package/{content-rules.example.md → content-rules-example.md} +2 -2
  19. package/dist/mcp/health-check.js +82 -68
  20. package/dist/mcp/mcp-config.json +8 -0
  21. package/dist/mcp/openstack-server.js +1746 -1262
  22. package/dist/server/.tsbuildinfo +1 -1
  23. package/extension.json +21 -4
  24. package/package.json +181 -184
  25. package/skills/company-config/SKILL.md +133 -0
  26. package/skills/configure/SKILL.md +1 -1
  27. package/skills/myai-configurator/SKILL.md +77 -0
  28. package/skills/myai-configurator/content-creation-configurator/SKILL.md +516 -0
  29. package/skills/myai-configurator/content-maintenance-configurator/SKILL.md +397 -0
  30. package/skills/myai-content-enrichment/SKILL.md +114 -0
  31. package/skills/myai-content-ideation/SKILL.md +288 -0
  32. package/skills/myai-content-ideation/evals/evals.json +182 -0
  33. package/skills/myai-content-production-coordinator/SKILL.md +946 -0
  34. package/skills/{content-rules-setup → myai-content-rules-setup}/SKILL.md +1 -1
  35. package/skills/{content-verifier → myai-content-verifier}/SKILL.md +1 -1
  36. package/skills/myai-content-writer/SKILL.md +333 -0
  37. package/skills/{infographic → myai-infographic}/SKILL.md +1 -1
  38. package/skills/myai-proprietary-content-verifier/SKILL.md +175 -0
  39. package/skills/myai-proprietary-content-verifier/evals/evals.json +36 -0
  40. package/skills/myai-skill-builder/SKILL.md +699 -0
  41. package/skills/myai-skill-builder/agents/analyzer-agent.md +137 -0
  42. package/skills/myai-skill-builder/agents/comparator-agent.md +77 -0
  43. package/skills/myai-skill-builder/agents/grader-agent.md +103 -0
  44. package/skills/myai-skill-builder/assets/eval_review.html +131 -0
  45. package/skills/myai-skill-builder/references/schemas.md +211 -0
  46. package/skills/myai-skill-builder/scripts/aggregate_benchmark.py +190 -0
  47. package/skills/myai-skill-builder/scripts/generate_review.py +381 -0
  48. package/skills/myai-skill-builder/scripts/package_skill.py +91 -0
  49. package/skills/myai-skill-builder/scripts/run_eval.py +105 -0
  50. package/skills/myai-skill-builder/scripts/run_loop.py +211 -0
  51. package/skills/myai-skill-builder/scripts/utils.py +123 -0
  52. package/skills/myai-visual-generator/SKILL.md +125 -0
  53. package/skills/myai-visual-generator/evals/evals.json +155 -0
  54. package/skills/myai-visual-generator/references/infographic-pipeline.md +73 -0
  55. package/skills/myai-visual-generator/references/research-visuals.md +57 -0
  56. package/skills/myai-visual-generator/references/services.md +89 -0
  57. package/skills/myai-visual-generator/scripts/visual-generation-utils.js +1272 -0
  58. package/skills/myaidev-figma/SKILL.md +212 -0
  59. package/skills/myaidev-figma/capture.js +133 -0
  60. package/skills/myaidev-figma/crawl.js +130 -0
  61. package/skills/myaidev-figma-configure/SKILL.md +130 -0
  62. package/skills/openstack-manager/SKILL.md +1 -1
  63. package/skills/payloadcms-publisher/SKILL.md +141 -77
  64. package/skills/payloadcms-publisher/references/field-mapping.md +142 -0
  65. package/skills/payloadcms-publisher/references/lexical-format.md +97 -0
  66. package/skills/security-auditor/SKILL.md +1 -1
  67. package/src/cli/commands/addon.js +105 -7
  68. package/src/config/workflows.js +172 -228
  69. package/src/lib/ascii-banner.js +197 -182
  70. package/src/lib/{content-coordinator.js → content-production-coordinator.js} +649 -459
  71. package/src/lib/installation-detector.js +93 -59
  72. package/src/lib/payloadcms-utils.js +285 -510
  73. package/src/lib/workflow-installer.js +55 -0
  74. package/src/mcp/health-check.js +82 -68
  75. package/src/mcp/openstack-server.js +1746 -1262
  76. package/src/scripts/configure-visual-apis.js +224 -173
  77. package/src/scripts/configure-wordpress-mcp.js +96 -66
  78. package/src/scripts/init/install.js +109 -85
  79. package/src/scripts/init-project.js +138 -67
  80. package/src/scripts/utils/write-content.js +67 -52
  81. package/src/scripts/wordpress/publish-to-wordpress.js +128 -128
  82. package/src/templates/claude/CLAUDE.md +19 -12
  83. package/hooks/hooks.json +0 -26
  84. package/skills/content-coordinator/SKILL.md +0 -130
  85. package/skills/content-enrichment/SKILL.md +0 -80
  86. package/skills/content-writer/SKILL.md +0 -285
  87. package/skills/skill-builder/SKILL.md +0 -417
  88. package/skills/visual-generator/SKILL.md +0 -140
  89. /package/skills/{content-writer → myai-content-writer}/agents/editor-agent.md +0 -0
  90. /package/skills/{content-writer → myai-content-writer}/agents/planner-agent.md +0 -0
  91. /package/skills/{content-writer → myai-content-writer}/agents/research-agent.md +0 -0
  92. /package/skills/{content-writer → myai-content-writer}/agents/seo-agent.md +0 -0
  93. /package/skills/{content-writer → myai-content-writer}/agents/visual-planner-agent.md +0 -0
  94. /package/skills/{content-writer → myai-content-writer}/agents/writer-agent.md +0 -0
@@ -4,9 +4,95 @@
4
4
  * Optimized for Claude Code 2.0 agent integration
5
5
  */
6
6
 
7
- import { readFileSync } from 'fs';
8
- import { parse } from 'dotenv';
9
- import { marked } from 'marked';
7
+ import { readFileSync } from "fs";
8
+ import { dirname, resolve } from "path";
9
+ import { parse } from "dotenv";
10
+ import { createHeadlessEditor } from "@payloadcms/richtext-lexical/lexical/headless";
11
+ import {
12
+ $convertFromMarkdownString,
13
+ CHECK_LIST,
14
+ TRANSFORMERS,
15
+ } from "@payloadcms/richtext-lexical/lexical/markdown";
16
+ import {
17
+ ListNode,
18
+ ListItemNode,
19
+ } from "@payloadcms/richtext-lexical/lexical/list";
20
+ import {
21
+ HeadingNode,
22
+ QuoteNode,
23
+ } from "@payloadcms/richtext-lexical/lexical/rich-text";
24
+ import { TableNode, TableRowNode, TableCellNode } from "@lexical/table";
25
+
26
+ // PayloadCMS internal nodes and transformers (file:// import to bypass exports map)
27
+ const _base = new URL(
28
+ "../../node_modules/@payloadcms/richtext-lexical/",
29
+ import.meta.url,
30
+ ).href;
31
+ const { HorizontalRuleServerNode } = await import(
32
+ _base + "dist/features/horizontalRule/server/nodes/HorizontalRuleNode.js"
33
+ );
34
+ const { MarkdownTransformer: HRTransformer } = await import(
35
+ _base + "dist/features/horizontalRule/server/markdownTransformer.js"
36
+ );
37
+ const { LinkMarkdownTransformer } = await import(
38
+ _base + "dist/features/link/markdownTransformer.js"
39
+ );
40
+ const { LinkNode } = await import(
41
+ _base + "dist/features/link/nodes/LinkNode.js"
42
+ );
43
+ const { TableMarkdownTransformer: TableTransformerFactory } = await import(
44
+ _base + "dist/features/experimental_table/markdownTransformer.js"
45
+ );
46
+ const { UploadServerNode } = await import(
47
+ _base + "dist/features/upload/server/nodes/UploadNode.js"
48
+ );
49
+ const { ServerBlockNode } = await import(
50
+ _base + "dist/features/blocks/server/nodes/BlocksNode.js"
51
+ );
52
+ const { getBlockMarkdownTransformers } = await import(
53
+ _base + "dist/features/blocks/server/markdown/markdownTransformer.js"
54
+ );
55
+ const { CodeBlock } = await import(
56
+ _base + "dist/features/blocks/premade/CodeBlock/index.js"
57
+ );
58
+
59
+ // Build transformers: CHECK_LIST must precede UNORDERED_LIST to match first
60
+ const BASE_TRANSFORMERS = [
61
+ CHECK_LIST,
62
+ ...TRANSFORMERS,
63
+ HRTransformer,
64
+ LinkMarkdownTransformer,
65
+ ];
66
+
67
+ // Code block support via PayloadCMS Blocks feature (converts ```lang blocks to block nodes)
68
+ const codeBlockDef = CodeBlock();
69
+ const blockTransformerFactories = getBlockMarkdownTransformers({
70
+ blocks: [codeBlockDef],
71
+ inlineBlocks: [],
72
+ });
73
+ const LEXICAL_NODES = [
74
+ HeadingNode,
75
+ QuoteNode,
76
+ ListNode,
77
+ ListItemNode,
78
+ LinkNode,
79
+ HorizontalRuleServerNode,
80
+ TableNode,
81
+ TableRowNode,
82
+ TableCellNode,
83
+ UploadServerNode,
84
+ ServerBlockNode,
85
+ ];
86
+ const resolvedBlockTransformers = blockTransformerFactories.map((f) =>
87
+ typeof f === "function"
88
+ ? f({ allNodes: LEXICAL_NODES, allTransformers: BASE_TRANSFORMERS })
89
+ : f,
90
+ );
91
+ const LEXICAL_TRANSFORMERS = [
92
+ ...BASE_TRANSFORMERS,
93
+ TableTransformerFactory({ allTransformers: BASE_TRANSFORMERS }),
94
+ ...resolvedBlockTransformers,
95
+ ];
10
96
 
11
97
  export class PayloadCMSUtils {
12
98
  constructor(config = {}) {
@@ -16,7 +102,7 @@ export class PayloadCMSUtils {
16
102
  config = { ...envConfig, ...config };
17
103
  }
18
104
 
19
- this.url = config.url?.replace(/\/$/, '');
105
+ this.url = config.url?.replace(/\/$/, "");
20
106
  this.email = config.email;
21
107
  this.password = config.password;
22
108
  this.token = null;
@@ -24,17 +110,19 @@ export class PayloadCMSUtils {
24
110
 
25
111
  loadEnvConfig() {
26
112
  try {
27
- const envPath = process.env.ENV_PATH || '.env';
28
- const envContent = readFileSync(envPath, 'utf8');
113
+ const envPath = process.env.ENV_PATH || ".env";
114
+ const envContent = readFileSync(envPath, "utf8");
29
115
  const parsed = parse(envContent);
30
116
 
31
117
  return {
32
118
  url: parsed.PAYLOADCMS_URL,
33
119
  email: parsed.PAYLOADCMS_EMAIL,
34
- password: parsed.PAYLOADCMS_PASSWORD
120
+ password: parsed.PAYLOADCMS_PASSWORD,
35
121
  };
36
122
  } catch (error) {
37
- throw new Error(`Failed to load PayloadCMS configuration: ${error.message}`);
123
+ throw new Error(
124
+ `Failed to load PayloadCMS configuration: ${error.message}`,
125
+ );
38
126
  }
39
127
  }
40
128
 
@@ -44,19 +132,21 @@ export class PayloadCMSUtils {
44
132
  */
45
133
  async authenticate() {
46
134
  if (!this.email || !this.password) {
47
- throw new Error('PAYLOADCMS_EMAIL and PAYLOADCMS_PASSWORD required for authentication');
135
+ throw new Error(
136
+ "PAYLOADCMS_EMAIL and PAYLOADCMS_PASSWORD required for authentication",
137
+ );
48
138
  }
49
139
 
50
140
  try {
51
141
  const response = await fetch(`${this.url}/api/users/login`, {
52
- method: 'POST',
142
+ method: "POST",
53
143
  headers: {
54
- 'Content-Type': 'application/json'
144
+ "Content-Type": "application/json",
55
145
  },
56
146
  body: JSON.stringify({
57
147
  email: this.email,
58
- password: this.password
59
- })
148
+ password: this.password,
149
+ }),
60
150
  });
61
151
 
62
152
  if (!response.ok) {
@@ -69,9 +159,9 @@ export class PayloadCMSUtils {
69
159
 
70
160
  return {
71
161
  success: true,
72
- method: 'jwt',
162
+ method: "jwt",
73
163
  user: data.user,
74
- token: this.token
164
+ token: this.token,
75
165
  };
76
166
  } catch (error) {
77
167
  throw new Error(`PayloadCMS authentication failed: ${error.message}`);
@@ -88,21 +178,24 @@ export class PayloadCMSUtils {
88
178
 
89
179
  const url = `${this.url}${endpoint}`;
90
180
  const headers = {
91
- 'Content-Type': 'application/json',
92
- 'Authorization': `JWT ${this.token}`,
93
- ...options.headers
181
+ "Content-Type": "application/json",
182
+ Authorization: `JWT ${this.token}`,
183
+ ...options.headers,
94
184
  };
95
185
 
96
186
  try {
97
187
  const response = await fetch(url, {
98
188
  ...options,
99
- headers
189
+ headers,
100
190
  });
101
191
 
102
192
  const data = await response.json().catch(() => ({}));
103
193
 
104
194
  if (!response.ok) {
105
- throw new Error(`HTTP ${response.status}: ${data.message || response.statusText}`);
195
+ const detail = data.errors
196
+ ? JSON.stringify(data.errors)
197
+ : data.message || response.statusText;
198
+ throw new Error(`HTTP ${response.status}: ${detail}`);
106
199
  }
107
200
 
108
201
  return data;
@@ -112,479 +205,27 @@ export class PayloadCMSUtils {
112
205
  }
113
206
 
114
207
  /**
115
- * Convert markdown to Lexical rich text format
116
- * PayloadCMS uses Lexical editor by default
208
+ * Convert markdown to Lexical rich text format using PayloadCMS's own Lexical
209
+ * node classes and markdown transformers via a headless editor. This produces
210
+ * serialized JSON identical to what PayloadCMS's editor generates, so
211
+ * parseEditorState() on the server will find all registered node types.
117
212
  */
118
213
  convertMarkdownToLexical(markdown) {
119
- const tokens = marked.lexer(markdown);
120
-
121
- const convertToken = (token) => {
122
- switch (token.type) {
123
- case 'heading':
124
- return {
125
- type: 'heading',
126
- tag: `h${token.depth}`,
127
- children: [{ type: 'text', text: token.text, format: 0 }],
128
- direction: 'ltr',
129
- format: '',
130
- indent: 0,
131
- version: 1
132
- };
133
-
134
- case 'paragraph':
135
- return {
136
- type: 'paragraph',
137
- children: this.parseInlineText(token.text),
138
- direction: 'ltr',
139
- format: '',
140
- indent: 0,
141
- version: 1
142
- };
143
-
144
- case 'list':
145
- return {
146
- type: 'list',
147
- listType: token.ordered ? 'number' : 'bullet',
148
- start: token.start || 1,
149
- tag: token.ordered ? 'ol' : 'ul',
150
- children: token.items.map(item => ({
151
- type: 'listitem',
152
- value: 1,
153
- children: [{
154
- type: 'paragraph',
155
- children: this.parseInlineText(item.text),
156
- direction: 'ltr',
157
- format: '',
158
- indent: 0
159
- }],
160
- direction: 'ltr',
161
- format: '',
162
- indent: 0
163
- })),
164
- direction: 'ltr',
165
- format: '',
166
- indent: 0,
167
- version: 1
168
- };
169
-
170
- case 'code':
171
- return {
172
- type: 'code',
173
- language: token.lang || 'plaintext',
174
- children: [{ type: 'text', text: token.text, format: 0 }],
175
- direction: 'ltr',
176
- format: '',
177
- indent: 0,
178
- version: 1
179
- };
180
-
181
- case 'blockquote':
182
- return {
183
- type: 'quote',
184
- children: [{
185
- type: 'paragraph',
186
- children: this.parseInlineText(token.text),
187
- direction: 'ltr',
188
- format: '',
189
- indent: 0
190
- }],
191
- direction: 'ltr',
192
- format: '',
193
- indent: 0,
194
- version: 1
195
- };
196
-
197
- case 'hr':
198
- return {
199
- type: 'horizontalrule',
200
- version: 1
201
- };
202
-
203
- default:
204
- return {
205
- type: 'paragraph',
206
- children: [{ type: 'text', text: token.raw || '', format: 0 }],
207
- direction: 'ltr',
208
- format: '',
209
- indent: 0,
210
- version: 1
211
- };
212
- }
213
- };
214
-
215
- // Filter out space tokens and convert remaining tokens
216
- const children = tokens
217
- .filter(token => token.type !== 'space')
218
- .map(convertToken);
219
-
220
- return {
221
- root: {
222
- type: 'root',
223
- format: '',
224
- indent: 0,
225
- version: 1,
226
- children,
227
- direction: 'ltr'
228
- }
229
- };
230
- }
231
-
232
- /**
233
- * Parse inline text with formatting (bold, italic, code, links)
234
- * Supports markdown formatters and converts to Lexical format codes:
235
- * - **bold** → format: 1
236
- * - *italic* → format: 2
237
- * - `code` → format: 16
238
- * - ***bold+italic*** → format: 3
239
- */
240
- parseInlineText(text) {
241
- const children = [];
242
-
243
- // Strip HTML tags first
244
- text = text.replace(/<[^>]+>/g, '');
245
-
246
- // Parse markdown inline formatting using marked's inline lexer
247
- try {
248
- const tokens = marked.lexer(text);
249
-
250
- // Extract inline tokens from paragraph if present
251
- let inlineTokens = [];
252
- if (tokens[0]?.type === 'paragraph' && tokens[0].tokens) {
253
- inlineTokens = tokens[0].tokens;
254
- } else if (tokens[0]?.tokens) {
255
- inlineTokens = tokens[0].tokens;
256
- } else {
257
- // No inline tokens, return plain text
258
- return [{
259
- type: 'text',
260
- text: text,
261
- format: 0,
262
- mode: 'normal',
263
- style: '',
264
- detail: 0,
265
- version: 1
266
- }];
267
- }
268
-
269
- // Convert each inline token to Lexical format
270
- for (const token of inlineTokens) {
271
- switch (token.type) {
272
- case 'text':
273
- children.push({
274
- type: 'text',
275
- text: token.text,
276
- format: 0,
277
- mode: 'normal',
278
- style: '',
279
- detail: 0,
280
- version: 1
281
- });
282
- break;
283
-
284
- case 'strong':
285
- children.push({
286
- type: 'text',
287
- text: token.text,
288
- format: 1, // IS_BOLD
289
- mode: 'normal',
290
- style: '',
291
- detail: 0,
292
- version: 1
293
- });
294
- break;
295
-
296
- case 'em':
297
- children.push({
298
- type: 'text',
299
- text: token.text,
300
- format: 2, // IS_ITALIC
301
- mode: 'normal',
302
- style: '',
303
- detail: 0,
304
- version: 1
305
- });
306
- break;
307
-
308
- case 'codespan':
309
- children.push({
310
- type: 'text',
311
- text: token.text,
312
- format: 16, // IS_CODE
313
- mode: 'normal',
314
- style: '',
315
- detail: 0,
316
- version: 1
317
- });
318
- break;
319
-
320
- case 'link':
321
- children.push({
322
- type: 'link',
323
- url: token.href,
324
- children: [{
325
- type: 'text',
326
- text: token.text,
327
- format: 0,
328
- mode: 'normal',
329
- style: '',
330
- detail: 0,
331
- version: 1
332
- }],
333
- direction: 'ltr',
334
- format: '',
335
- indent: 0,
336
- version: 3
337
- });
338
- break;
339
-
340
- default:
341
- // Fallback for any unhandled token types
342
- children.push({
343
- type: 'text',
344
- text: token.raw || token.text || '',
345
- format: 0,
346
- mode: 'normal',
347
- style: '',
348
- detail: 0,
349
- version: 1
350
- });
351
- }
352
- }
353
- } catch (error) {
354
- // If parsing fails, return plain text
355
- children.push({
356
- type: 'text',
357
- text: text,
358
- format: 0,
359
- mode: 'normal',
360
- style: '',
361
- detail: 0,
362
- version: 1
363
- });
364
- }
365
-
366
- return children.length > 0 ? children : [{
367
- type: 'text',
368
- text: text,
369
- format: 0,
370
- mode: 'normal',
371
- style: '',
372
- detail: 0,
373
- version: 1
374
- }];
375
- }
376
-
377
- /**
378
- * Validate Lexical JSON structure
379
- * Ensures output matches PayloadCMS Lexical editor format
380
- * @param {Object} lexicalJSON - The Lexical JSON to validate
381
- * @param {Object} options - Validation options
382
- * @returns {Object} Validation result with errors array
383
- */
384
- validateLexicalStructure(lexicalJSON, options = {}) {
385
- const errors = [];
386
- const warnings = [];
387
-
388
- // Check root structure
389
- if (!lexicalJSON || typeof lexicalJSON !== 'object') {
390
- errors.push('Lexical JSON must be an object');
391
- return { valid: false, errors, warnings };
392
- }
393
-
394
- if (!lexicalJSON.root) {
395
- errors.push('Missing root node');
396
- return { valid: false, errors, warnings };
397
- }
398
-
399
- const root = lexicalJSON.root;
400
-
401
- // Validate root node
402
- if (root.type !== 'root') {
403
- errors.push(`Root type must be 'root', got '${root.type}'`);
404
- }
405
-
406
- if (!Array.isArray(root.children)) {
407
- errors.push('Root must have children array');
408
- return { valid: false, errors, warnings };
409
- }
410
-
411
- // Validate each child node
412
- const validateNode = (node, path = 'root') => {
413
- if (!node || typeof node !== 'object') {
414
- errors.push(`${path}: Node must be an object`);
415
- return;
416
- }
417
-
418
- if (!node.type) {
419
- errors.push(`${path}: Node missing type property`);
420
- return;
421
- }
422
-
423
- // Validate based on node type
424
- switch (node.type) {
425
- case 'heading':
426
- if (!node.tag || !/^h[1-6]$/.test(node.tag)) {
427
- errors.push(`${path}: Heading must have tag h1-h6, got '${node.tag}'`);
428
- }
429
- if (!Array.isArray(node.children)) {
430
- errors.push(`${path}: Heading must have children array`);
431
- }
432
- break;
433
-
434
- case 'paragraph':
435
- if (!Array.isArray(node.children)) {
436
- errors.push(`${path}: Paragraph must have children array`);
437
- }
438
- break;
439
-
440
- case 'list':
441
- if (!['bullet', 'number', 'check'].includes(node.listType)) {
442
- errors.push(`${path}: List listType must be bullet/number/check, got '${node.listType}'`);
443
- }
444
- if (!['ul', 'ol'].includes(node.tag)) {
445
- errors.push(`${path}: List tag must be ul/ol, got '${node.tag}'`);
446
- }
447
- if (!Array.isArray(node.children)) {
448
- errors.push(`${path}: List must have children array`);
449
- }
450
- // Validate list items
451
- node.children?.forEach((item, i) => {
452
- if (item.type !== 'listitem') {
453
- errors.push(`${path}.children[${i}]: List child must be listitem, got '${item.type}'`);
454
- }
455
- });
456
- break;
457
-
458
- case 'listitem':
459
- if (!Array.isArray(node.children)) {
460
- errors.push(`${path}: Listitem must have children array`);
461
- }
462
- break;
463
-
464
- case 'text':
465
- if (typeof node.text !== 'string') {
466
- errors.push(`${path}: Text node must have string text property`);
467
- }
468
- if (typeof node.format !== 'number') {
469
- errors.push(`${path}: Text node format must be number, got ${typeof node.format}`);
470
- }
471
- // Validate format is valid bitwise combination
472
- if (node.format !== 0 && options.strictFormat) {
473
- const validFormats = [1, 2, 3, 4, 8, 16, 32, 64, 128];
474
- const isValidCombination = (format) => {
475
- // Check if format is valid bitwise combination of base values
476
- return format <= 255 && (format & ~255) === 0;
477
- };
478
- if (!isValidCombination(node.format)) {
479
- warnings.push(`${path}: Unusual format value ${node.format}, expected bitwise combination`);
480
- }
481
- }
482
- break;
483
-
484
- case 'link':
485
- if (typeof node.url !== 'string') {
486
- errors.push(`${path}: Link must have string url property`);
487
- }
488
- if (!Array.isArray(node.children)) {
489
- errors.push(`${path}: Link must have children array`);
490
- }
491
- break;
492
-
493
- case 'code':
494
- if (!Array.isArray(node.children)) {
495
- errors.push(`${path}: Code must have children array`);
496
- }
497
- break;
498
-
499
- case 'quote':
500
- if (!Array.isArray(node.children)) {
501
- errors.push(`${path}: Quote must have children array`);
502
- }
503
- break;
504
-
505
- case 'horizontalrule':
506
- // HR nodes don't have children
507
- break;
508
-
509
- default:
510
- if (options.strict) {
511
- warnings.push(`${path}: Unknown node type '${node.type}'`);
512
- }
513
- }
514
-
515
- // Recursively validate children
516
- if (Array.isArray(node.children)) {
517
- node.children.forEach((child, i) => {
518
- validateNode(child, `${path}.children[${i}]`);
519
- });
520
- }
521
- };
522
-
523
- // Validate all root children
524
- root.children.forEach((child, i) => {
525
- validateNode(child, `root.children[${i}]`);
526
- });
527
-
528
- return {
529
- valid: errors.length === 0,
530
- errors,
531
- warnings
532
- };
533
- }
534
-
535
- /**
536
- * Validate and optionally fix Lexical format codes
537
- * Ensures format codes follow Lexical bitwise specification
538
- * @param {number} format - The format code to validate
539
- * @returns {Object} Validation result
540
- */
541
- validateFormatCode(format) {
542
- const VALID_FORMATS = {
543
- BOLD: 1, // 1 << 0
544
- ITALIC: 2, // 1 << 1
545
- STRIKETHROUGH: 4, // 1 << 2
546
- UNDERLINE: 8, // 1 << 3
547
- CODE: 16, // 1 << 4
548
- SUBSCRIPT: 32, // 1 << 5
549
- SUPERSCRIPT: 64, // 1 << 6
550
- HIGHLIGHT: 128 // 1 << 7
551
- };
552
-
553
- if (typeof format !== 'number') {
554
- return {
555
- valid: false,
556
- error: `Format must be number, got ${typeof format}`
557
- };
558
- }
559
-
560
- if (format < 0 || format > 255) {
561
- return {
562
- valid: false,
563
- error: `Format must be between 0 and 255, got ${format}`
564
- };
565
- }
566
-
567
- // Decompose format into individual flags
568
- const flags = [];
569
- Object.entries(VALID_FORMATS).forEach(([name, value]) => {
570
- if ((format & value) === value) {
571
- flags.push(name);
572
- }
573
- });
574
-
575
- return {
576
- valid: true,
577
- format,
578
- flags,
579
- description: flags.length > 0 ? flags.join(' + ') : 'Plain text'
580
- };
214
+ const editor = createHeadlessEditor({ nodes: LEXICAL_NODES });
215
+ editor.update(
216
+ () => {
217
+ $convertFromMarkdownString(markdown, LEXICAL_TRANSFORMERS);
218
+ },
219
+ { discrete: true },
220
+ );
221
+ return editor.getEditorState().toJSON();
581
222
  }
582
223
 
583
224
  /**
584
225
  * List all collections
585
226
  */
586
227
  async listCollections() {
587
- return await this.request('/api');
228
+ return await this.request("/api");
588
229
  }
589
230
 
590
231
  /**
@@ -592,11 +233,11 @@ export class PayloadCMSUtils {
592
233
  */
593
234
  async getDocuments(collection, options = {}) {
594
235
  const params = new URLSearchParams();
595
- if (options.limit) params.append('limit', options.limit);
596
- if (options.page) params.append('page', options.page);
597
- if (options.where) params.append('where', JSON.stringify(options.where));
236
+ if (options.limit) params.append("limit", options.limit);
237
+ if (options.page) params.append("page", options.page);
238
+ if (options.where) params.append("where", JSON.stringify(options.where));
598
239
 
599
- const query = params.toString() ? `?${params.toString()}` : '';
240
+ const query = params.toString() ? `?${params.toString()}` : "";
600
241
  return await this.request(`/api/${collection}${query}`);
601
242
  }
602
243
 
@@ -612,8 +253,8 @@ export class PayloadCMSUtils {
612
253
  */
613
254
  async createDocument(collection, data) {
614
255
  return await this.request(`/api/${collection}`, {
615
- method: 'POST',
616
- body: JSON.stringify(data)
256
+ method: "POST",
257
+ body: JSON.stringify(data),
617
258
  });
618
259
  }
619
260
 
@@ -622,8 +263,8 @@ export class PayloadCMSUtils {
622
263
  */
623
264
  async updateDocument(collection, id, data) {
624
265
  return await this.request(`/api/${collection}/${id}`, {
625
- method: 'PATCH',
626
- body: JSON.stringify(data)
266
+ method: "PATCH",
267
+ body: JSON.stringify(data),
627
268
  });
628
269
  }
629
270
 
@@ -632,38 +273,59 @@ export class PayloadCMSUtils {
632
273
  */
633
274
  async deleteDocument(collection, id) {
634
275
  return await this.request(`/api/${collection}/${id}`, {
635
- method: 'DELETE'
276
+ method: "DELETE",
636
277
  });
637
278
  }
638
279
 
639
280
  /**
640
281
  * Upload media file
282
+ * Uses Node.js native FormData + Blob (requires Node 20+)
641
283
  */
642
- async uploadMedia(filePath, altText = '') {
284
+ async uploadMedia(filePath, altText = "") {
643
285
  if (!this.token) {
644
286
  await this.authenticate();
645
287
  }
646
288
 
647
- const FormData = (await import('form-data')).default;
648
- const fs = await import('fs');
289
+ const { readFileSync } = await import("fs");
290
+ const { basename, extname } = await import("path");
291
+
292
+ const MIME_TYPES = {
293
+ ".png": "image/png",
294
+ ".jpg": "image/jpeg",
295
+ ".jpeg": "image/jpeg",
296
+ ".gif": "image/gif",
297
+ ".webp": "image/webp",
298
+ ".svg": "image/svg+xml",
299
+ ".avif": "image/avif",
300
+ ".ico": "image/x-icon",
301
+ ".bmp": "image/bmp",
302
+ ".pdf": "application/pdf",
303
+ ".mp4": "video/mp4",
304
+ ".webm": "video/webm",
305
+ };
306
+
307
+ const fileName = basename(filePath);
308
+ const mimeType =
309
+ MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
310
+ const fileBuffer = readFileSync(filePath);
311
+ const blob = new Blob([fileBuffer], { type: mimeType });
649
312
 
650
313
  const formData = new FormData();
651
- formData.append('file', fs.createReadStream(filePath));
652
- if (altText) formData.append('alt', altText);
314
+ formData.append("file", blob, fileName);
315
+ if (altText) formData.append("alt", altText);
653
316
 
654
317
  try {
655
318
  const response = await fetch(`${this.url}/api/media`, {
656
- method: 'POST',
319
+ method: "POST",
657
320
  headers: {
658
- 'Authorization': `JWT ${this.token}`,
659
- ...formData.getHeaders()
321
+ Authorization: `JWT ${this.token}`,
660
322
  },
661
- body: formData
323
+ body: formData,
662
324
  });
663
325
 
664
326
  if (!response.ok) {
665
327
  const error = await response.text();
666
- throw new Error(`Upload failed: ${error}`);
328
+ throw new Error(`Upload failed (${response.status}): ${error}`);
667
329
  }
668
330
 
669
331
  return await response.json();
@@ -672,26 +334,139 @@ export class PayloadCMSUtils {
672
334
  }
673
335
  }
674
336
 
337
+ /**
338
+ * Pre-process markdown for publishing: strip HTML wrappers, upload images,
339
+ * and return cleaned markdown + a map of upload placeholders to media IDs.
340
+ */
341
+ async preprocessMarkdownForPublish(markdown, markdownFilePath, frontmatter = {}) {
342
+ const fs = await import("fs");
343
+ const uploads = []; // { placeholder, mediaId }
344
+
345
+ // Strip leading H1 if it duplicates the frontmatter title (title is rendered by the page template)
346
+ let cleaned = markdown.trimStart();
347
+ if (frontmatter.title) {
348
+ cleaned = cleaned.replace(/^#\s+.+\n+/, '');
349
+ }
350
+
351
+ // Strip <div class="infographic" ...> and </div> wrapper lines
352
+ cleaned = cleaned.replace(
353
+ /^<div\s+class="infographic"[^>]*>\s*$/gm,
354
+ "",
355
+ );
356
+ cleaned = cleaned.replace(/^<\/div>\s*$/gm, "");
357
+
358
+ // Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line)
359
+ // This prevents div stripping from leaving excessive blank lines
360
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
361
+
362
+ // Find all markdown images: ![alt](path)
363
+ const imageRegex = /^!\[([^\]]*)\]\(([^)]+)\)\s*$/gm;
364
+ const images = [];
365
+ let match;
366
+ while ((match = imageRegex.exec(cleaned)) !== null) {
367
+ images.push({ full: match[0], alt: match[1], path: match[2] });
368
+ }
369
+
370
+ // Upload each image and replace with placeholder
371
+ const baseDir = markdownFilePath
372
+ ? dirname(markdownFilePath)
373
+ : process.cwd();
374
+ for (const img of images) {
375
+ const filePath = resolve(baseDir, img.path);
376
+ if (!fs.existsSync(filePath)) continue;
377
+
378
+ try {
379
+ const result = await this.uploadMedia(filePath, img.alt);
380
+ const mediaId = result.doc?.id || result.id;
381
+ if (mediaId) {
382
+ const placeholder = `%%UPLOAD:${mediaId}%%`;
383
+ cleaned = cleaned.replace(img.full, placeholder);
384
+ uploads.push({ placeholder, mediaId });
385
+ }
386
+ } catch (err) {
387
+ console.error(`Warning: Failed to upload ${img.path}: ${err.message}`);
388
+ }
389
+ }
390
+
391
+ return { markdown: cleaned, uploads };
392
+ }
393
+
394
+ /**
395
+ * Post-process Lexical JSON to replace upload placeholders with upload nodes.
396
+ * Handles cases where Lexical merges the placeholder with adjacent inline
397
+ * content (e.g. an italic caption on the next line) into a single paragraph.
398
+ */
399
+ injectUploadNodes(lexicalJson, uploads) {
400
+ if (!uploads.length) return lexicalJson;
401
+
402
+ const placeholderMap = new Map(
403
+ uploads.map((u) => [u.placeholder, u.mediaId]),
404
+ );
405
+ const newChildren = [];
406
+
407
+ for (const node of lexicalJson.root.children) {
408
+ if (
409
+ node.type === "paragraph" &&
410
+ node.children?.length >= 1 &&
411
+ node.children[0].type === "text"
412
+ ) {
413
+ const text = node.children[0].text.trim();
414
+ const mediaId = placeholderMap.get(text);
415
+ if (mediaId) {
416
+ // Emit the upload node
417
+ newChildren.push({
418
+ type: "upload",
419
+ version: 3,
420
+ format: "",
421
+ relationTo: "media",
422
+ value: mediaId,
423
+ id: crypto.randomUUID().replace(/-/g, "").slice(0, 24),
424
+ fields: {},
425
+ });
426
+ // If the paragraph had additional children (e.g. merged italic caption),
427
+ // emit them as a separate paragraph
428
+ if (node.children.length > 1) {
429
+ newChildren.push({ ...node, children: node.children.slice(1) });
430
+ }
431
+ continue;
432
+ }
433
+ }
434
+ newChildren.push(node);
435
+ }
436
+
437
+ return {
438
+ ...lexicalJson,
439
+ root: { ...lexicalJson.root, children: newChildren },
440
+ };
441
+ }
442
+
675
443
  /**
676
444
  * Publish markdown content to PayloadCMS
677
445
  */
678
446
  async publishContent(markdownFile, collection, options = {}) {
679
- const fs = await import('fs');
680
- const grayMatter = (await import('gray-matter')).default;
447
+ const fs = await import("fs");
448
+ const grayMatter = (await import("gray-matter")).default;
681
449
 
682
450
  // Read and parse markdown file
683
- const fileContent = fs.readFileSync(markdownFile, 'utf8');
451
+ const fileContent = fs.readFileSync(markdownFile, "utf8");
684
452
  const { data: frontmatter, content } = grayMatter(fileContent);
685
453
 
454
+ // Pre-process: strip HTML wrappers, duplicate H1 title, upload images
455
+ const { markdown: cleanedContent, uploads } =
456
+ await this.preprocessMarkdownForPublish(content, markdownFile, frontmatter);
457
+
686
458
  // Convert markdown to Lexical format
687
- const richText = this.convertMarkdownToLexical(content);
459
+ let richText = this.convertMarkdownToLexical(cleanedContent);
460
+
461
+ // Inject upload nodes where placeholders exist
462
+ richText = this.injectUploadNodes(richText, uploads);
688
463
 
689
464
  // Prepare document data
690
465
  const documentData = {
691
466
  ...frontmatter,
692
467
  content: richText,
693
- status: options.status || frontmatter.status || 'draft',
694
- ...options.additionalFields
468
+ status: options.status || frontmatter.status || "draft",
469
+ ...options.additionalFields,
695
470
  };
696
471
 
697
472
  // Create or update document