wuppycms 0.0.0-stage → 0.2.1
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/dist/admin-fo4l4lu2.mjs +59 -0
- package/dist/astro.d.mts +20 -0
- package/dist/astro.mjs +302 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +210 -0
- package/dist/components-BrGAEPly.mjs +39 -0
- package/dist/config-CM8_-x_W.mjs +1242 -0
- package/dist/dist-D7fysV_5.mjs +9048 -0
- package/dist/frontmatter-DGaz9--t.mjs +24 -0
- package/dist/generate-iX4B_2U2.mjs +473 -0
- package/dist/i18n-CgLAHd9Q.mjs +79 -0
- package/dist/index-B6AHmgP9.d.mts +34 -0
- package/dist/index.d.mts +8636 -0
- package/dist/index.mjs +83 -0
- package/dist/live-runtime.d.mts +120 -0
- package/dist/live-runtime.mjs +207 -0
- package/dist/markdown.mjs +4 -0
- package/dist/mdx.d.mts +68 -0
- package/dist/mdx.mjs +4 -0
- package/dist/media-DGfJXgBA.mjs +356 -0
- package/dist/page-cache-middleware.d.mts +8 -0
- package/dist/page-cache-middleware.mjs +101 -0
- package/dist/publishing.d.mts +17 -0
- package/dist/publishing.mjs +42 -0
- package/dist/read-Bh4-a3Ap.mjs +18 -0
- package/dist/redirect-middleware.d.mts +6 -0
- package/dist/redirect-middleware.mjs +37 -0
- package/dist/render-DzH7Wil3.mjs +116 -0
- package/dist/routes/admin.d.mts +7 -0
- package/dist/routes/admin.mjs +10 -0
- package/dist/routes/api.d.mts +7 -0
- package/dist/routes/api.mjs +97 -0
- package/dist/routes/icons.d.mts +12 -0
- package/dist/routes/icons.mjs +36 -0
- package/dist/routes/robots.d.mts +7 -0
- package/dist/routes/robots.mjs +15 -0
- package/dist/routes/rss.d.mts +7 -0
- package/dist/routes/rss.mjs +20 -0
- package/dist/routes/sitemap.d.mts +7 -0
- package/dist/routes/sitemap.mjs +23 -0
- package/dist/routes/uploads.d.mts +7 -0
- package/dist/routes/uploads.mjs +11 -0
- package/dist/schemas-dHRJ1HeG.mjs +275 -0
- package/dist/seo.d.mts +125 -0
- package/dist/seo.mjs +130 -0
- package/dist/server-D92ryW2y.mjs +5883 -0
- package/dist/service-CgcvCG7l.mjs +353 -0
- package/dist/site-dF-xVPqe.mjs +148 -0
- package/dist/site.d.mts +28 -0
- package/dist/site.mjs +44 -0
- package/dist/sitemap-Ba0vil6a.mjs +46 -0
- package/dist/theme-pages-CRsBm1EX.mjs +246 -0
- package/dist/theme-seo-middleware.d.mts +7 -0
- package/dist/theme-seo-middleware.mjs +62 -0
- package/dist/visual-middleware.d.mts +7 -0
- package/dist/visual-middleware.mjs +71 -0
- package/package.json +72 -3
- package/README.md +0 -3
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parse, stringify } from "yaml";
|
|
2
|
+
|
|
3
|
+
//#region src/content/parsers/frontmatter.ts
|
|
4
|
+
const FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
5
|
+
function parseDocument(source) {
|
|
6
|
+
const m = FENCE.exec(source);
|
|
7
|
+
if (!m) return {
|
|
8
|
+
data: {},
|
|
9
|
+
body: source
|
|
10
|
+
};
|
|
11
|
+
const data = parse(m[1] ?? "");
|
|
12
|
+
if (data !== null && (typeof data !== "object" || Array.isArray(data))) throw new Error("Frontmatter must be a YAML mapping");
|
|
13
|
+
return {
|
|
14
|
+
data: data ?? {},
|
|
15
|
+
body: source.slice(m[0].length)
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** Unknown keys in `data` are written back untouched. */
|
|
19
|
+
function serializeDocument({ data, body }) {
|
|
20
|
+
return (Object.keys(data).length ? `---\n${stringify(data, { lineWidth: 0 })}---\n` : "") + body;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { serializeDocument as n, parseDocument as t };
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { t as parseDocument } from "./frontmatter-DGaz9--t.mjs";
|
|
2
|
+
import { access, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
|
|
8
|
+
//#region src/cli/init.ts
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* `wuppycms init`: wraps an existing Astro project. Finds its Markdown/MDX content collections, writes wuppy.config.mjs,
|
|
12
|
+
* adds the integration (plus MDX, and a Node adapter when there is none) to astro.config, lets the content globs load
|
|
13
|
+
* .mdx, and adds the dependencies to package.json.
|
|
14
|
+
* Files are only patched when the pattern is recognised; otherwise the manual step is printed instead.
|
|
15
|
+
*/
|
|
16
|
+
const exists = (p) => access(p).then(() => true, () => false);
|
|
17
|
+
const read = (p) => readFile(p, "utf8").catch(() => null);
|
|
18
|
+
const CONFIGS = [
|
|
19
|
+
"astro.config.mjs",
|
|
20
|
+
"astro.config.ts",
|
|
21
|
+
"astro.config.js",
|
|
22
|
+
"astro.config.mts"
|
|
23
|
+
];
|
|
24
|
+
const CONTENT_CONFIGS = [
|
|
25
|
+
"src/content.config.ts",
|
|
26
|
+
"src/content.config.mjs",
|
|
27
|
+
"src/content.config.js",
|
|
28
|
+
"src/content/config.ts"
|
|
29
|
+
];
|
|
30
|
+
async function walk(dir) {
|
|
31
|
+
return (await readdir(dir, { recursive: true }).catch(() => [])).map((n) => join(dir, n));
|
|
32
|
+
}
|
|
33
|
+
const IMAGE = /\.(png|jpe?g|webp|avif|gif|svg)$/i;
|
|
34
|
+
const DATE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
35
|
+
/** Common Astro theme field names for the starter's `date` and `cover`. */
|
|
36
|
+
const ALIASES = {
|
|
37
|
+
date: [
|
|
38
|
+
"pubDate",
|
|
39
|
+
"publishDate",
|
|
40
|
+
"pubDatetime",
|
|
41
|
+
"publishedAt",
|
|
42
|
+
"published",
|
|
43
|
+
"publishedDate"
|
|
44
|
+
],
|
|
45
|
+
cover: [
|
|
46
|
+
"heroImage",
|
|
47
|
+
"coverImage",
|
|
48
|
+
"image",
|
|
49
|
+
"featuredImage",
|
|
50
|
+
"thumbnail"
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
/** Guesses one field's type from the values it has across the existing files. */
|
|
54
|
+
function inferField(key, values) {
|
|
55
|
+
const present = values.filter((v) => v !== void 0 && v !== null);
|
|
56
|
+
const all = (test) => present.length > 0 && present.every(test);
|
|
57
|
+
let type;
|
|
58
|
+
if (all((v) => typeof v === "boolean")) type = "boolean";
|
|
59
|
+
else if (all((v) => typeof v === "number")) type = "number";
|
|
60
|
+
else if (all((v) => v instanceof Date || typeof v === "string" && DATE.test(v))) type = "date";
|
|
61
|
+
else if (all((v) => Array.isArray(v) && v.every((i) => typeof i === "string" && i.length <= 100))) type = "list";
|
|
62
|
+
else if (all((v) => typeof v === "string")) {
|
|
63
|
+
const strings = present;
|
|
64
|
+
if (/image|cover|thumbnail|photo|avatar|logo|banner/i.test(key) || strings.every((v) => IMAGE.test(v))) type = "image";
|
|
65
|
+
else if (strings.some((v) => v.includes("\n") || v.length > 160)) type = "text";
|
|
66
|
+
else type = "string";
|
|
67
|
+
} else type = "json";
|
|
68
|
+
return present.length === values.length && values.length > 0 ? {
|
|
69
|
+
type,
|
|
70
|
+
multiple: false,
|
|
71
|
+
required: true
|
|
72
|
+
} : type;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* `const posts = defineCollection({ loader: glob({ pattern, base }) ... })` -> name, base, pattern. The name is the key
|
|
76
|
+
* in `export const collections = { post: postCollection }` (what `getCollection("post")` uses), else the variable's.
|
|
77
|
+
*/
|
|
78
|
+
function collectionsFromConfig(source) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const exported = /export\s+const\s+collections\s*=\s*\{([^}]*)\}/.exec(source)?.[1] ?? "";
|
|
81
|
+
const keys = /* @__PURE__ */ new Map();
|
|
82
|
+
for (const entry of exported.split(",")) {
|
|
83
|
+
const m = /^\s*(?:["'`]?([\w-]+)["'`]?\s*:\s*)?(\w+)\s*$/.exec(entry);
|
|
84
|
+
if (m) keys.set(m[2], m[1] ?? m[2]);
|
|
85
|
+
}
|
|
86
|
+
for (const m of source.matchAll(/(?:const|let)\s+(\w+)\s*=\s*defineCollection\(\s*\{([\s\S]*?)\n\}\s*\)/g)) {
|
|
87
|
+
const body = m[2] ?? "";
|
|
88
|
+
const glob = /loader:\s*glob\(\s*\{([\s\S]*?)\}\s*\)/.exec(body)?.[1];
|
|
89
|
+
const str = (key) => glob ? new RegExp(`${key}:\\s*["'\`]([^"'\`]+)["'\`]`).exec(glob)?.[1] : void 0;
|
|
90
|
+
out.push({
|
|
91
|
+
name: keys.get(m[1]) ?? m[1],
|
|
92
|
+
base: str("base"),
|
|
93
|
+
pattern: str("pattern"),
|
|
94
|
+
loader: glob ? "glob" : /loader:\s*(\w+)/.exec(body)?.[1] ?? "legacy"
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** The page that renders `getCollection("name")` with a [slug] param gives the public route. */
|
|
100
|
+
async function findRoute(root, name) {
|
|
101
|
+
const pages = join(root, "src/pages");
|
|
102
|
+
for (const file of await walk(pages)) {
|
|
103
|
+
if (!/\[(\.\.\.)?slug\]\.(astro|mdx?|ts|js)$/.test(file)) continue;
|
|
104
|
+
const src = await read(file);
|
|
105
|
+
if (!src || !new RegExp(`get(Collection|Entry)\\(\\s*["'\`]${name}["'\`]`).test(src)) continue;
|
|
106
|
+
const rel = relative(pages, file).split(/[\\/]/);
|
|
107
|
+
rel[rel.length - 1] = "{slug}";
|
|
108
|
+
return `/${rel.join("/")}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function planInit(root) {
|
|
112
|
+
const plan = {
|
|
113
|
+
collections: [],
|
|
114
|
+
skipped: [],
|
|
115
|
+
integration: {},
|
|
116
|
+
components: [],
|
|
117
|
+
warnings: []
|
|
118
|
+
};
|
|
119
|
+
let declared = [];
|
|
120
|
+
for (const f of CONTENT_CONFIGS) {
|
|
121
|
+
const src = await read(join(root, f));
|
|
122
|
+
if (src) {
|
|
123
|
+
declared = collectionsFromConfig(src);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (declared.length === 0) declared = (await readdir(join(root, "src/content"), { withFileTypes: true }).catch(() => [])).filter((d) => d.isDirectory()).map((d) => ({
|
|
128
|
+
name: d.name,
|
|
129
|
+
base: `src/content/${d.name}`,
|
|
130
|
+
loader: "legacy"
|
|
131
|
+
}));
|
|
132
|
+
const components = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const c of declared) {
|
|
134
|
+
if (!/^[a-z][a-z0-9-]*$/.test(c.name)) {
|
|
135
|
+
plan.skipped.push(`${c.name}: Wuppy collection names are lowercase letters, digits and dashes`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c.loader !== "glob" && c.loader !== "legacy") {
|
|
139
|
+
plan.skipped.push(`${c.name}: uses the "${c.loader}" loader, only file collections can be edited`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const dir = (c.base ?? `src/content/${c.name}`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
143
|
+
const files = (await walk(join(root, dir))).filter((f) => /\.(md|mdx)$/.test(f));
|
|
144
|
+
if (files.length === 0 && !/mdx?/.test(c.pattern ?? "")) {
|
|
145
|
+
plan.skipped.push(`${c.name}: no Markdown or MDX files (data collections stay as they are)`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const format = !c.pattern || /mdx|\.md$/.test(c.pattern) ? "mdx" : "md";
|
|
149
|
+
if (format === "md") plan.warnings.push(`${c.name}: its glob does not load .mdx, so new entries stay Markdown`);
|
|
150
|
+
const values = /* @__PURE__ */ new Map();
|
|
151
|
+
const editable = files;
|
|
152
|
+
for (const [i, file] of editable.entries()) {
|
|
153
|
+
const src = await read(file) ?? "";
|
|
154
|
+
let data = {};
|
|
155
|
+
try {
|
|
156
|
+
data = parseDocument(src).data;
|
|
157
|
+
} catch {
|
|
158
|
+
plan.warnings.push(`${relative(root, file)}: frontmatter is not valid YAML`);
|
|
159
|
+
}
|
|
160
|
+
for (const key of Object.keys(data)) if (!values.has(key)) values.set(key, Array(i).fill(void 0));
|
|
161
|
+
for (const [key, list] of values) list.push(data[key]);
|
|
162
|
+
if (file.endsWith(".mdx")) for (const m of src.matchAll(/<([A-Z][A-Za-z0-9]*)[\s/>]/g)) components.add(m[1]);
|
|
163
|
+
}
|
|
164
|
+
const fields = Object.fromEntries([...values].map(([key, list]) => [key, inferField(key, list)]));
|
|
165
|
+
const route = await findRoute(root, c.name);
|
|
166
|
+
if (!route) plan.warnings.push(`${c.name}: no page renders it with a [slug] param; set "route" in wuppy.config.mjs`);
|
|
167
|
+
plan.collections.push({
|
|
168
|
+
name: c.name,
|
|
169
|
+
dir,
|
|
170
|
+
format,
|
|
171
|
+
route,
|
|
172
|
+
fields,
|
|
173
|
+
files: editable.length
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const kind of ["posts", "pages"]) {
|
|
177
|
+
if (plan.collections.some((c) => c.name === kind || c.kind === kind)) continue;
|
|
178
|
+
const dated = (c) => Object.values(c.fields).some((f) => (typeof f === "string" ? f : f.type) === "date");
|
|
179
|
+
const pick = plan.collections.find((c) => !c.kind && c.name !== "posts" && c.name !== "pages" && dated(c) === (kind === "posts"));
|
|
180
|
+
if (pick) pick.kind = kind;
|
|
181
|
+
}
|
|
182
|
+
for (const c of plan.collections) {
|
|
183
|
+
const map = {};
|
|
184
|
+
for (const [to, names] of Object.entries(ALIASES)) {
|
|
185
|
+
const from = names.find((n) => n in c.fields);
|
|
186
|
+
if (from && !(to in c.fields)) map[from] = to;
|
|
187
|
+
}
|
|
188
|
+
if (Object.keys(map).length) c.map = map;
|
|
189
|
+
}
|
|
190
|
+
plan.components = [...components].toSorted();
|
|
191
|
+
const pages = (await walk(join(root, "src/pages"))).map((f) => relative(join(root, "src/pages"), f));
|
|
192
|
+
if (pages.some((p) => p.startsWith("sitemap.xml")) || await exists(join(root, "public/sitemap.xml"))) plan.integration.sitemap = false;
|
|
193
|
+
if (await exists(join(root, "public/robots.txt")) || pages.some((p) => p.startsWith("robots.txt"))) plan.integration.robots = false;
|
|
194
|
+
if (pages.some((p) => p.startsWith("rss.xml"))) plan.integration.rss = false;
|
|
195
|
+
for (const taken of [
|
|
196
|
+
"admin",
|
|
197
|
+
"api",
|
|
198
|
+
"uploads"
|
|
199
|
+
]) if (pages.some((p) => p === taken || p.startsWith(`${taken}/`) || p.startsWith(`${taken}.`))) plan.warnings.push(`src/pages/${taken} exists; Wuppy serves /${taken}, rename yours to avoid a clash`);
|
|
200
|
+
return plan;
|
|
201
|
+
}
|
|
202
|
+
async function findAstroConfig(root) {
|
|
203
|
+
for (const f of CONFIGS) {
|
|
204
|
+
const source = await read(join(root, f));
|
|
205
|
+
if (source !== null) return {
|
|
206
|
+
file: f,
|
|
207
|
+
source
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
/** The Node adapter and MDX releases that match the Astro version Wuppy supports. */
|
|
213
|
+
const NODE_ADAPTER = "^11.1.5";
|
|
214
|
+
const MDX_INTEGRATION = "^8.1.0";
|
|
215
|
+
/** Lets glob loaders that read only `.md` read `.mdx` too: `pattern: "**\/*.md"` -> `"**\/*.{md,mdx}"`. */
|
|
216
|
+
function patchContentConfig(source) {
|
|
217
|
+
return source.replace(/(pattern:\s*["'`][^"'`]*?)\.md(["'`])/g, "$1.{md,mdx}$2");
|
|
218
|
+
}
|
|
219
|
+
/** Adds `wuppycms(...)`, `mdx()` and a Node adapter when missing. Returns null when the config shape is not recognised. */
|
|
220
|
+
function patchAstroConfig(source, integration) {
|
|
221
|
+
if (/wuppycms(cms)?\/astro/.test(source)) return source;
|
|
222
|
+
const off = Object.keys(integration);
|
|
223
|
+
const options = off.length ? `{ ${off.map((k) => `${k}: false`).join(", ")} }` : "";
|
|
224
|
+
const mdx = !/@astrojs\/mdx/.test(source);
|
|
225
|
+
const call = `wuppycms(${options})${mdx ? ", mdx()" : ""}`;
|
|
226
|
+
const tab = /^([ \t]+)\S/m.exec(source)?.[1] ?? " ";
|
|
227
|
+
let out = source;
|
|
228
|
+
if (/integrations:\s*\[/.test(out)) out = out.replace(/integrations:\s*\[/, `integrations: [${call}, `);
|
|
229
|
+
else if (/defineConfig\(\s*\{/.test(out)) out = out.replace(/defineConfig\(\s*\{/, `defineConfig({\n${tab}integrations: [${call}],`);
|
|
230
|
+
else return null;
|
|
231
|
+
const imports = ["import wuppycms from \"wuppycms/astro\";"];
|
|
232
|
+
if (mdx) imports.push("import mdx from \"@astrojs/mdx\";");
|
|
233
|
+
if (!/\badapter\s*:/.test(out)) {
|
|
234
|
+
out = out.replace(/defineConfig\(\s*\{/, `defineConfig({\n${tab}adapter: node({ mode: "standalone" }),`);
|
|
235
|
+
imports.push("import node from \"@astrojs/node\";");
|
|
236
|
+
}
|
|
237
|
+
const lastImport = [...out.matchAll(/^import .*$/gm)].at(-1);
|
|
238
|
+
const at = lastImport ? lastImport.index + lastImport[0].length + 1 : 0;
|
|
239
|
+
return `${out.slice(0, at)}${imports.join("\n")}\n${out.slice(at)}`;
|
|
240
|
+
}
|
|
241
|
+
/** Renders wuppy.config.mjs; every value stays editable by hand afterwards. */
|
|
242
|
+
function renderConfig(plan, site) {
|
|
243
|
+
const config = {
|
|
244
|
+
...site.url ? { site: {
|
|
245
|
+
url: site.url,
|
|
246
|
+
name: site.name
|
|
247
|
+
} } : {},
|
|
248
|
+
...plan.components.length ? { components: plan.components } : {},
|
|
249
|
+
collections: plan.collections.map(({ name, dir, format, kind, map, route, fields }) => ({
|
|
250
|
+
name,
|
|
251
|
+
dir,
|
|
252
|
+
format,
|
|
253
|
+
...kind ? { kind } : {},
|
|
254
|
+
...map ? { map } : {},
|
|
255
|
+
...route ? { route } : {},
|
|
256
|
+
fields
|
|
257
|
+
}))
|
|
258
|
+
};
|
|
259
|
+
return `// Made by \`wuppycms init\` from the files already in this project. Fields are guessed from the frontmatter; adjust freely.\nexport default ${JSON.stringify(config, null, " ")};\n`;
|
|
260
|
+
}
|
|
261
|
+
const IGNORE = [
|
|
262
|
+
"# Wuppy: runtime state stays local; .wuppy/data (menus, settings) is content and is committed",
|
|
263
|
+
".wuppy/*",
|
|
264
|
+
"!.wuppy/data/",
|
|
265
|
+
".env"
|
|
266
|
+
];
|
|
267
|
+
async function init(root, { force = false, version = "latest" } = {}) {
|
|
268
|
+
const astro = await findAstroConfig(root);
|
|
269
|
+
if (!astro) return {
|
|
270
|
+
code: 1,
|
|
271
|
+
stdout: "",
|
|
272
|
+
stderr: "No astro.config found here. Run `wuppycms init` in the root of an Astro project."
|
|
273
|
+
};
|
|
274
|
+
if (!force && await exists(join(root, "wuppy.config.mjs"))) return {
|
|
275
|
+
code: 1,
|
|
276
|
+
stdout: "",
|
|
277
|
+
stderr: "wuppy.config.mjs already exists (use --force to replace it)."
|
|
278
|
+
};
|
|
279
|
+
const plan = await planInit(root);
|
|
280
|
+
if (plan.collections.length === 0) return {
|
|
281
|
+
code: 1,
|
|
282
|
+
stdout: "",
|
|
283
|
+
stderr: `No Markdown/MDX content collections found.\n${plan.skipped.join("\n")}`
|
|
284
|
+
};
|
|
285
|
+
const pkgPath = join(root, "package.json");
|
|
286
|
+
const pkg = JSON.parse(await read(pkgPath) ?? "{}");
|
|
287
|
+
const siteUrl = /\bsite:\s*["'`]([^"'`]+)["'`]/.exec(astro.source)?.[1];
|
|
288
|
+
await writeFile(join(root, "wuppy.config.mjs"), renderConfig(plan, {
|
|
289
|
+
url: siteUrl,
|
|
290
|
+
name: pkg.name ?? "Site"
|
|
291
|
+
}));
|
|
292
|
+
const lines = [`Wrote wuppy.config.mjs with ${plan.collections.length} collection(s):`];
|
|
293
|
+
for (const c of plan.collections) lines.push(` ${c.name.padEnd(16)} ${c.dir} (${c.files} .${c.format}, ${Object.keys(c.fields).length} fields) -> ${c.route ?? "route not found"}`);
|
|
294
|
+
for (const s of plan.skipped) lines.push(` skipped ${s}`);
|
|
295
|
+
const patched = patchAstroConfig(astro.source, plan.integration);
|
|
296
|
+
const adds = (pkgName) => patched !== null && patched.includes(`from "${pkgName}"`) && !astro.source.includes(pkgName);
|
|
297
|
+
const addsAdapter = adds("@astrojs/node");
|
|
298
|
+
const addsMdx = adds("@astrojs/mdx");
|
|
299
|
+
if (patched === null) plan.warnings.push(`${astro.file}: add \`import wuppycms from "wuppycms/astro"\` and \`wuppycms()\` to integrations by hand (plus a server adapter)`);
|
|
300
|
+
else if (patched !== astro.source) {
|
|
301
|
+
await writeFile(join(root, astro.file), patched);
|
|
302
|
+
lines.push(`Added wuppycms()${addsMdx ? ", mdx()" : ""}${addsAdapter ? " and the Node adapter" : ""} to ${astro.file}`);
|
|
303
|
+
}
|
|
304
|
+
for (const f of CONTENT_CONFIGS) {
|
|
305
|
+
const source = await read(join(root, f));
|
|
306
|
+
if (source === null) continue;
|
|
307
|
+
const widened = patchContentConfig(source);
|
|
308
|
+
if (widened !== source) {
|
|
309
|
+
await writeFile(join(root, f), widened);
|
|
310
|
+
lines.push(`Content globs in ${f} now load .mdx too`);
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
pkg.dependencies = {
|
|
315
|
+
...pkg.dependencies,
|
|
316
|
+
wuppycms: pkg.dependencies?.wuppycms ?? version
|
|
317
|
+
};
|
|
318
|
+
if (addsAdapter) pkg.dependencies["@astrojs/node"] ??= NODE_ADAPTER;
|
|
319
|
+
if (addsMdx) pkg.dependencies["@astrojs/mdx"] ??= MDX_INTEGRATION;
|
|
320
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
321
|
+
const gitignore = await read(join(root, ".gitignore")) ?? "";
|
|
322
|
+
if (!gitignore.includes(".wuppy/*")) await writeFile(join(root, ".gitignore"), `${gitignore.replace(/\n?$/, "\n")}${IGNORE.join("\n")}\n`);
|
|
323
|
+
const env = await read(join(root, ".env")) ?? "";
|
|
324
|
+
if (!/^WUPPY_SECRET=/m.test(env)) await writeFile(join(root, ".env"), `${env.replace(/\n?$/, env ? "\n" : "")}WUPPY_SECRET=${randomBytes(32).toString("base64url")}\nWUPPY_URL=http://localhost:4321\n`, { mode: 384 });
|
|
325
|
+
for (const w of plan.warnings) lines.push(`warning: ${w}`);
|
|
326
|
+
lines.push("", "Next: install dependencies (npm install / pnpm install), then run the dev server and open /admin.");
|
|
327
|
+
return {
|
|
328
|
+
code: 0,
|
|
329
|
+
stdout: lines.join("\n") + "\n",
|
|
330
|
+
stderr: ""
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const run = (cmd, args) => exec(cmd, args, { maxBuffer: 64 * 1024 * 1024 });
|
|
334
|
+
const REPOS_QUERY = `query($endCursor: String) { viewer { repositories(first: 100, after: $endCursor,
|
|
335
|
+
ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], orderBy: {field: PUSHED_AT, direction: DESC}) {
|
|
336
|
+
pageInfo { hasNextPage endCursor }
|
|
337
|
+
nodes { nameWithOwner isPrivate description object(expression: "HEAD:package.json") { ... on Blob { text } } } } } }`;
|
|
338
|
+
/** The signed-in GitHub user's repositories that depend on Astro, newest push first. Needs the GitHub CLI (`gh`). */
|
|
339
|
+
async function astroRepos(sh = run) {
|
|
340
|
+
const { stdout } = await sh("gh", [
|
|
341
|
+
"api",
|
|
342
|
+
"graphql",
|
|
343
|
+
"--paginate",
|
|
344
|
+
"-f",
|
|
345
|
+
`query=${REPOS_QUERY}`,
|
|
346
|
+
"--jq",
|
|
347
|
+
".data.viewer.repositories.nodes[] | {repo: .nameWithOwner, private: .isPrivate, description: (.description // \"\"), pkg: (.object.text // \"\")}"
|
|
348
|
+
]);
|
|
349
|
+
return astroOnly(stdout.split("\n").filter(Boolean).map((line) => JSON.parse(line)));
|
|
350
|
+
}
|
|
351
|
+
/** Keeps the repos whose root package.json depends on Astro. */
|
|
352
|
+
const astroOnly = (repos) => repos.filter((r) => /"astro"\s*:/.test(r.pkg)).map((r) => ({
|
|
353
|
+
repo: r.repo,
|
|
354
|
+
private: r.private,
|
|
355
|
+
description: r.description
|
|
356
|
+
}));
|
|
357
|
+
async function askRepo(repos) {
|
|
358
|
+
const { createInterface } = await import("node:readline/promises");
|
|
359
|
+
const rl = createInterface({
|
|
360
|
+
input: process.stdin,
|
|
361
|
+
output: process.stderr
|
|
362
|
+
});
|
|
363
|
+
repos.forEach((r, i) => process.stderr.write(`${String(i + 1).padStart(3)}. ${r.repo}${r.private ? " (private)" : ""}${r.description ? ` ${r.description}` : ""}\n`));
|
|
364
|
+
const answer = await rl.question("Repository to wrap (number): ").finally(() => rl.close());
|
|
365
|
+
return repos[Number(answer) - 1];
|
|
366
|
+
}
|
|
367
|
+
const failed = (what, e) => ({
|
|
368
|
+
code: 1,
|
|
369
|
+
stdout: "",
|
|
370
|
+
stderr: `${what} failed: ${e.stderr?.trim() || e.message}`
|
|
371
|
+
});
|
|
372
|
+
/**
|
|
373
|
+
* `wuppycms import <git-url> <dir>`: clone a repository, then wrap it.
|
|
374
|
+
* `wuppycms import` with no URL lists the user's Astro repositories on GitHub to pick from (via `gh`, so private repos work).
|
|
375
|
+
*/
|
|
376
|
+
async function importRepo(url, dir, cwd, deps = {}) {
|
|
377
|
+
const sh = deps.exec ?? run;
|
|
378
|
+
if (!url) {
|
|
379
|
+
let repos;
|
|
380
|
+
try {
|
|
381
|
+
repos = await astroRepos(sh);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
return failed("Listing GitHub repositories (needs the GitHub CLI: install gh, run `gh auth login`)", e);
|
|
384
|
+
}
|
|
385
|
+
if (repos.length === 0) return {
|
|
386
|
+
code: 1,
|
|
387
|
+
stdout: "",
|
|
388
|
+
stderr: "No Astro repositories found on your GitHub account."
|
|
389
|
+
};
|
|
390
|
+
const picked = await (deps.choose ?? askRepo)(repos);
|
|
391
|
+
if (!picked) return {
|
|
392
|
+
code: 1,
|
|
393
|
+
stdout: "",
|
|
394
|
+
stderr: "No repository picked."
|
|
395
|
+
};
|
|
396
|
+
const target = resolve(cwd, dir || picked.repo.split("/")[1]);
|
|
397
|
+
try {
|
|
398
|
+
await sh("gh", [
|
|
399
|
+
"repo",
|
|
400
|
+
"clone",
|
|
401
|
+
picked.repo,
|
|
402
|
+
target
|
|
403
|
+
]);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
return failed("gh repo clone", e);
|
|
406
|
+
}
|
|
407
|
+
return init(target);
|
|
408
|
+
}
|
|
409
|
+
if (url.startsWith("-") || !dir || dir.startsWith("-")) return {
|
|
410
|
+
code: 1,
|
|
411
|
+
stdout: "",
|
|
412
|
+
stderr: "Usage: wuppycms import [<git-url> <dir>]"
|
|
413
|
+
};
|
|
414
|
+
const target = resolve(cwd, dir);
|
|
415
|
+
try {
|
|
416
|
+
await sh("git", [
|
|
417
|
+
"clone",
|
|
418
|
+
"--",
|
|
419
|
+
url,
|
|
420
|
+
target
|
|
421
|
+
]);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return failed("git clone", e);
|
|
424
|
+
}
|
|
425
|
+
return init(target);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/content-types/generate.ts
|
|
430
|
+
const pascal = (s) => s.replace(/(^|-)([a-z0-9])/g, (_, __, c) => c.toUpperCase());
|
|
431
|
+
function tsType(field) {
|
|
432
|
+
const f = typeof field === "string" ? {
|
|
433
|
+
type: field,
|
|
434
|
+
multiple: false
|
|
435
|
+
} : field;
|
|
436
|
+
const many = (t) => "multiple" in f && f.multiple ? `${t}[]` : t;
|
|
437
|
+
switch (f.type) {
|
|
438
|
+
case "boolean": return "boolean";
|
|
439
|
+
case "number": return "number";
|
|
440
|
+
case "list": return "string[]";
|
|
441
|
+
case "json": return "unknown";
|
|
442
|
+
case "byline": return "string | Array<string | { byline: string; role?: string }>";
|
|
443
|
+
case "section": return "string[]";
|
|
444
|
+
case "taxonomy":
|
|
445
|
+
case "reference": return "multiple" in f && f.multiple ? "string[]" : "string | string[]";
|
|
446
|
+
case "select": return many("options" in f && f.options?.length ? f.options.map((o) => JSON.stringify(o)).join(" | ") : "string");
|
|
447
|
+
case "repeater": return `Array<${obj("fields" in f && f.fields ? f.fields : {})}>`;
|
|
448
|
+
case "blocks": {
|
|
449
|
+
const blocks = "blocks" in f && f.blocks ? Object.entries(f.blocks) : [];
|
|
450
|
+
return blocks.length ? `Array<${blocks.map(([name, fields]) => `{ type: ${JSON.stringify(name)} } & ${obj(fields)}`).join(" | ")}>` : "unknown[]";
|
|
451
|
+
}
|
|
452
|
+
default: return "string";
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function obj(fields) {
|
|
456
|
+
const rows = Object.entries(fields).map(([name, f]) => {
|
|
457
|
+
const required = typeof f !== "string" && f.required && !f.showIf;
|
|
458
|
+
return `${JSON.stringify(name)}${required ? "" : "?"}: ${tsType(f)}`;
|
|
459
|
+
});
|
|
460
|
+
return `{ ${rows.join("; ")}${rows.length ? "; " : ""}[key: string]: unknown }`;
|
|
461
|
+
}
|
|
462
|
+
/** A `.d.ts` with one frontmatter interface per collection, regenerated whenever the schema changes. */
|
|
463
|
+
function generateTypes(collections) {
|
|
464
|
+
const out = ["// Generated by `wuppycms types`. Do not edit.", ""];
|
|
465
|
+
for (const c of collections) out.push(`export type ${pascal(c.name)}Data = ${obj(c.fields)};`);
|
|
466
|
+
out.push("", `export interface WuppyCollections {`);
|
|
467
|
+
for (const c of collections) out.push(`\t${JSON.stringify(c.name)}: ${pascal(c.name)}Data;`);
|
|
468
|
+
out.push("}", "");
|
|
469
|
+
return out.join("\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
//#endregion
|
|
473
|
+
export { astroOnly as a, REPOS_QUERY as i, CONFIGS as n, importRepo as o, NODE_ADAPTER as r, init as s, generateTypes as t };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/i18n/index.ts
|
|
2
|
+
/** Convention: the first slug segment is the locale (`en/hello`, `tr/hello`); the rest is the shared key. */
|
|
3
|
+
function translationStatus(slugs, locales, modified = {}) {
|
|
4
|
+
const groups = /* @__PURE__ */ new Map();
|
|
5
|
+
for (const slug of slugs) {
|
|
6
|
+
const i = slug.indexOf("/");
|
|
7
|
+
const locale = slug.slice(0, i);
|
|
8
|
+
if (i < 1 || !locales.includes(locale)) continue;
|
|
9
|
+
const key = slug.slice(i + 1);
|
|
10
|
+
groups.set(key, {
|
|
11
|
+
...groups.get(key),
|
|
12
|
+
[locale]: slug
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return [...groups].map(([key, translations]) => {
|
|
16
|
+
const source = locales[0] && translations[locales[0]] ? modified[translations[locales[0]]] : void 0;
|
|
17
|
+
return {
|
|
18
|
+
key,
|
|
19
|
+
translations,
|
|
20
|
+
missing: locales.filter((l) => !translations[l]),
|
|
21
|
+
stale: source ? locales.slice(1).filter((l) => {
|
|
22
|
+
const at = translations[l] ? modified[translations[l]] : void 0;
|
|
23
|
+
return at !== void 0 && at < source;
|
|
24
|
+
}) : []
|
|
25
|
+
};
|
|
26
|
+
}).toSorted((a, b) => a.key.localeCompare(b.key));
|
|
27
|
+
}
|
|
28
|
+
/** `en/hello` -> `{ locale: "en", key: "hello" }`; null when the first segment is not a configured locale. */
|
|
29
|
+
function splitLocale(slug, locales) {
|
|
30
|
+
const i = slug.indexOf("/");
|
|
31
|
+
const locale = slug.slice(0, i);
|
|
32
|
+
return i > 0 && locales.includes(locale) ? {
|
|
33
|
+
locale,
|
|
34
|
+
key: slug.slice(i + 1)
|
|
35
|
+
} : null;
|
|
36
|
+
}
|
|
37
|
+
/** The entry to show for `key` in `wanted`; falls back to the default (first) locale, flagged. Null when neither exists. */
|
|
38
|
+
function resolveLocale(key, wanted, existing, locales) {
|
|
39
|
+
for (const [locale, fallback] of [[wanted, false], [locales[0], true]]) {
|
|
40
|
+
const slug = locale && `${locale}/${key}`;
|
|
41
|
+
if (slug && existing.includes(slug)) return {
|
|
42
|
+
slug,
|
|
43
|
+
locale,
|
|
44
|
+
fallback: fallback && locale !== wanted
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** Language switcher data for the entry `slug`: one link per locale, falling back to the default language. */
|
|
50
|
+
function localeSwitcher(slug, existing, locales) {
|
|
51
|
+
const parts = splitLocale(slug, locales);
|
|
52
|
+
if (!parts) return [];
|
|
53
|
+
return locales.map((locale) => {
|
|
54
|
+
const hit = resolveLocale(parts.key, locale, existing, locales);
|
|
55
|
+
return {
|
|
56
|
+
locale,
|
|
57
|
+
slug: hit?.slug ?? null,
|
|
58
|
+
fallback: hit?.fallback ?? false
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** `hreflang` map for `buildSeo`: real translations only, plus `x-default` for the default language. */
|
|
63
|
+
function hreflangAlternates(slug, existing, locales, pathOf) {
|
|
64
|
+
const parts = splitLocale(slug, locales);
|
|
65
|
+
if (!parts) return {};
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const locale of locales) {
|
|
68
|
+
const translated = `${locale}/${parts.key}`;
|
|
69
|
+
if (existing.includes(translated)) out[locale] = pathOf(translated);
|
|
70
|
+
}
|
|
71
|
+
const base = locales[0] && out[locales[0]];
|
|
72
|
+
return base ? {
|
|
73
|
+
...out,
|
|
74
|
+
"x-default": base
|
|
75
|
+
} : out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
export { translationStatus as a, splitLocale as i, localeSwitcher as n, resolveLocale as r, hreflangAlternates as t };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/i18n/index.d.ts
|
|
2
|
+
interface TranslationRow {
|
|
3
|
+
key: string;
|
|
4
|
+
translations: Record<string, string>;
|
|
5
|
+
missing: string[];
|
|
6
|
+
/** Locales whose translation was last edited before the source (first locale) changed. */
|
|
7
|
+
stale: string[];
|
|
8
|
+
}
|
|
9
|
+
/** Convention: the first slug segment is the locale (`en/hello`, `tr/hello`); the rest is the shared key. */
|
|
10
|
+
declare function translationStatus(slugs: string[], locales: string[], modified?: Record<string, string>): TranslationRow[];
|
|
11
|
+
/** `en/hello` -> `{ locale: "en", key: "hello" }`; null when the first segment is not a configured locale. */
|
|
12
|
+
declare function splitLocale(slug: string, locales: readonly string[]): {
|
|
13
|
+
locale: string;
|
|
14
|
+
key: string;
|
|
15
|
+
} | null;
|
|
16
|
+
/** The entry to show for `key` in `wanted`; falls back to the default (first) locale, flagged. Null when neither exists. */
|
|
17
|
+
declare function resolveLocale(key: string, wanted: string, existing: readonly string[], locales: readonly string[]): {
|
|
18
|
+
slug: string;
|
|
19
|
+
locale: string;
|
|
20
|
+
fallback: boolean;
|
|
21
|
+
} | null;
|
|
22
|
+
interface LocaleLink {
|
|
23
|
+
locale: string;
|
|
24
|
+
/** Entry slug to link to, or null when the key has no entry in any language that applies. */
|
|
25
|
+
slug: string | null;
|
|
26
|
+
/** True when this locale has no translation and the link points at the default language. */
|
|
27
|
+
fallback: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** Language switcher data for the entry `slug`: one link per locale, falling back to the default language. */
|
|
30
|
+
declare function localeSwitcher(slug: string, existing: readonly string[], locales: readonly string[]): LocaleLink[];
|
|
31
|
+
/** `hreflang` map for `buildSeo`: real translations only, plus `x-default` for the default language. */
|
|
32
|
+
declare function hreflangAlternates(slug: string, existing: readonly string[], locales: readonly string[], pathOf: (slug: string) => string): Record<string, string>;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { resolveLocale as a, localeSwitcher as i, TranslationRow as n, splitLocale as o, hreflangAlternates as r, translationStatus as s, LocaleLink as t };
|