hostinger-api-mcp 3.28.6 → 3.29.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.
- package/CHANGELOG.md +7 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +1 -0
- package/skills/headless/VERSION +2 -2
- package/skills/headless/hostinger-mcp.mjs +88 -0
- package/skills/headless/push-content.mjs +530 -0
- package/skills/headless/steps/04-content-model/SKILL.md +9 -9
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +83 -252
- package/skills/headless/steps/04-content-model/wordpress/by-hand.md +31 -0
- package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +2 -2
- package/skills/headless/steps/04-content-model/wordpress/woocommerce.md +21 -49
- package/skills/headless/steps/05-design/SKILL.md +1 -1
- package/skills/headless/wait.mjs +15 -95
- package/skills/headless/wp-mcp.mjs +125 -0
- package/src/core/skills.js +49 -21
- package/src/core/skills.ts +49 -21
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/gpu.js +1 -1
- package/src/servers/gpu.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hostinger Headless content push: creates a WordPress site's content from one JSON file in a single
|
|
3
|
+
// command, instead of one agent call per category, image, page, post and product.
|
|
4
|
+
//
|
|
5
|
+
// node push-content.mjs <domain> # reads ~/.hostinger/<domain>/site.json and content.json
|
|
6
|
+
//
|
|
7
|
+
// It mints the installation token through the local Hostinger MCP server (it never prints or writes
|
|
8
|
+
// it), opens the site's WP MCP, and creates the site title, categories, tags, images, pages, posts, the
|
|
9
|
+
// menu, Yoast fields and the WooCommerce catalog. Every create looks the item up first, so a re-run
|
|
10
|
+
// reuses what exists. Then it reads the result back over public wp/v2 and the Store API, and writes the
|
|
11
|
+
// created-id map into content-model.md.
|
|
12
|
+
//
|
|
13
|
+
// Progress goes to stderr; stdout is one JSON line. Exit 0 when everything was pushed and verified;
|
|
14
|
+
// 1 when the call, the files or some items need fixing (the JSON names what; fix it and re-run); 2 when
|
|
15
|
+
// it couldn't reach the site (`fallback` names the by-hand recipe).
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { connect } from './hostinger-mcp.mjs';
|
|
21
|
+
import { listOf, openSite } from './wp-mcp.mjs';
|
|
22
|
+
|
|
23
|
+
const FALLBACK = 'steps/04-content-model/wordpress/by-hand.md';
|
|
24
|
+
const log = (message) => process.stderr.write(`push-content: ${message}\n`);
|
|
25
|
+
let localMcp = null;
|
|
26
|
+
const finish = (code, result) => {
|
|
27
|
+
localMcp?.close();
|
|
28
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
29
|
+
process.exit(code);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ── the content file ───────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const IMAGE_KEYS = ['url', 'alt', 'title', 'filename', 'caption', 'description'];
|
|
35
|
+
const SEO_KEYS = ['title', 'description', 'focus_keyword'];
|
|
36
|
+
const SHAPES = {
|
|
37
|
+
top: { keys: ['site', 'categories', 'tags', 'pages', 'posts', 'menu', 'store'] },
|
|
38
|
+
site: { keys: ['title', 'description'] },
|
|
39
|
+
term: { keys: ['name', 'slug', 'description', 'parent'], required: ['name', 'slug'] },
|
|
40
|
+
page: { keys: ['slug', 'title', 'content', 'excerpt', 'parent', 'menu_order', 'image', 'seo'], required: ['slug', 'title', 'content'] },
|
|
41
|
+
post: { keys: ['slug', 'title', 'content', 'excerpt', 'categories', 'tags', 'image', 'seo'], required: ['slug', 'title', 'content'] },
|
|
42
|
+
menu: { keys: ['name', 'slug', 'items'], required: ['name', 'items'] },
|
|
43
|
+
menuItem: { keys: ['title', 'page', 'url'], required: ['title'] },
|
|
44
|
+
store: { keys: ['categories', 'products', 'coupons'] },
|
|
45
|
+
// Products take any other WooCommerce product field (stock_quantity, attributes, …) as given.
|
|
46
|
+
product: { required: ['sku', 'name', 'price'] },
|
|
47
|
+
coupon: { keys: ['code', 'discount_type', 'amount', 'description', 'minimum_amount', 'usage_limit', 'date_expires', 'free_shipping'], required: ['code', 'discount_type', 'amount'] },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Returns every problem with the file at once, so one fix-and-rerun covers them all.
|
|
51
|
+
function validate(content) {
|
|
52
|
+
const problems = [];
|
|
53
|
+
const shape = (value, kind, where) => {
|
|
54
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return problems.push(`${where}: should be an object`) && false;
|
|
55
|
+
const { keys, required = [] } = SHAPES[kind];
|
|
56
|
+
for (const k of required) if (value[k] === undefined || value[k] === '') problems.push(`${where}.${k}: missing`);
|
|
57
|
+
if (keys) for (const k of Object.keys(value)) if (!keys.includes(k)) problems.push(`${where}.${k}: unknown field (allowed: ${keys.join(', ')})`);
|
|
58
|
+
return true;
|
|
59
|
+
};
|
|
60
|
+
const list = (value, where) => {
|
|
61
|
+
if (value === undefined) return [];
|
|
62
|
+
if (Array.isArray(value)) return value;
|
|
63
|
+
problems.push(`${where}: should be an array`);
|
|
64
|
+
return [];
|
|
65
|
+
};
|
|
66
|
+
const subset = (value, allowed, where) => {
|
|
67
|
+
if (value === undefined) return;
|
|
68
|
+
if (!value || typeof value !== 'object') return problems.push(`${where}: should be an object`);
|
|
69
|
+
for (const k of Object.keys(value)) if (!allowed.includes(k)) problems.push(`${where}.${k}: unknown field (allowed: ${allowed.join(', ')})`);
|
|
70
|
+
};
|
|
71
|
+
const image = (value, where) => {
|
|
72
|
+
if (value === undefined) return;
|
|
73
|
+
subset(value, IMAGE_KEYS, where);
|
|
74
|
+
if (!/^https:\/\//.test(value?.url ?? '')) problems.push(`${where}.url: should be a direct https file URL`);
|
|
75
|
+
if (!value?.alt) problems.push(`${where}.alt: missing`);
|
|
76
|
+
};
|
|
77
|
+
const refs = (value, known, where) => {
|
|
78
|
+
for (const slug of list(value, where)) if (!known.has(slug)) problems.push(`${where}: "${slug}" is not a slug defined in the file`);
|
|
79
|
+
};
|
|
80
|
+
const unique = (items, key, where) => {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
if (seen.has(item?.[key])) problems.push(`${where}: ${key} "${item[key]}" appears twice`);
|
|
84
|
+
seen.add(item?.[key]);
|
|
85
|
+
}
|
|
86
|
+
return seen;
|
|
87
|
+
};
|
|
88
|
+
// Lookups match the slug WordPress stores (sanitize_title: lower case, non-ASCII percent-encoded), so a
|
|
89
|
+
// slug in any other form would never match on a re-run and be created again as <slug>-2.
|
|
90
|
+
const slugForm = (slug, where) => {
|
|
91
|
+
if (typeof slug === 'string' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) problems.push(`${where}.slug: use lower-case ASCII letters, digits and dashes, like "about-us"`);
|
|
92
|
+
};
|
|
93
|
+
const slugList = (items, where) => {
|
|
94
|
+
items.forEach((item, i) => slugForm(item?.slug, `${where}[${i}]`));
|
|
95
|
+
return unique(items, 'slug', where);
|
|
96
|
+
};
|
|
97
|
+
// The push creates items in file order, so a parent must come before its child.
|
|
98
|
+
const parentsFirst = (items, where) => items.forEach((item, i) => {
|
|
99
|
+
if (item?.parent === undefined) return;
|
|
100
|
+
if (!items.slice(0, i).some((earlier) => earlier?.slug === item.parent)) problems.push(`${where}[${i}].parent: "${item.parent}" should be a slug listed earlier in ${where}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!shape(content, 'top', 'content')) return problems;
|
|
104
|
+
if (content.site !== undefined) shape(content.site, 'site', 'site');
|
|
105
|
+
const categories = list(content.categories, 'categories');
|
|
106
|
+
const tags = list(content.tags, 'tags');
|
|
107
|
+
const pages = list(content.pages, 'pages');
|
|
108
|
+
const posts = list(content.posts, 'posts');
|
|
109
|
+
categories.forEach((t, i) => shape(t, 'term', `categories[${i}]`));
|
|
110
|
+
tags.forEach((t, i) => shape(t, 'term', `tags[${i}]`));
|
|
111
|
+
const categorySlugs = slugList(categories, 'categories');
|
|
112
|
+
const tagSlugs = slugList(tags, 'tags');
|
|
113
|
+
const pageSlugs = slugList(pages, 'pages');
|
|
114
|
+
slugList(posts, 'posts');
|
|
115
|
+
parentsFirst(categories, 'categories');
|
|
116
|
+
parentsFirst(pages, 'pages');
|
|
117
|
+
for (const [kind, items] of [['page', pages], ['post', posts]]) {
|
|
118
|
+
items.forEach((item, i) => {
|
|
119
|
+
const where = `${kind}s[${i}]`;
|
|
120
|
+
if (!shape(item, kind, where)) return;
|
|
121
|
+
image(item.image, `${where}.image`);
|
|
122
|
+
subset(item.seo, SEO_KEYS, `${where}.seo`);
|
|
123
|
+
refs(item.categories, categorySlugs, `${where}.categories`);
|
|
124
|
+
refs(item.tags, tagSlugs, `${where}.tags`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (content.menu !== undefined && shape(content.menu, 'menu', 'menu')) {
|
|
128
|
+
slugForm(content.menu.slug, 'menu');
|
|
129
|
+
list(content.menu.items, 'menu.items').forEach((item, i) => {
|
|
130
|
+
const where = `menu.items[${i}]`;
|
|
131
|
+
if (!shape(item, 'menuItem', where)) return;
|
|
132
|
+
if (!item.page === !item.url) problems.push(`${where}: give exactly one of page (a page slug) or url`);
|
|
133
|
+
if (item.page) refs([item.page], pageSlugs, `${where}.page`);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (content.store !== undefined && shape(content.store, 'store', 'store')) {
|
|
137
|
+
const storeCategories = list(content.store.categories, 'store.categories');
|
|
138
|
+
const products = list(content.store.products, 'store.products');
|
|
139
|
+
const coupons = list(content.store.coupons, 'store.coupons');
|
|
140
|
+
storeCategories.forEach((t, i) => shape(t, 'term', `store.categories[${i}]`));
|
|
141
|
+
const storeCategorySlugs = slugList(storeCategories, 'store.categories');
|
|
142
|
+
parentsFirst(storeCategories, 'store.categories');
|
|
143
|
+
products.forEach((p, i) => {
|
|
144
|
+
const where = `store.products[${i}]`;
|
|
145
|
+
if (!shape(p, 'product', where)) return;
|
|
146
|
+
if (!/^\d+(\.\d+)?$/.test(String(p.price))) problems.push(`${where}.price: should be a decimal in major units, like "12.50"`);
|
|
147
|
+
image(p.image, `${where}.image`);
|
|
148
|
+
refs(p.categories, storeCategorySlugs, `${where}.categories`);
|
|
149
|
+
});
|
|
150
|
+
unique(products, 'sku', 'store.products');
|
|
151
|
+
coupons.forEach((c, i) => {
|
|
152
|
+
const where = `store.coupons[${i}]`;
|
|
153
|
+
if (!shape(c, 'coupon', where)) return;
|
|
154
|
+
if (!['percent', 'fixed_cart', 'fixed_product'].includes(c.discount_type)) problems.push(`${where}.discount_type: should be percent, fixed_cart or fixed_product`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
// A re-run finds an earlier upload by its title, so two different images can't share one.
|
|
158
|
+
const imageUrls = new Map();
|
|
159
|
+
for (const [where, items] of [['pages', pages], ['posts', posts], ['store.products', Array.isArray(content.store?.products) ? content.store.products : []]]) {
|
|
160
|
+
items.forEach((item, i) => {
|
|
161
|
+
const img = item?.image;
|
|
162
|
+
if (!img?.url) return;
|
|
163
|
+
const title = img.title ?? img.alt;
|
|
164
|
+
if (imageUrls.has(title) && imageUrls.get(title) !== img.url) problems.push(`${where}[${i}].image: title "${title}" is already used by another image; give each image its own title`);
|
|
165
|
+
imageUrls.set(title, img.url);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return problems;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── the push ───────────────────────────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
const args = process.argv.slice(2);
|
|
174
|
+
if (args.length !== 1 || args[0].startsWith('-')) finish(1, { ok: false, error: 'usage: push-content.mjs <domain>' });
|
|
175
|
+
const [domain] = args;
|
|
176
|
+
const workspace = path.join(homedir(), '.hostinger', domain);
|
|
177
|
+
function readJson(file) {
|
|
178
|
+
const full = path.join(workspace, file);
|
|
179
|
+
if (!existsSync(full)) finish(1, { ok: false, error: `no ${file} at ${full}` });
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(readFileSync(full, 'utf8'));
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return finish(1, { ok: false, invalid: [`${file}: ${e.message}`] });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const site = readJson('site.json');
|
|
187
|
+
const content = readJson('content.json');
|
|
188
|
+
const problems = validate(content);
|
|
189
|
+
if (problems.length) finish(1, { ok: false, invalid: problems });
|
|
190
|
+
const username = site.username;
|
|
191
|
+
const software = site.wordpress?.install_id;
|
|
192
|
+
if (!username || !software) finish(1, { ok: false, error: 'site.json has no username or wordpress.install_id' });
|
|
193
|
+
const restApi = site.urls?.rest_api ?? `https://${domain}/cms/wp-json/wp/v2`;
|
|
194
|
+
const storeApi = site.urls?.store_api ?? `https://${domain}/cms/wp-json/wc/store/v1`;
|
|
195
|
+
const store = content.store;
|
|
196
|
+
|
|
197
|
+
localMcp = connect('headless-push-content');
|
|
198
|
+
const mint = async () => {
|
|
199
|
+
const { data, error } = await localMcp.execute('wordpress_installations_jwt-token', { username, software });
|
|
200
|
+
if (error || !data?.token) throw new Error(`couldn't mint the installation token: ${error ?? 'no token in the reply'}`);
|
|
201
|
+
return { token: data.token, url: data.mcp_url ?? site.urls?.wp_mcp };
|
|
202
|
+
};
|
|
203
|
+
const wp = await openSite(mint).catch((e) => finish(2, { ok: false, error: `couldn't open the site's WP MCP: ${e.message}`, fallback: FALLBACK }));
|
|
204
|
+
|
|
205
|
+
// The abilities each part of the file needs, checked before anything is written.
|
|
206
|
+
const NEEDS = [
|
|
207
|
+
[true, ['posts-search', 'posts-delete', 'pages-search', 'pages-delete']],
|
|
208
|
+
[content.site, ['wp-settings-update']],
|
|
209
|
+
[content.categories?.length, ['categories-list', 'categories-create']],
|
|
210
|
+
[content.tags?.length, ['tags-list', 'tags-create']],
|
|
211
|
+
[content.pages?.length, ['pages-create', 'pages-update']],
|
|
212
|
+
[content.posts?.length, ['posts-create', 'posts-update']],
|
|
213
|
+
[[...(content.pages ?? []), ...(content.posts ?? []), ...(store?.products ?? [])].some((i) => i.image), ['media-search', 'media-upload-from-url']],
|
|
214
|
+
[content.menu, ['menus-search', 'menus-create', 'menu-items-search', 'menu-items-create']],
|
|
215
|
+
[store?.categories?.length, ['wc-product-categories-list', 'wc-product-categories-create']],
|
|
216
|
+
[store?.products?.length, ['wc-products-search', 'wc-products-create', 'wc-products-update']],
|
|
217
|
+
[store?.coupons?.length, ['wc-coupons-search', 'wc-coupons-create']],
|
|
218
|
+
];
|
|
219
|
+
const missing = NEEDS.filter(([wanted]) => wanted).flatMap(([, abilities]) => abilities).filter((a) => !wp.has(a));
|
|
220
|
+
if (missing.length) finish(2, { ok: false, error: `the site's MCP lacks ${missing.join(', ')}`, fallback: FALLBACK });
|
|
221
|
+
|
|
222
|
+
const ids = { categories: {}, tags: {}, media: {}, pages: {}, posts: {}, menu: null, product_categories: {}, products: {}, coupons: {} };
|
|
223
|
+
const counts = {};
|
|
224
|
+
const failed = [];
|
|
225
|
+
const warnings = [];
|
|
226
|
+
const media = []; // { id, image, owner: { kind, slug } }
|
|
227
|
+
const tally = (kind, verb) => {
|
|
228
|
+
counts[kind] ??= {};
|
|
229
|
+
counts[kind][verb] = (counts[kind][verb] ?? 0) + 1;
|
|
230
|
+
};
|
|
231
|
+
const failure = (item, error) => {
|
|
232
|
+
log(`${item}: FAILED ${error}`);
|
|
233
|
+
failed.push({ item, error });
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
const asRaw = (text) => (text === undefined ? undefined : { raw: String(text) });
|
|
237
|
+
const defined = (object) => Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined));
|
|
238
|
+
const imageTitle = (image) => image.title ?? image.alt;
|
|
239
|
+
// WordPress stores and renders titles escaped and texturized (& → &, ' → ’), so titles are compared
|
|
240
|
+
// on their letters and digits only.
|
|
241
|
+
const titleKey = (title) => String(title ?? '').replace(/&#?\w+;/g, '').replace(/[^\p{L}\p{N}]/gu, '').toLowerCase();
|
|
242
|
+
const sameTitle = (a, b) => titleKey(a) === titleKey(b);
|
|
243
|
+
const mediaName = (image) => image.filename ?? imageTitle(image);
|
|
244
|
+
const parentOf = (item, known) => (item.parent === undefined ? undefined : known[item.parent]);
|
|
245
|
+
|
|
246
|
+
// Looks an item up and reuses a hit, else creates it. `key` is "<kind>/<slug>"; returns the id or null.
|
|
247
|
+
async function ensure(key, { find, match, create }) {
|
|
248
|
+
const kind = key.split('/')[0];
|
|
249
|
+
const found = await wp.run(...find);
|
|
250
|
+
if (found.error) return failure(key, `lookup: ${found.error}`);
|
|
251
|
+
const hit = listOf(found.data).find(match);
|
|
252
|
+
if (hit) {
|
|
253
|
+
tally(kind, 'reused');
|
|
254
|
+
return hit.id;
|
|
255
|
+
}
|
|
256
|
+
const created = await wp.run(...create);
|
|
257
|
+
if (!created.id) return failure(key, created.error ?? 'no id in the reply');
|
|
258
|
+
tally(kind, created.error ? 'reused' : 'created'); // term_exists names the existing id
|
|
259
|
+
log(`${key} → ${created.id}`);
|
|
260
|
+
return created.id;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Looks an item up, then updates the hit or creates it; write(hit) returns the [ability, params] call.
|
|
264
|
+
async function upsert(key, { find, match, write }) {
|
|
265
|
+
const kind = key.split('/')[0];
|
|
266
|
+
const found = await wp.run(...find);
|
|
267
|
+
if (found.error) return failure(key, `lookup: ${found.error}`);
|
|
268
|
+
const hit = listOf(found.data).find(match);
|
|
269
|
+
const result = await wp.run(...(await write(hit)));
|
|
270
|
+
const id = hit?.id ?? result.id;
|
|
271
|
+
if (result.error || !id) return failure(key, result.error ?? 'no id in the reply');
|
|
272
|
+
tally(kind, hit ? 'updated' : 'created');
|
|
273
|
+
log(`${key} → ${id}`);
|
|
274
|
+
return id;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Images. An owner that already has an image keeps it. Otherwise an earlier upload is found by its exact
|
|
278
|
+
// title (media-search matches titles, not filenames), and only then is the URL uploaded, once per URL.
|
|
279
|
+
const uploads = new Map();
|
|
280
|
+
async function imageFor(image, existing, owner) {
|
|
281
|
+
if (!image) return existing || undefined;
|
|
282
|
+
const key = `media/${mediaName(image)}`;
|
|
283
|
+
let id = existing || uploads.get(image.url);
|
|
284
|
+
if (!id) {
|
|
285
|
+
const title = imageTitle(image);
|
|
286
|
+
const found = await wp.run('media-search', { search: title, per_page: 20 });
|
|
287
|
+
if (found.error) return failure(key, `lookup: ${found.error}`) ?? undefined;
|
|
288
|
+
id = listOf(found.data).find((r) => sameTitle(r.post_title ?? r.title?.rendered ?? r.title, title));
|
|
289
|
+
id = id?.ID ?? id?.id;
|
|
290
|
+
if (id) tally('media', 'reused');
|
|
291
|
+
else {
|
|
292
|
+
const created = await wp.run('media-upload-from-url', defined({
|
|
293
|
+
url: image.url, filename: image.filename, title, alt_text: image.alt, caption: image.caption, description: image.description,
|
|
294
|
+
}));
|
|
295
|
+
if (!created.id) return failure(key, created.error ?? 'no id in the reply') ?? undefined;
|
|
296
|
+
id = created.id;
|
|
297
|
+
tally('media', 'created');
|
|
298
|
+
log(`${key} → ${id}`);
|
|
299
|
+
}
|
|
300
|
+
uploads.set(image.url, id);
|
|
301
|
+
}
|
|
302
|
+
ids.media[mediaName(image)] = id;
|
|
303
|
+
media.push({ id, image, owner });
|
|
304
|
+
return id;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 1. Site title and tagline.
|
|
308
|
+
if (content.site) {
|
|
309
|
+
const { error } = await wp.run('wp-settings-update', defined({ title: content.site.title, description: content.site.description }));
|
|
310
|
+
if (error) failure('site', error);
|
|
311
|
+
else tally('site', 'updated');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. WordPress's sample post and page, unless the file reuses their slugs.
|
|
315
|
+
for (const [kind, slug] of [['posts', 'hello-world'], ['pages', 'sample-page']]) {
|
|
316
|
+
if (content[kind]?.some((item) => item.slug === slug)) continue;
|
|
317
|
+
const found = await wp.run(`${kind}-search`, { slug: [slug] });
|
|
318
|
+
for (const row of listOf(found.data)) {
|
|
319
|
+
const { error } = await wp.run(`${kind}-delete`, { id: row.id });
|
|
320
|
+
if (error) failure(`${kind}/${slug}`, `delete: ${error}`);
|
|
321
|
+
else tally(`sample_${kind}`, 'deleted');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 3. Terms, by slug.
|
|
326
|
+
for (const [kind, prefix] of [['categories', 'categories'], ['tags', 'tags'], ['product_categories', 'wc-product-categories']]) {
|
|
327
|
+
const terms = kind === 'product_categories' ? store?.categories : content[kind];
|
|
328
|
+
for (const item of terms ?? []) {
|
|
329
|
+
// The wp/v2 term lists take an array of slugs; WooCommerce's takes one.
|
|
330
|
+
const slug = kind === 'product_categories' ? item.slug : [item.slug];
|
|
331
|
+
const id = await ensure(`${kind}/${item.slug}`, {
|
|
332
|
+
find: [`${prefix}-list`, { slug, hide_empty: false }],
|
|
333
|
+
match: (r) => r.slug === item.slug,
|
|
334
|
+
create: [`${prefix}-create`, defined({ name: item.name, slug: item.slug, description: item.description, parent: parentOf(item, ids[kind]) })],
|
|
335
|
+
});
|
|
336
|
+
if (id) ids[kind][item.slug] = id;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 4. Pages, then posts, by slug; an existing one is updated to match the file.
|
|
341
|
+
for (const kind of ['pages', 'posts']) {
|
|
342
|
+
for (const item of content[kind] ?? []) {
|
|
343
|
+
const key = `${kind}/${item.slug}`;
|
|
344
|
+
const id = await upsert(key, {
|
|
345
|
+
find: [`${kind}-search`, { slug: [item.slug] }],
|
|
346
|
+
match: (r) => r.slug === item.slug,
|
|
347
|
+
write: async (hit) => {
|
|
348
|
+
const fields = defined({
|
|
349
|
+
title: asRaw(item.title), content: asRaw(item.content), excerpt: asRaw(item.excerpt),
|
|
350
|
+
featured_media: await imageFor(item.image, hit?.featured_media, { kind, slug: item.slug }),
|
|
351
|
+
parent: parentOf(item, ids.pages), menu_order: item.menu_order,
|
|
352
|
+
categories: item.categories?.map((s) => ids.categories[s]).filter(Boolean),
|
|
353
|
+
tags: item.tags?.map((s) => ids.tags[s]).filter(Boolean),
|
|
354
|
+
});
|
|
355
|
+
return hit ? [`${kind}-update`, { id: hit.id, ...fields }] : [`${kind}-create`, { slug: item.slug, status: 'publish', ...fields }];
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
if (id) ids[kind][item.slug] = id;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 5. The menu (owners edit it in wp-admin; the frontend reads nav from content-model.md). Items are
|
|
363
|
+
// matched by title, so a re-run adds only the missing ones.
|
|
364
|
+
if (content.menu) {
|
|
365
|
+
const slug = content.menu.slug ?? content.menu.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
366
|
+
const menuId = await ensure(`menu/${slug}`, {
|
|
367
|
+
find: ['menus-search', { slug: [slug] }],
|
|
368
|
+
match: (r) => r.slug === slug,
|
|
369
|
+
create: ['menus-create', { name: content.menu.name, slug }],
|
|
370
|
+
});
|
|
371
|
+
if (menuId) {
|
|
372
|
+
ids.menu = { slug, id: menuId, items: {} };
|
|
373
|
+
const existing = await wp.run('menu-items-search', { menus: [menuId], per_page: 100 });
|
|
374
|
+
const byTitle = new Map(listOf(existing.data).map((r) => [r.title?.raw ?? r.title?.rendered ?? r.title, r.id]));
|
|
375
|
+
for (const [i, item] of content.menu.items.entries()) {
|
|
376
|
+
if (byTitle.has(item.title)) {
|
|
377
|
+
ids.menu.items[item.title] = byTitle.get(item.title);
|
|
378
|
+
tally('menu_items', 'reused');
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const target = item.page ? { type: 'post_type', object: 'page', object_id: ids.pages[item.page] } : { type: 'custom', url: item.url };
|
|
382
|
+
if (item.page && !target.object_id) {
|
|
383
|
+
failure(`menu/${item.title}`, `page ${item.page} wasn't created`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const created = await wp.run('menu-items-create', { title: { raw: item.title }, status: 'publish', menus: menuId, menu_order: i + 1, ...target });
|
|
387
|
+
if (!created.id) failure(`menu/${item.title}`, created.error ?? 'no id in the reply');
|
|
388
|
+
else {
|
|
389
|
+
ids.menu.items[item.title] = created.id;
|
|
390
|
+
tally('menu_items', 'created');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 6. Yoast fields, 50 items a call. SEO never fails the push: a miss is a warning.
|
|
397
|
+
const seoItems = ['pages', 'posts'].flatMap((kind) => (content[kind] ?? []).map((p) => [ids[kind][p.slug], p.seo])).filter(([id, seo]) => id && seo);
|
|
398
|
+
if (seoItems.length && site.seo?.enabled === false) warnings.push('seo: site.json says SEO is off, so the seo fields were skipped');
|
|
399
|
+
else if (seoItems.length && !wp.has('seo-bulk-update')) warnings.push('seo: the site has no seo-bulk-update ability, so the seo fields were skipped');
|
|
400
|
+
else {
|
|
401
|
+
for (let i = 0; i < seoItems.length; i += 50) {
|
|
402
|
+
const posts = seoItems.slice(i, i + 50).map(([id, seo]) => defined({ id, seo_title: seo.title, meta_description: seo.description, focus_keyword: seo.focus_keyword }));
|
|
403
|
+
const { error } = await wp.run('seo-bulk-update', { posts });
|
|
404
|
+
if (error) warnings.push(`seo: ${error}`);
|
|
405
|
+
else counts.seo = { updated: (counts.seo?.updated ?? 0) + posts.length };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 7. Products by SKU (updated to match the file), coupons by code.
|
|
410
|
+
for (const product of store?.products ?? []) {
|
|
411
|
+
const { sku, name, price, image, categories, ...rest } = product;
|
|
412
|
+
const id = await upsert(`products/${sku}`, {
|
|
413
|
+
find: ['wc-products-search', { sku }],
|
|
414
|
+
match: (r) => r.sku === sku,
|
|
415
|
+
write: async (hit) => {
|
|
416
|
+
const imageId = await imageFor(image, hit?.images?.[0]?.id, { kind: 'products', slug: sku });
|
|
417
|
+
const fields = defined({
|
|
418
|
+
name, type: 'simple', status: 'publish', regular_price: String(price), ...rest,
|
|
419
|
+
categories: categories?.map((s) => ids.product_categories[s]).filter(Boolean).map((c) => ({ id: c })),
|
|
420
|
+
images: imageId && !hit?.images?.length ? [{ id: imageId }] : undefined,
|
|
421
|
+
});
|
|
422
|
+
return hit ? ['wc-products-update', { id: hit.id, ...fields }] : ['wc-products-create', { sku, ...fields }];
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (id) ids.products[sku] = id;
|
|
426
|
+
}
|
|
427
|
+
for (const coupon of store?.coupons ?? []) {
|
|
428
|
+
const code = coupon.code.toLowerCase(); // WooCommerce stores codes in lower case
|
|
429
|
+
const id = await ensure(`coupons/${code}`, {
|
|
430
|
+
find: ['wc-coupons-search', { code }],
|
|
431
|
+
match: (r) => r.code?.toLowerCase() === code,
|
|
432
|
+
create: ['wc-coupons-create', { ...coupon, amount: String(coupon.amount) }],
|
|
433
|
+
});
|
|
434
|
+
if (id) ids.coupons[code] = id;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 8. Read it back over the public APIs: each planned slug and SKU exactly once, featured images set,
|
|
438
|
+
// and one image per title (a second one is a duplicate upload).
|
|
439
|
+
async function getAll(base, query) {
|
|
440
|
+
const all = [];
|
|
441
|
+
for (let page = 1; page <= 20; page += 1) {
|
|
442
|
+
try {
|
|
443
|
+
const response = await fetch(`${base}?${query}&per_page=100&page=${page}&_=${Date.now()}`, { signal: AbortSignal.timeout(30_000) });
|
|
444
|
+
if (!response.ok) return { error: `GET ${base} answered ${response.status}` };
|
|
445
|
+
all.push(...(await response.json()));
|
|
446
|
+
if (page >= Number(response.headers.get('x-wp-totalpages') ?? 1)) break;
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return { error: `GET ${base}: ${e.message}` };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { all };
|
|
452
|
+
}
|
|
453
|
+
const verify = {};
|
|
454
|
+
// Runs one read-back: check(all) returns the problems found ({} when none).
|
|
455
|
+
async function verifyEach(what, planned, url, query, check) {
|
|
456
|
+
if (!planned) return;
|
|
457
|
+
const { all, error } = await getAll(url, query);
|
|
458
|
+
const issues = error ?? defined(Object.fromEntries(Object.entries(check(all)).map(([k, v]) => [k, v.length ? v : undefined])));
|
|
459
|
+
if (typeof issues === 'string' || Object.keys(issues).length) {
|
|
460
|
+
verify[what] = issues;
|
|
461
|
+
failure(`verify/${what}`, issues);
|
|
462
|
+
} else verify[what] = `${planned} ok`;
|
|
463
|
+
}
|
|
464
|
+
for (const kind of ['pages', 'posts']) {
|
|
465
|
+
const planned = content[kind] ?? [];
|
|
466
|
+
await verifyEach(kind, planned.length, `${restApi}/${kind}`, '_fields=id,slug,featured_media', (all) => ({
|
|
467
|
+
missing: planned.filter((p) => !all.some((r) => r.slug === p.slug)).map((p) => p.slug),
|
|
468
|
+
duplicates: all.filter((r) => planned.some((p) => new RegExp(`^${p.slug}-\\d+$`).test(r.slug))).map((r) => r.slug),
|
|
469
|
+
no_featured_image: planned.filter((p) => p.image && !all.find((r) => r.slug === p.slug)?.featured_media).map((p) => p.slug),
|
|
470
|
+
}));
|
|
471
|
+
}
|
|
472
|
+
const titles = new Set(media.map((m) => imageTitle(m.image)));
|
|
473
|
+
await verifyEach('media', titles.size, `${restApi}/media`, '_fields=id,title', (all) => ({
|
|
474
|
+
duplicates: [...titles].filter((t) => all.filter((r) => sameTitle(r.title?.rendered, t)).length > 1),
|
|
475
|
+
}));
|
|
476
|
+
await verifyEach('products', store?.products?.length, `${storeApi}/products`, 'status=publish', (all) => ({
|
|
477
|
+
missing_or_duplicated: store.products.filter((p) => all.filter((r) => r.sku === p.sku).length !== 1).map((p) => p.sku),
|
|
478
|
+
no_image: store.products.filter((p) => p.image && !all.find((r) => r.sku === p.sku)?.images?.length).map((p) => p.sku),
|
|
479
|
+
}));
|
|
480
|
+
await verifyEach('product_categories', store?.categories?.length, `${storeApi}/products/categories`, '', (all) => ({
|
|
481
|
+
missing: store.categories.filter((c) => !all.some((r) => r.slug === c.slug)).map((c) => c.slug),
|
|
482
|
+
}));
|
|
483
|
+
|
|
484
|
+
// 9. The id map, into content-model.md (its own sections, replaced on a re-run).
|
|
485
|
+
const cell = (text) => String(text ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
486
|
+
const featured = (kind, slug) => media.find((m) => m.owner.kind === kind && m.owner.slug === slug)?.id;
|
|
487
|
+
const mediaRow = (m) => ['media', cell(mediaName(m.image)), m.id, cell(`on ${m.owner.kind}/${m.owner.slug}${m.image.caption ? `; ${m.image.caption}` : ''}`)];
|
|
488
|
+
const SECTIONS = [
|
|
489
|
+
['Created in WordPress', [
|
|
490
|
+
...Object.entries(ids.categories).map(([s, id]) => ['category', s, id, '']),
|
|
491
|
+
...Object.entries(ids.tags).map(([s, id]) => ['tag', s, id, '']),
|
|
492
|
+
...['pages', 'posts'].flatMap((kind) => Object.entries(ids[kind]).map(([s, id]) => [kind.slice(0, -1), s, id, featured(kind, s) ? `featured image ${featured(kind, s)}` : ''])),
|
|
493
|
+
...(ids.menu ? [['menu', ids.menu.slug, ids.menu.id, `${Object.keys(ids.menu.items).length} items`]] : []),
|
|
494
|
+
...media.filter((m) => m.owner.kind !== 'products').map(mediaRow),
|
|
495
|
+
]],
|
|
496
|
+
['Created in WooCommerce', [
|
|
497
|
+
...Object.entries(ids.product_categories).map(([s, id]) => ['product category', s, id, '']),
|
|
498
|
+
...Object.entries(ids.products).map(([s, id]) => ['product', s, id, featured('products', s) ? `image ${featured('products', s)}` : '']),
|
|
499
|
+
...media.filter((m) => m.owner.kind === 'products').map(mediaRow),
|
|
500
|
+
...Object.entries(ids.coupons).map(([c, id]) => ['coupon', c, id, '']),
|
|
501
|
+
]],
|
|
502
|
+
];
|
|
503
|
+
const modelPath = path.join(workspace, 'content-model.md');
|
|
504
|
+
try {
|
|
505
|
+
let model = existsSync(modelPath) ? readFileSync(modelPath, 'utf8') : `# Content — ${domain}\n`;
|
|
506
|
+
for (const [title, rows] of SECTIONS) {
|
|
507
|
+
const text = rows.length
|
|
508
|
+
? `## ${title}\n\n> Written by push-content.mjs on ${new Date().toISOString()}. A re-run reuses these.\n\n| Kind | Key | ID | Notes |\n|---|---|---|---|\n${rows.map((r) => `| ${r.join(' | ')} |`).join('\n')}\n`
|
|
509
|
+
: '';
|
|
510
|
+
const at = model.search(new RegExp(`^## ${title}\\s*$`, 'm'));
|
|
511
|
+
if (at >= 0) {
|
|
512
|
+
const next = model.slice(at + 3).search(/^## /m);
|
|
513
|
+
model = model.slice(0, at) + text + (next >= 0 ? `\n${model.slice(at + 3 + next)}` : '');
|
|
514
|
+
} else if (text) model = `${model.trimEnd()}\n\n${text}`;
|
|
515
|
+
}
|
|
516
|
+
writeFileSync(modelPath, model);
|
|
517
|
+
} catch (e) {
|
|
518
|
+
warnings.push(`content-model.md: ${e.message}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
finish(failed.length ? 1 : 0, defined({
|
|
522
|
+
ok: failed.length === 0,
|
|
523
|
+
domain,
|
|
524
|
+
counts,
|
|
525
|
+
verify,
|
|
526
|
+
failed: failed.length ? failed : undefined,
|
|
527
|
+
warnings: warnings.length ? warnings : undefined,
|
|
528
|
+
ids,
|
|
529
|
+
content_model: modelPath,
|
|
530
|
+
}));
|
|
@@ -78,6 +78,10 @@ Guidance:
|
|
|
78
78
|
images only generate once the instance exists, so write their brief now and generate at the barrier.
|
|
79
79
|
Put any generated/own media in `~/.hostinger/<domain>/media/` and reference it by filename. Record
|
|
80
80
|
alt text for every image.
|
|
81
|
+
- **WordPress: drafts go straight into `content.json`.** On a WordPress backend, write the generated
|
|
82
|
+
copy, terms, menu and image details into `~/.hostinger/<domain>/content.json` (format: `wordpress/SKILL.md`
|
|
83
|
+
Step 1), not into `content-model.md`, so nothing is written twice. `content-model.md` keeps the fixed
|
|
84
|
+
values, the prompts and the image briefs.
|
|
81
85
|
- **Optional sections.** Drop **Articles** if the brief has no article-like content. Drop **Products**
|
|
82
86
|
for a non-commerce brief; for a commerce brief, generate the product catalog here (names, prices,
|
|
83
87
|
descriptions, collections, images) — that's prepare work, and the `ecommerce` push phase then creates
|
|
@@ -148,14 +152,9 @@ runs **at the instance barrier** (after `provision.status == "completed"`), usin
|
|
|
148
152
|
3. **Use the images before they expire.** The URLs are short-lived, so in the push phase hand each
|
|
149
153
|
`url` straight to WordPress with `media-upload-from-url`, which downloads and stores WP's own copy
|
|
150
154
|
(see the wordpress push `SKILL.md`). Don't persist the temporary URL as the live image. There is no
|
|
151
|
-
photographer to credit — leave `caption` empty or a short on-brand one.
|
|
152
|
-
article
|
|
153
|
-
|
|
154
|
-
```markdown
|
|
155
|
-
Image: Hostinger AI (image_type: <type>, generation_id: <uuid>)
|
|
156
|
-
Image alt: <what the image shows>
|
|
157
|
-
Image file: <site>-<subject>.jpg
|
|
158
|
-
```
|
|
155
|
+
photographer to credit — leave `caption` empty or a short on-brand one. Generate these images last,
|
|
156
|
+
right before the push, and put each `url` in its article's or product's `image` in `content.json`
|
|
157
|
+
(with `alt` and `filename`).
|
|
159
158
|
|
|
160
159
|
On `failed`, retry once with a tightened prompt; if it fails again, fall back to Option B and note
|
|
161
160
|
the gap. Never block the whole push on a single image.
|
|
@@ -208,7 +207,8 @@ Source every image in a few batched calls, not a search and a fetch per photo:
|
|
|
208
207
|
rather than mislabel it.
|
|
209
208
|
4. **Write alt text from what the photo shows**, not from the article's image brief. For example,
|
|
210
209
|
use "Shopfronts and parked scooters on a Hanoi street" even if the article is about rain.
|
|
211
|
-
5. **Record it** under each article in `content-model.md
|
|
210
|
+
5. **Record it** under each article in `content-model.md` (on WordPress, in the article's `image` in
|
|
211
|
+
`content.json` instead: `url`, `alt`, `filename`, and the credit as `caption`):
|
|
212
212
|
|
|
213
213
|
```markdown
|
|
214
214
|
Image: https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max
|