forgedock 1.0.17 → 1.0.18

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 (131) hide show
  1. package/README.md +34 -1
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +229 -17
  4. package/commands/autopilot.md +77 -0
  5. package/commands/design-bench.md +176 -0
  6. package/commands/design-render-critique-loop.md +103 -0
  7. package/commands/design.md +259 -0
  8. package/commands/issue.md +7 -3
  9. package/commands/orchestrate.md +121 -0
  10. package/commands/qa-sweep.md +37 -2
  11. package/commands/quality-gate.md +30 -0
  12. package/commands/review-pr-staging.md +158 -2
  13. package/commands/test-gate.md +929 -0
  14. package/commands/work-on/build/implement.md +5 -3
  15. package/commands/work-on.md +53 -3
  16. package/docs/CI.md +177 -0
  17. package/docs/FORGE-PROTOCOL.md +324 -0
  18. package/docs/articles/b-vs-c-baseline.md +210 -0
  19. package/docs/articles/full-abc-benchmark.md +205 -0
  20. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  21. package/docs/design/abc-benchmark.md +195 -0
  22. package/docs/design/design-architect-rationale.md +123 -0
  23. package/docs/design/design-memory.md +61 -0
  24. package/docs/design/design-spec-schema.md +248 -0
  25. package/docs/design/design-system-lint.md +150 -0
  26. package/docs/design/divergent-generation.md +106 -0
  27. package/docs/design/effects-appropriateness.md +133 -0
  28. package/docs/design/fixtures/briefs/README.md +47 -0
  29. package/docs/design/fixtures/briefs/cadence.md +39 -0
  30. package/docs/design/fixtures/briefs/plume.md +39 -0
  31. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  32. package/docs/design/fixtures/briefs/tender.md +39 -0
  33. package/docs/design/fixtures/briefs/voltage.md +39 -0
  34. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  35. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  36. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  37. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  38. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  39. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  40. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  41. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  42. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  43. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  44. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  45. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  46. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  47. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  48. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  49. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  50. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  51. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  52. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  53. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  54. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  55. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  56. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  57. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  58. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  59. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  60. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  61. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  62. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  63. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  64. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  65. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  66. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  67. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  68. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  69. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  70. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  71. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  72. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  73. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  74. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  75. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  76. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  77. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  78. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  79. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  80. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  81. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  82. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  83. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  84. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  85. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  86. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  87. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  88. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  89. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  90. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  91. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  92. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  93. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  94. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  95. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  96. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  97. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  98. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  99. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  100. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  101. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  102. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  103. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  104. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  105. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  106. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  107. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  108. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  109. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  110. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  111. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  112. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  113. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  114. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  115. package/docs/design/foundations/brutalist.css +294 -0
  116. package/docs/design/foundations/editorial.css +300 -0
  117. package/docs/design/foundations/minimal-luxury.css +295 -0
  118. package/docs/design/foundations/technical.css +293 -0
  119. package/docs/design/foundations/warm-photo.css +299 -0
  120. package/docs/design/reference-corpus.md +1726 -0
  121. package/docs/design/render-critique-loop.md +57 -0
  122. package/docs/spec-graph-schema.md +397 -0
  123. package/package.json +1 -1
  124. package/scripts/bench-scorecard.mjs +239 -0
  125. package/scripts/build-spec-graph.mjs +509 -0
  126. package/scripts/classify-lane.sh +0 -7
  127. package/scripts/design-system-lint.mjs +993 -0
  128. package/scripts/graph-query.sh +486 -0
  129. package/scripts/transition-label.sh +10 -2
  130. package/scripts/validate-spec-graph.sh +423 -0
  131. package/templates/workflows/forgedock-review.yml +96 -0
package/README.md CHANGED
@@ -95,7 +95,13 @@ The context phase surfaced two historical bugs (#577, #587) in the same module
95
95
 
96
96
  ## Install
97
97
 
98
- **Requirements:** [Node.js 18+](https://nodejs.org/), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub CLI (`gh`)](https://cli.github.com/), and [`yq`](https://github.com/mikefarah/yq) (YAML parser used by pipeline commands to read `forge.yaml`)
98
+ **Requirements:** [Node.js 18+](https://nodejs.org/), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [GitHub CLI (`gh`)](https://cli.github.com/), [`yq`](https://github.com/mikefarah/yq) (YAML parser used by pipeline commands to read `forge.yaml`), and **[Playwright MCP](https://github.com/microsoft/playwright-mcp)** (browser automation for `/qa-sweep` and visual testing commands)
99
+
100
+ > **Playwright MCP** is a guaranteed ForgeDock dependency for browser automation commands. Register it in Claude Code after installing ForgeDock:
101
+ > ```bash
102
+ > claude mcp add playwright npx @playwright/mcp@latest
103
+ > ```
104
+ > Verify with `npx forgedock doctor` — Check 9 confirms Playwright MCP is registered.
99
105
 
100
106
  ```bash
101
107
  # Install pipeline commands
@@ -132,6 +138,33 @@ npx forgedock help # Show all available commands
132
138
 
133
139
  ---
134
140
 
141
+ ## Global Command Namespace
142
+
143
+ ForgeDock owns the **global** `~/.claude/commands/` namespace. When `npx forgedock` runs, it symlinks all pipeline commands there and writes a `.symlink-source` sentinel file so other installers can detect the owner:
144
+
145
+ ```
146
+ ~/.claude/commands/
147
+ .symlink-source ← sentinel: "ForgeDock owns this namespace"
148
+ work-on.md → /path/to/forgedock/commands/work-on.md
149
+ review-pr.md → /path/to/forgedock/commands/review-pr.md
150
+ ...
151
+ ```
152
+
153
+ **Project-specific commands** should install to the **project-local** `.claude/commands/` directory instead:
154
+
155
+ ```
156
+ your-project/
157
+ .claude/
158
+ commands/
159
+ my-custom-command.md ← project-local — Claude Code merges these in
160
+ ```
161
+
162
+ Claude Code merges global (`~/.claude/commands/`) and project-local (`.claude/commands/`) automatically. Project-local commands win on name collisions.
163
+
164
+ If another tool runs its own install script that targets `~/.claude/commands/`, it will silently repoint ForgeDock's symlinks to its own source path — breaking all ForgeDock commands globally. The `.symlink-source` sentinel is a machine-readable warning to other installers; ForgeDock also emits a per-command warning during `npx forgedock install` when it detects a collision.
165
+
166
+ ---
167
+
135
168
  ## How It Works
136
169
 
137
170
  ```
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cross-publish.mjs
4
+ *
5
+ * Publishes draft articles from docs/articles/ to dev.to and Hashnode.
6
+ * Reads frontmatter from each .md file. If `published: false`, calls both APIs.
7
+ * Updates frontmatter to `published: true` and writes to content/publish-log.json.
8
+ *
9
+ * Required env vars:
10
+ * DEVTO_API_KEY — dev.to API key (repository secret)
11
+ * HASHNODE_ACCESS_TOKEN — Hashnode personal access token (repository secret)
12
+ * HASHNODE_PUBLICATION_ID — Hashnode publication ID (repository secret)
13
+ *
14
+ * Usage:
15
+ * node bin/cross-publish.mjs
16
+ */
17
+
18
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
19
+ import { existsSync } from 'node:fs';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const REPO_ROOT = join(__dirname, '..');
25
+ const ARTICLES_DIR = join(REPO_ROOT, 'docs', 'articles');
26
+ const PUBLISH_LOG = join(REPO_ROOT, 'content', 'publish-log.json');
27
+
28
+ const DEVTO_API_KEY = process.env.DEVTO_API_KEY;
29
+ const HASHNODE_ACCESS_TOKEN = process.env.HASHNODE_ACCESS_TOKEN;
30
+ const HASHNODE_PUBLICATION_ID = process.env.HASHNODE_PUBLICATION_ID;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Frontmatter parsing
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Parse YAML frontmatter from a markdown file body.
38
+ * Returns { frontmatter: Record<string,string|boolean|string[]>, body: string }.
39
+ */
40
+ function parseFrontmatter(content) {
41
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
42
+ if (!match) return { frontmatter: {}, body: content };
43
+
44
+ const raw = match[1];
45
+ const body = match[2];
46
+ const frontmatter = {};
47
+
48
+ for (const line of raw.split('\n')) {
49
+ const colonIdx = line.indexOf(':');
50
+ if (colonIdx === -1) continue;
51
+ const key = line.slice(0, colonIdx).trim();
52
+ const val = line.slice(colonIdx + 1).trim();
53
+
54
+ if (val === 'true') {
55
+ frontmatter[key] = true;
56
+ } else if (val === 'false') {
57
+ frontmatter[key] = false;
58
+ } else if (val.startsWith('[') && val.endsWith(']')) {
59
+ // Simple array: ["a", "b", "c"]
60
+ frontmatter[key] = val
61
+ .slice(1, -1)
62
+ .split(',')
63
+ .map(s => s.trim().replace(/^["']|["']$/g, ''))
64
+ .filter(Boolean);
65
+ } else {
66
+ // Strip surrounding quotes if present
67
+ frontmatter[key] = val.replace(/^["']|["']$/g, '');
68
+ }
69
+ }
70
+
71
+ return { frontmatter, body };
72
+ }
73
+
74
+ /**
75
+ * Serialize frontmatter back to YAML block. Sets published: true.
76
+ */
77
+ function serializeFrontmatter(frontmatter, body) {
78
+ const lines = [];
79
+ for (const [key, val] of Object.entries(frontmatter)) {
80
+ if (typeof val === 'boolean') {
81
+ lines.push(`${key}: ${val}`);
82
+ } else if (Array.isArray(val)) {
83
+ lines.push(`${key}: [${val.map(v => `"${v}"`).join(', ')}]`);
84
+ } else {
85
+ lines.push(`${key}: ${val}`);
86
+ }
87
+ }
88
+ return `---\n${lines.join('\n')}\n---\n${body}`;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // dev.to publisher
93
+ // ---------------------------------------------------------------------------
94
+
95
+ async function publishToDevTo(article) {
96
+ const { title, description, tags, canonical_url, body, markdown } = article;
97
+
98
+ const payload = {
99
+ article: {
100
+ title,
101
+ body_markdown: markdown,
102
+ published: true,
103
+ tags: Array.isArray(tags) ? tags : [],
104
+ canonical_url: canonical_url || undefined,
105
+ description: description || undefined,
106
+ },
107
+ };
108
+
109
+ console.log(`[dev.to] Publishing: "${title}"`);
110
+
111
+ const response = await fetch('https://dev.to/api/articles', {
112
+ method: 'POST',
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'api-key': DEVTO_API_KEY,
116
+ },
117
+ body: JSON.stringify(payload),
118
+ });
119
+
120
+ const data = await response.json();
121
+
122
+ if (!response.ok) {
123
+ const errorMsg = data?.error || JSON.stringify(data);
124
+ throw new Error(`dev.to API error ${response.status}: ${errorMsg}`);
125
+ }
126
+
127
+ if (!data.id) {
128
+ throw new Error(`dev.to API returned unexpected response: ${JSON.stringify(data)}`);
129
+ }
130
+
131
+ console.log(`[dev.to] Published successfully — ID: ${data.id}, URL: ${data.url}`);
132
+ return { id: String(data.id), url: data.url };
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Hashnode publisher
137
+ // ---------------------------------------------------------------------------
138
+
139
+ async function publishToHashnode(article) {
140
+ const { title, description, tags, canonical_url, markdown } = article;
141
+
142
+ const tagObjects = (Array.isArray(tags) ? tags : []).map(t => ({
143
+ name: t,
144
+ slug: t.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
145
+ }));
146
+
147
+ const mutation = `
148
+ mutation PublishPost($input: PublishPostInput!) {
149
+ publishPost(input: $input) {
150
+ post {
151
+ id
152
+ url
153
+ }
154
+ }
155
+ }
156
+ `;
157
+
158
+ const variables = {
159
+ input: {
160
+ title,
161
+ contentMarkdown: markdown,
162
+ publicationId: HASHNODE_PUBLICATION_ID,
163
+ tags: tagObjects,
164
+ originalArticleURL: canonical_url || undefined,
165
+ metaTags: description
166
+ ? { description }
167
+ : undefined,
168
+ },
169
+ };
170
+
171
+ console.log(`[Hashnode] Publishing: "${title}"`);
172
+
173
+ const response = await fetch('https://gql.hashnode.com/', {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ Authorization: HASHNODE_ACCESS_TOKEN,
178
+ },
179
+ body: JSON.stringify({ query: mutation, variables }),
180
+ });
181
+
182
+ const data = await response.json();
183
+
184
+ if (!response.ok) {
185
+ throw new Error(`Hashnode API HTTP error ${response.status}: ${JSON.stringify(data)}`);
186
+ }
187
+
188
+ if (data.errors && data.errors.length > 0) {
189
+ const errMessages = data.errors.map(e => e.message).join('; ');
190
+ throw new Error(`Hashnode GraphQL errors: ${errMessages}`);
191
+ }
192
+
193
+ const post = data?.data?.publishPost?.post;
194
+ if (!post?.id) {
195
+ throw new Error(`Hashnode API returned unexpected response: ${JSON.stringify(data)}`);
196
+ }
197
+
198
+ console.log(`[Hashnode] Published successfully — ID: ${post.id}, URL: ${post.url}`);
199
+ return { id: post.id, url: post.url };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Publish log
204
+ // ---------------------------------------------------------------------------
205
+
206
+ async function loadPublishLog() {
207
+ try {
208
+ const raw = await readFile(PUBLISH_LOG, 'utf8');
209
+ return JSON.parse(raw);
210
+ } catch {
211
+ return { articles: {} };
212
+ }
213
+ }
214
+
215
+ async function savePublishLog(log) {
216
+ await writeFile(PUBLISH_LOG, JSON.stringify(log, null, 2) + '\n', 'utf8');
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Main
221
+ // ---------------------------------------------------------------------------
222
+
223
+ async function main() {
224
+ // Validate required secrets
225
+ const missing = [];
226
+ if (!DEVTO_API_KEY) missing.push('DEVTO_API_KEY');
227
+ if (!HASHNODE_ACCESS_TOKEN) missing.push('HASHNODE_ACCESS_TOKEN');
228
+ if (!HASHNODE_PUBLICATION_ID) missing.push('HASHNODE_PUBLICATION_ID');
229
+
230
+ if (missing.length > 0) {
231
+ console.error(`ERROR: Missing required environment variables: ${missing.join(', ')}`);
232
+ console.error('Set these as repository secrets in GitHub Actions.');
233
+ process.exit(1);
234
+ }
235
+
236
+ // Load article files
237
+ const files = (await readdir(ARTICLES_DIR)).filter(f => f.endsWith('.md'));
238
+
239
+ if (files.length === 0) {
240
+ console.log('No markdown files found in docs/articles/ — nothing to publish.');
241
+ return;
242
+ }
243
+
244
+ const log = await loadPublishLog();
245
+ let anyPublished = false;
246
+ let anyFailed = false;
247
+
248
+ for (const filename of files) {
249
+ const filepath = join(ARTICLES_DIR, filename);
250
+ const raw = await readFile(filepath, 'utf8');
251
+ const { frontmatter, body } = parseFrontmatter(raw);
252
+
253
+ if (frontmatter.published === true) {
254
+ console.log(`[skip] ${filename} — already published`);
255
+ continue;
256
+ }
257
+
258
+ const { title, description, tags, canonical_url } = frontmatter;
259
+
260
+ if (!title) {
261
+ console.warn(`[warn] ${filename} — missing title in frontmatter, skipping`);
262
+ continue;
263
+ }
264
+
265
+ // Strip HTML comments (demo gif placeholders etc.) from body for publishing.
266
+ // Loop until stable to avoid incomplete sanitization when nested fragments re-form.
267
+ let cleanBody = body;
268
+ let prev;
269
+ do {
270
+ prev = cleanBody;
271
+ cleanBody = cleanBody.replace(/<!--[\s\S]*?-->/g, '');
272
+ } while (cleanBody !== prev);
273
+ cleanBody = cleanBody.trim();
274
+
275
+ const article = { title, description, tags, canonical_url, markdown: cleanBody };
276
+ const slug = filename.replace(/\.md$/, '');
277
+ const logEntry = log.articles[slug] || {};
278
+
279
+ console.log(`\n=== Publishing: ${filename} ===`);
280
+
281
+ // dev.to
282
+ let devtoResult = null;
283
+ if (logEntry.devto?.id) {
284
+ console.log(`[dev.to] Already logged as published (ID: ${logEntry.devto.id}) — skipping`);
285
+ devtoResult = logEntry.devto;
286
+ } else {
287
+ try {
288
+ devtoResult = await publishToDevTo(article);
289
+ logEntry.devto = { ...devtoResult, publishedAt: new Date().toISOString() };
290
+ log.articles[slug] = logEntry;
291
+ await savePublishLog(log);
292
+ } catch (err) {
293
+ console.error(`[dev.to] FAILED for ${filename}: ${err.message}`);
294
+ anyFailed = true;
295
+ }
296
+ }
297
+
298
+ // Hashnode
299
+ let hashnodeResult = null;
300
+ if (logEntry.hashnode?.id) {
301
+ console.log(`[Hashnode] Already logged as published (ID: ${logEntry.hashnode.id}) — skipping`);
302
+ hashnodeResult = logEntry.hashnode;
303
+ } else {
304
+ try {
305
+ hashnodeResult = await publishToHashnode(article);
306
+ logEntry.hashnode = { ...hashnodeResult, publishedAt: new Date().toISOString() };
307
+ log.articles[slug] = logEntry;
308
+ await savePublishLog(log);
309
+ } catch (err) {
310
+ console.error(`[Hashnode] FAILED for ${filename}: ${err.message}`);
311
+ anyFailed = true;
312
+ }
313
+ }
314
+
315
+ // Only mark as published in frontmatter if both platforms succeeded
316
+ if (devtoResult && hashnodeResult) {
317
+ const updated = { ...frontmatter, published: true };
318
+ const newContent = serializeFrontmatter(updated, body);
319
+ await writeFile(filepath, newContent, 'utf8');
320
+ console.log(`[ok] ${filename} — frontmatter updated to published: true`);
321
+ anyPublished = true;
322
+ } else {
323
+ console.warn(`[warn] ${filename} — not fully published on both platforms; frontmatter NOT updated`);
324
+ }
325
+ }
326
+
327
+ await savePublishLog(log);
328
+
329
+ if (anyFailed) {
330
+ console.error('\nOne or more articles failed to publish. See errors above.');
331
+ process.exit(1);
332
+ }
333
+
334
+ if (!anyPublished) {
335
+ console.log('\nNo new articles to publish — all up to date.');
336
+ } else {
337
+ console.log('\nDone. All new articles published successfully.');
338
+ }
339
+ }
340
+
341
+ main().catch(err => {
342
+ console.error('Unexpected error:', err);
343
+ process.exit(1);
344
+ });