forgedock 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/bin/cross-publish.mjs +344 -0
- package/bin/forgedock.mjs +229 -17
- package/commands/autopilot.md +77 -0
- package/commands/design-bench.md +176 -0
- package/commands/design-render-critique-loop.md +103 -0
- package/commands/design.md +259 -0
- package/commands/issue.md +7 -3
- package/commands/orchestrate.md +121 -0
- package/commands/{resume.md → pipeline-resume.md} +4 -4
- package/commands/{status.md → pipeline-status.md} +1 -1
- package/commands/qa-sweep.md +37 -2
- package/commands/quality-gate.md +57 -0
- package/commands/review-pr-staging.md +158 -2
- package/commands/test-gate.md +929 -0
- package/commands/work-on/build/implement.md +5 -3
- package/commands/work-on.md +53 -3
- package/docs/CI.md +177 -0
- package/docs/FORGE-PROTOCOL.md +324 -0
- package/docs/articles/b-vs-c-baseline.md +210 -0
- package/docs/articles/full-abc-benchmark.md +205 -0
- package/docs/articles/selective-spec-loading-token-savings.md +140 -0
- package/docs/design/abc-benchmark.md +195 -0
- package/docs/design/design-architect-rationale.md +123 -0
- package/docs/design/design-memory.md +61 -0
- package/docs/design/design-spec-schema.md +248 -0
- package/docs/design/design-system-lint.md +150 -0
- package/docs/design/divergent-generation.md +106 -0
- package/docs/design/effects-appropriateness.md +133 -0
- package/docs/design/fixtures/briefs/README.md +47 -0
- package/docs/design/fixtures/briefs/cadence.md +39 -0
- package/docs/design/fixtures/briefs/plume.md +39 -0
- package/docs/design/fixtures/briefs/slipstream.md +39 -0
- package/docs/design/fixtures/briefs/tender.md +39 -0
- package/docs/design/fixtures/briefs/voltage.md +39 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
- package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
- package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
- package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
- package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
- package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
- package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
- package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
- package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
- package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
- package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
- package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
- package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
- package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
- package/docs/design/foundations/brutalist.css +294 -0
- package/docs/design/foundations/editorial.css +300 -0
- package/docs/design/foundations/minimal-luxury.css +295 -0
- package/docs/design/foundations/technical.css +293 -0
- package/docs/design/foundations/warm-photo.css +299 -0
- package/docs/design/reference-corpus.md +1726 -0
- package/docs/design/render-critique-loop.md +57 -0
- package/docs/spec-graph-schema.md +397 -0
- package/package.json +1 -1
- package/scripts/bench-scorecard.mjs +239 -0
- package/scripts/build-spec-graph.mjs +509 -0
- package/scripts/check-native-conflicts.sh +200 -0
- package/scripts/classify-lane.sh +0 -7
- package/scripts/design-system-lint.mjs +993 -0
- package/scripts/graph-query.sh +486 -0
- package/scripts/transition-label.sh +10 -2
- package/scripts/validate-spec-graph.sh +423 -0
- 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/),
|
|
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
|
+
});
|