hollware 0.9.0 → 0.10.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/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  The command line of [Hollware](https://github.com/hollware/hollware), an open-source headless
4
4
  CMS for the agent era, native to Cloudflare Workers.
5
5
 
6
- > **Status: pre-alpha.** `init`, `migrate`, `deploy`, `doctor`, `token`, `invite` and `import`
7
- > work, against a Hollware that is still being built. POSIX shells only: it has not been tried on
6
+ > **Status: pre-alpha.** `init`, `migrate`, `deploy`, `doctor`, `token`, `invite`, `import` and
7
+ > `export` work, against a Hollware that is still being built. POSIX shells only: it has not been tried on
8
8
  > Windows.
9
9
 
10
10
  ## Commands
@@ -17,12 +17,25 @@ CMS for the agent era, native to Cloudflare Workers.
17
17
  | `doctor` | Check the project, its bindings, its database and its owner, with what to fix |
18
18
  | `token` | Create, list and revoke personal tokens, and read tokens with `--read` |
19
19
  | `invite` | Invite a person to the admin, as an editor or an author |
20
- | `import` | Import Markdown files over MCP, as drafts, and publish them with `--publish` |
21
- | `export` | Export entries as Markdown files (v0.2) |
20
+ | `import` | Import Markdown over MCP, as drafts; from a Nuxt Content site with `--from` |
21
+ | `export` | Export entries over MCP as Markdown files, the layout `import` reads |
22
22
 
23
23
  Every command answers `--help`. A command that fails prints one message on stderr, with what to
24
24
  do, and exits with code 1.
25
25
 
26
+ ### Moving a site to Hollware
27
+
28
+ `import --from nuxt-content` reads the content directory of a Nuxt Content site as the files Hollware
29
+ imports, and validates them all before it writes anything:
30
+
31
+ ```sh
32
+ # With --locale-dirs, content/<locale>/… and the translations of each page
33
+ npx hollware import ./content --from nuxt-content --collection docs --locale-dirs --url https://cms.example.com --token-file ./token.txt
34
+ ```
35
+
36
+ A page keeps its Markdown byte for byte (Comark reads MDC), with its slug as Nuxt Content routes
37
+ it and a `title` from its first heading when it has none.
38
+
26
39
  ## A first run
27
40
 
28
41
  In a Hollware project (the starter has the D1 binding `DB` in its `wrangler.jsonc`):
@@ -59,11 +72,25 @@ again changes nothing: every entry answers `unchanged`. With `--publish` it publ
59
72
  entries that have unpublished changes, and it acknowledges no warning: an entry with warnings is
60
73
  reported, and the command exits with code 1. The token never appears in its output.
61
74
 
75
+ `export` is its mirror: it writes the entries of the site of the token, as
76
+ `<collection>/<locale>/<slug>.md`, into a directory that must be empty or not exist:
77
+
78
+ ```sh
79
+ hollware export ./backup --url https://cms.example.com --token-file ./token.txt
80
+ hollware export ./live --url https://cms.example.com --token-file ./token.txt --published
81
+ ```
82
+
83
+ Each file is the revision as Hollware keeps it, byte for byte, so importing an export into the
84
+ same site changes nothing. By default it writes the latest revision of every entry, drafts
85
+ included; `--published` writes only the published entries, as they are published. Entries in
86
+ the trash are left out. `--collection` and `--locale` narrow it (repeat them for several),
87
+ `--dry-run` lists the files and writes nothing, and a token with `content:read` is enough.
88
+
62
89
  ## How it works
63
90
 
64
91
  `init`, `migrate`, `deploy`, `doctor` and `token` run the project's own `wrangler` (the nearest
65
92
  `node_modules/.bin/wrangler`) against its D1 database, and read the binding from `wrangler.jsonc`.
66
- `import` speaks MCP to `<url>/mcp` with the token as a bearer token. The structure of the sites
93
+ `import` and `export` speak MCP to `<url>/mcp` with the token as a bearer token. The structure of the sites
67
94
  lives in the database (ADR-033): `init` gives a new site its first definition from a preset, and
68
95
  its owner shapes it in the admin, or an agent over MCP.
69
96
 
@@ -0,0 +1,234 @@
1
+ import { i as UsageError, r as CliError, t as printable } from "./format-BpRYEsGB.mjs";
2
+ import { t as readVersion } from "./version-BMhdZaNo.mjs";
3
+ import { t as parseFlags } from "./args-CljWh-kZ.mjs";
4
+ import { a as redact, c as openSession, i as readToken, o as withoutToken, r as mcpEndpoint, s as ToolFailure } from "./import-D9LRrYtb.mjs";
5
+ import { mkdirSync, readdirSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { describeSite, getEntry, listEntries, schemas } from "@hollware/core";
8
+ //#region src/commands/export.ts
9
+ const EXPORT_USAGE = [
10
+ "Usage: hollware export <directory> --url <hollware url> (--token-file <file> | token on stdin)",
11
+ " [--site <key>] [--collection <name>]... [--locale <code>]...",
12
+ " [--published] [--dry-run]",
13
+ "",
14
+ "Writes the entries of a running Hollware, read over MCP, as Markdown files at",
15
+ "<directory>/<collection>/<locale>/<slug>.md: the layout \"hollware import\" reads. Each file is",
16
+ "the revision byte for byte as Hollware keeps it, frontmatter included, so an import of the",
17
+ "export into the same site changes nothing. Entries in the trash are left out. The directory",
18
+ "must be empty or not exist: nothing of yours is overwritten.",
19
+ "",
20
+ "Options:",
21
+ " --url <url> The address of your Hollware, such as https://cms.example.com (MCP is at <url>/mcp)",
22
+ " --token-file <file> A file with a personal token; without it, the token is read from stdin",
23
+ " --site <key> Stop unless the token is of this site (a token reads the one site it is of)",
24
+ " --collection <name> Export only this collection; repeat it for several",
25
+ " --locale <code> Export only this locale; repeat it for several",
26
+ " --published Export only the published entries, as they are published; by default the",
27
+ " latest revision of every entry, drafts included",
28
+ " --dry-run List the files it would write, and write nothing",
29
+ " --help Show this help",
30
+ "",
31
+ "The token needs content:read."
32
+ ].join("\n");
33
+ /** The most entries one `list_entries` answers. */
34
+ const PAGE_SIZE = 100;
35
+ /**
36
+ * A folder name that is safe to write under the directory: no separator, no dot first, nothing a
37
+ * terminal or a file system acts on. Collection names and locales are narrower than this.
38
+ */
39
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
40
+ async function exportContent(argv, io) {
41
+ const { values, positionals } = parseFlags({
42
+ args: [...argv],
43
+ allowPositionals: true,
44
+ options: {
45
+ url: { type: "string" },
46
+ "token-file": { type: "string" },
47
+ site: { type: "string" },
48
+ collection: {
49
+ type: "string",
50
+ multiple: true
51
+ },
52
+ locale: {
53
+ type: "string",
54
+ multiple: true
55
+ },
56
+ published: { type: "boolean" },
57
+ "dry-run": { type: "boolean" },
58
+ help: { type: "boolean" }
59
+ }
60
+ });
61
+ if (values.help === true) {
62
+ io.out(EXPORT_USAGE);
63
+ return 0;
64
+ }
65
+ const [directory, ...extra] = positionals;
66
+ if (directory === void 0 || extra.length > 0) throw new UsageError("export takes one directory: hollware export <directory> --url <url>.");
67
+ if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
68
+ const endpoint = mcpEndpoint(values.url);
69
+ const root = resolve(io.cwd, directory);
70
+ checkTarget(root, directory);
71
+ const token = await readToken(io, values["token-file"]);
72
+ try {
73
+ return await exportEntries({
74
+ io,
75
+ token,
76
+ endpoint,
77
+ directory,
78
+ root,
79
+ site: values.site,
80
+ collections: values.collection,
81
+ locales: values.locale,
82
+ published: values.published === true,
83
+ dryRun: values["dry-run"] === true
84
+ });
85
+ } catch (error) {
86
+ throw withoutToken(error, token);
87
+ }
88
+ }
89
+ /** The directory must be empty or not exist, so that the export overwrites nothing. */
90
+ function checkTarget(root, directory) {
91
+ let names;
92
+ try {
93
+ names = readdirSync(root);
94
+ } catch (error) {
95
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
96
+ if (code === "ENOENT") return;
97
+ if (code === "ENOTDIR") throw new CliError(`${printable(directory)} is not a directory.`);
98
+ throw new CliError(`could not read ${printable(directory)}: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
100
+ if (names.length > 0) throw new CliError(`${printable(directory)} is not empty: export into a new or an empty directory, so that no file is overwritten. Nothing was written.`);
101
+ }
102
+ /**
103
+ * Reads everything first, then writes: a call that fails stops the export before any file is
104
+ * written.
105
+ */
106
+ async function exportEntries(plan) {
107
+ const { io, token, directory, root, published, dryRun } = plan;
108
+ const out = (message) => io.out(redact(message, token));
109
+ const session = await openSession(plan.endpoint, token, readVersion());
110
+ let files;
111
+ let siteKey;
112
+ try {
113
+ const { site } = await session.call(describeSite, {});
114
+ siteKey = site.key;
115
+ if (plan.site !== void 0 && plan.site !== site.key) throw new CliError(`the token is of the site ${printable(JSON.stringify(site.key))}, not ${printable(JSON.stringify(plan.site))}: a personal token reads the one site it is of. Use a token of ${printable(plan.site)}.`);
116
+ files = await readAll(session, await listAll(session, chosen("collection", site.collections.map((collection) => collection.name), plan.collections), plan.locales === void 0 ? [void 0] : chosen("locale", site.locales, plan.locales), published), published);
117
+ } finally {
118
+ await session.close();
119
+ }
120
+ const view = published ? "the published revisions" : "the latest revisions, drafts included";
121
+ if (files.length === 0) {
122
+ out(`No entries to export from the site ${printable(siteKey)} (${view}). Nothing was written.`);
123
+ return 0;
124
+ }
125
+ if (dryRun) {
126
+ out(files.map((file) => `would write ${printable(file.path)}`).join("\n"));
127
+ out(`Dry run: ${count(files.length, "entry", "entries")} of the site ${printable(siteKey)} would be written to ${printable(directory)} (${view}):\n${groupSummary(files)}\nNothing was written.`);
128
+ return 0;
129
+ }
130
+ let written = 0;
131
+ for (const file of files) {
132
+ const target = join(root, file.collection, file.locale, `${file.slug}.md`);
133
+ try {
134
+ mkdirSync(dirname(target), { recursive: true });
135
+ writeFileSync(target, file.markdown, { flag: "wx" });
136
+ } catch (error) {
137
+ throw new CliError(`could not write ${printable(file.path)}: ${error instanceof Error ? error.message : String(error)}. ${written} of ${count(files.length, "file")} written: remove ${printable(directory)} and run the export again.`);
138
+ }
139
+ written += 1;
140
+ }
141
+ out(`Exported ${count(files.length, "entry", "entries")} of the site ${printable(siteKey)} to ${printable(directory)} (${view}):\n${groupSummary(files)}`);
142
+ return 0;
143
+ }
144
+ /** The names asked for, each checked against what the site has; all of them when none is. */
145
+ function chosen(kind, available, asked) {
146
+ if (asked === void 0) return [...available];
147
+ const unknown = asked.filter((name) => !available.includes(name));
148
+ if (unknown.length > 0) throw new CliError(`the site has no ${kind} ${unknown.map((name) => printable(JSON.stringify(name))).join(", ")}: it has ${available.map(printable).join(", ")}.`);
149
+ return [...new Set(asked)];
150
+ }
151
+ /**
152
+ * Every entry of the collections, in the locales (`undefined`: every locale), page by page.
153
+ * The head view leaves out the trash; `published` lists only published entries. An entry whose
154
+ * collection, locale or slug cannot be a file name below the directory stops the export.
155
+ */
156
+ async function listAll(session, collections, locales, published) {
157
+ const byPath = /* @__PURE__ */ new Map();
158
+ for (const collection of collections) for (const locale of locales) {
159
+ let cursor;
160
+ do {
161
+ let page;
162
+ try {
163
+ page = await session.call(listEntries, {
164
+ collection,
165
+ ...locale === void 0 ? {} : { locale },
166
+ ...published ? { status: "published" } : {},
167
+ limit: PAGE_SIZE,
168
+ ...cursor === void 0 ? {} : { cursor }
169
+ });
170
+ } catch (error) {
171
+ if (!(error instanceof ToolFailure)) throw error;
172
+ throw new CliError(`could not list ${printable(locale === void 0 ? collection : `${collection}/${locale}`)}: ${sentence(error.message)}. Nothing was written.`);
173
+ }
174
+ for (const summary of page.entries) {
175
+ const path = `${summary.collection}/${summary.locale}/${summary.slug}.md`;
176
+ if (!SAFE_SEGMENT.test(summary.collection) || !SAFE_SEGMENT.test(summary.locale) || !schemas.slug.safeParse(summary.slug).success) throw new CliError(`the server listed an entry that cannot be a file below the directory: ${printable(JSON.stringify(path))}. Nothing was written.`);
177
+ byPath.set(path, {
178
+ collection: summary.collection,
179
+ locale: summary.locale,
180
+ slug: summary.slug,
181
+ path
182
+ });
183
+ }
184
+ cursor = page.nextCursor ?? void 0;
185
+ } while (cursor !== void 0);
186
+ }
187
+ return [...byPath.values()].sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
188
+ }
189
+ /** The Markdown of each entry: its latest revision, or the published one. */
190
+ async function readAll(session, listed, published) {
191
+ const files = [];
192
+ for (const item of listed) {
193
+ let answer;
194
+ try {
195
+ answer = await session.call(getEntry, {
196
+ collection: item.collection,
197
+ locale: item.locale,
198
+ slug: item.slug,
199
+ formats: ["markdown"],
200
+ published
201
+ });
202
+ } catch (error) {
203
+ if (!(error instanceof ToolFailure)) throw error;
204
+ throw new CliError(`could not read ${printable(item.path)}: ${sentence(error.message)}. Nothing was written.`);
205
+ }
206
+ const markdown = answer.entry?.markdown;
207
+ if (answer.outcome === "not_found" || markdown === void 0) throw new CliError(`${printable(item.path)} is gone: it was ${published ? "unpublished" : "moved to the trash or renamed"} while the export ran. Nothing was written: run the export again.`);
208
+ files.push({
209
+ ...item,
210
+ markdown
211
+ });
212
+ }
213
+ return files;
214
+ }
215
+ function sentence(message) {
216
+ return message.replace(/[\s.]+$/, "");
217
+ }
218
+ function compare(a, b) {
219
+ return a < b ? -1 : a > b ? 1 : 0;
220
+ }
221
+ function count(n, singular, plural = `${singular}s`) {
222
+ return `${n} ${n === 1 ? singular : plural}`;
223
+ }
224
+ /** One line per collection and locale, with how many entries: ` blog/en: 3`. */
225
+ function groupSummary(files) {
226
+ const sizes = /* @__PURE__ */ new Map();
227
+ for (const file of files) {
228
+ const group = `${file.collection}/${file.locale}`;
229
+ sizes.set(group, (sizes.get(group) ?? 0) + 1);
230
+ }
231
+ return [...sizes].map(([group, size]) => ` ${printable(group)}: ${size}`).join("\n");
232
+ }
233
+ //#endregion
234
+ export { exportContent };
@@ -5,83 +5,6 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
6
6
  import { REFUSAL_CONTRACT_IDS, createDraft, getEntry, isPersonalToken, publish, schemas, updateDraft, validate } from "@hollware/core";
7
7
  import { Client, InsufficientScopeError, ProtocolError, SdkError, SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client";
8
- //#region src/import/corpus.ts
9
- /**
10
- * Reads the entries of a directory laid out as `<collection>/<locale>/<slug>.md`. Only `.md`
11
- * files count. A Markdown file at any other depth is skipped and reported; one at the right
12
- * depth with a slug that is not a slug, or with bytes that are not UTF-8, is a problem. Files
13
- * and folders whose name starts with a dot, and symbolic links, are never read: a link could
14
- * lead out of the directory the person pointed at.
15
- */
16
- function readCorpus(root) {
17
- const corpus = {
18
- files: [],
19
- skipped: [],
20
- problems: []
21
- };
22
- walk(root, [], corpus);
23
- corpus.files.sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
24
- corpus.skipped.sort(compare);
25
- corpus.problems.sort((a, b) => compare(a.path, b.path));
26
- return corpus;
27
- }
28
- function compare(a, b) {
29
- return a < b ? -1 : a > b ? 1 : 0;
30
- }
31
- function walk(directory, segments, corpus) {
32
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
33
- if (entry.name.startsWith(".")) continue;
34
- if (entry.isDirectory()) walk(join(directory, entry.name), [...segments, entry.name], corpus);
35
- else if (entry.isFile() && entry.name.endsWith(".md")) readFile(join(directory, entry.name), [...segments, entry.name], corpus);
36
- }
37
- }
38
- function readFile(file, segments, corpus) {
39
- const path = segments.join("/");
40
- const [collection, locale, name] = segments;
41
- if (segments.length !== 3 || collection === void 0 || locale === void 0 || name === void 0) {
42
- corpus.skipped.push(path);
43
- return;
44
- }
45
- const slug = name.slice(0, -3);
46
- if (!schemas.slug.safeParse(slug).success) {
47
- corpus.problems.push({
48
- path,
49
- reason: `${JSON.stringify(slug)} is not a slug: use lowercase letters and digits joined by hyphens, up to 200 characters.`
50
- });
51
- return;
52
- }
53
- let bytes;
54
- try {
55
- bytes = readFileSync(file);
56
- } catch (error) {
57
- corpus.problems.push({
58
- path,
59
- reason: `the file cannot be read: ${error instanceof Error ? error.message : String(error)}`
60
- });
61
- return;
62
- }
63
- let markdown;
64
- try {
65
- markdown = new TextDecoder("utf-8", {
66
- fatal: true,
67
- ignoreBOM: true
68
- }).decode(bytes);
69
- } catch {
70
- corpus.problems.push({
71
- path,
72
- reason: "the file is not valid UTF-8."
73
- });
74
- return;
75
- }
76
- corpus.files.push({
77
- path,
78
- collection,
79
- locale,
80
- slug,
81
- markdown
82
- });
83
- }
84
- //#endregion
85
8
  //#region src/import/session.ts
86
9
  /** A tool failed and said so in words, instead of answering with one of its outcomes. */
87
10
  var ToolFailure = class extends CliError {
@@ -178,10 +101,176 @@ function explainStatus(error, endpoint) {
178
101
  }
179
102
  }
180
103
  //#endregion
104
+ //#region src/import/corpus.ts
105
+ /**
106
+ * Reads the entries of a directory laid out as `<collection>/<locale>/<slug>.md`. Only `.md`
107
+ * files count. A Markdown file at any other depth is skipped and reported; one at the right
108
+ * depth with a slug that is not a slug, or with bytes that are not UTF-8, is a problem. Files
109
+ * and folders whose name starts with a dot, and symbolic links, are never read: a link could
110
+ * lead out of the directory the person pointed at.
111
+ */
112
+ function readCorpus(root) {
113
+ const corpus = {
114
+ files: [],
115
+ skipped: [],
116
+ problems: []
117
+ };
118
+ walk(root, [], corpus);
119
+ corpus.files.sort((a, b) => compare(a.collection, b.collection) || compare(a.locale, b.locale) || compare(a.slug, b.slug));
120
+ corpus.skipped.sort(compare);
121
+ corpus.problems.sort((a, b) => compare(a.path, b.path));
122
+ return corpus;
123
+ }
124
+ function compare(a, b) {
125
+ return a < b ? -1 : a > b ? 1 : 0;
126
+ }
127
+ function walk(directory, segments, corpus) {
128
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
129
+ if (entry.name.startsWith(".")) continue;
130
+ if (entry.isDirectory()) walk(join(directory, entry.name), [...segments, entry.name], corpus);
131
+ else if (entry.isFile() && entry.name.endsWith(".md")) readFile(join(directory, entry.name), [...segments, entry.name], corpus);
132
+ }
133
+ }
134
+ function readFile(file, segments, corpus) {
135
+ const path = segments.join("/");
136
+ const [collection, locale, name] = segments;
137
+ if (segments.length !== 3 || collection === void 0 || locale === void 0 || name === void 0) {
138
+ corpus.skipped.push(path);
139
+ return;
140
+ }
141
+ const slug = name.slice(0, -3);
142
+ if (!schemas.slug.safeParse(slug).success) {
143
+ corpus.problems.push({
144
+ path,
145
+ reason: `${JSON.stringify(slug)} is not a slug: use lowercase letters and digits joined by hyphens, up to 200 characters.`
146
+ });
147
+ return;
148
+ }
149
+ let bytes;
150
+ try {
151
+ bytes = readFileSync(file);
152
+ } catch (error) {
153
+ corpus.problems.push({
154
+ path,
155
+ reason: `the file cannot be read: ${error instanceof Error ? error.message : String(error)}`
156
+ });
157
+ return;
158
+ }
159
+ let markdown;
160
+ try {
161
+ markdown = new TextDecoder("utf-8", {
162
+ fatal: true,
163
+ ignoreBOM: true
164
+ }).decode(bytes);
165
+ } catch {
166
+ corpus.problems.push({
167
+ path,
168
+ reason: "the file is not valid UTF-8."
169
+ });
170
+ return;
171
+ }
172
+ corpus.files.push({
173
+ path,
174
+ collection,
175
+ locale,
176
+ slug,
177
+ markdown
178
+ });
179
+ }
180
+ //#endregion
181
+ //#region src/import/from-nuxt-content.ts
182
+ /** The byte order mark a file may start with, kept where it is. */
183
+ const BOM = String.fromCharCode(65279);
184
+ /** A segment of a path as Nuxt Content routes it: its numeric prefix gone. */
185
+ const routed = (segment) => segment.replace(/^\d+\./, "");
186
+ /** The frontmatter of `markdown` with `field` set to `value`, unless the page sets it already. */
187
+ function withField(markdown, field, value) {
188
+ const line = `${field}: ${JSON.stringify(value)}`;
189
+ const bom = markdown.startsWith(BOM) ? BOM : "";
190
+ const body = markdown.slice(bom.length);
191
+ const open = /^---\r?\n/.exec(body);
192
+ if (open === null) return `${bom}---\n${line}\n---\n\n${body}`;
193
+ const close = /^---\s*$/m.exec(body.slice(open[0].length));
194
+ if (close === null) return markdown;
195
+ const frontmatter = body.slice(open[0].length, open[0].length + close.index);
196
+ if (new RegExp(`^${field}\\s*:`, "m").test(frontmatter)) return markdown;
197
+ const at = open[0].length + close.index;
198
+ return `${bom}${body.slice(0, at)}${line}\n${body.slice(at)}`;
199
+ }
200
+ function corpusFromNuxtContent(root, options) {
201
+ const files = [];
202
+ const skipped = [];
203
+ const problems = [];
204
+ const bySlug = /* @__PURE__ */ new Map();
205
+ const walk = (directory, segments) => {
206
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
207
+ for (const entry of entries) {
208
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
209
+ const path = [...segments, entry.name];
210
+ if (entry.isDirectory()) {
211
+ walk(join(directory, entry.name), path);
212
+ continue;
213
+ }
214
+ if (!entry.isFile()) continue;
215
+ if (!entry.name.endsWith(".md")) {
216
+ skipped.push(path.join("/"));
217
+ continue;
218
+ }
219
+ const [first, ...rest] = path;
220
+ const locale = options.localeDirs ? first ?? "" : options.locale;
221
+ const below = options.localeDirs ? rest : path;
222
+ if (options.localeDirs && rest.length === 0) {
223
+ skipped.push(path.join("/"));
224
+ continue;
225
+ }
226
+ const route = below.map(routed);
227
+ route[route.length - 1] = (route.at(-1) ?? "").slice(0, -3);
228
+ if (route.length > 1 && route.at(-1) === "index") route.pop();
229
+ const slug = route.join("-");
230
+ const where = path.join("/");
231
+ if (!schemas.slug.safeParse(slug).success) {
232
+ problems.push({
233
+ path: where,
234
+ reason: `its route makes "${slug}", which is not a slug`
235
+ });
236
+ continue;
237
+ }
238
+ const key = `${locale}/${slug}`;
239
+ const other = bySlug.get(key);
240
+ if (other !== void 0) {
241
+ problems.push({
242
+ path: where,
243
+ reason: `its slug "${slug}" is the slug of ${other} too`
244
+ });
245
+ continue;
246
+ }
247
+ bySlug.set(key, where);
248
+ const read = readFileSync(join(directory, entry.name), "utf8");
249
+ const heading = /^#[ \t]+(.+?)[ \t]*$/m.exec(read)?.[1];
250
+ const text = withField(read, "title", heading ?? slug);
251
+ files.push({
252
+ path: `${options.collection}/${locale}/${slug}.md`,
253
+ collection: options.collection,
254
+ locale,
255
+ slug,
256
+ markdown: options.localeDirs ? withField(text, options.groupField, slug) : text
257
+ });
258
+ }
259
+ };
260
+ walk(root, []);
261
+ const order = (a, b) => a < b ? -1 : a > b ? 1 : 0;
262
+ files.sort((a, b) => order(a.path, b.path));
263
+ return {
264
+ files,
265
+ skipped: skipped.sort(order),
266
+ problems: problems.sort((a, b) => order(a.path, b.path))
267
+ };
268
+ }
269
+ //#endregion
181
270
  //#region src/commands/import.ts
182
271
  const IMPORT_USAGE = [
183
272
  "Usage: hollware import <directory> --url <hollware url> (--token-file <file> | token on stdin)",
184
- " [--publish] [--dry-run]",
273
+ " [--publish] [--dry-run] [--from markdown | nuxt-content]",
185
274
  "",
186
275
  "Sends the Markdown files of a directory to a running Hollware over MCP, as drafts. The files",
187
276
  "are <directory>/<collection>/<locale>/<slug>.md. Every file is validated first: if one would be",
@@ -193,6 +282,13 @@ const IMPORT_USAGE = [
193
282
  " --token-file <file> A file with a personal token; without it, the token is read from stdin",
194
283
  " --publish Then publish the entries that have unpublished changes, acknowledging no warnings",
195
284
  " --dry-run Only validate: write nothing, publish nothing",
285
+ " --from <source> What the directory is: markdown (the default), or the content/ of a",
286
+ " Nuxt Content site",
287
+ " --collection <name> nuxt-content: the collection its pages go to",
288
+ " --locale <code> nuxt-content: the locale of every page (default en)",
289
+ " --locale-dirs nuxt-content: the first folder of each page is its locale",
290
+ " --group-field <name> nuxt-content: the field of a translation group (default",
291
+ " translationGroup)",
196
292
  " --help Show this help",
197
293
  "",
198
294
  "The token needs content:read and content:write, and content:publish for --publish: without it,",
@@ -207,6 +303,11 @@ async function importContent(argv, io) {
207
303
  "token-file": { type: "string" },
208
304
  publish: { type: "boolean" },
209
305
  "dry-run": { type: "boolean" },
306
+ from: { type: "string" },
307
+ collection: { type: "string" },
308
+ locale: { type: "string" },
309
+ "locale-dirs": { type: "boolean" },
310
+ "group-field": { type: "string" },
210
311
  help: { type: "boolean" }
211
312
  }
212
313
  });
@@ -214,12 +315,21 @@ async function importContent(argv, io) {
214
315
  io.out(IMPORT_USAGE);
215
316
  return 0;
216
317
  }
318
+ const from = values.from ?? "markdown";
319
+ if (from !== "markdown" && from !== "nuxt-content") throw new UsageError(`--from takes markdown or nuxt-content, not "${printable(from)}".`);
320
+ if (from === "nuxt-content" && values.collection === void 0) throw new UsageError("--from nuxt-content needs --collection: the collection its pages go to.");
217
321
  const [directory, ...extra] = positionals;
218
322
  if (directory === void 0 || extra.length > 0) throw new UsageError("import takes one directory: hollware import <directory> --url <url>.");
219
323
  if (values.url === void 0) throw new UsageError("--url is required: the address of your Hollware, such as https://cms.example.com.");
220
324
  const endpoint = mcpEndpoint(values.url);
221
325
  const root = resolve(io.cwd, directory);
222
326
  if (!isDirectory(root)) throw new CliError(`${directory} is not a directory.`);
327
+ const corpus = sourceOf(from, root, {
328
+ collection: values.collection ?? "",
329
+ locale: values.locale ?? "en",
330
+ localeDirs: values["locale-dirs"] === true,
331
+ groupField: values["group-field"] ?? "translationGroup"
332
+ });
223
333
  const token = await readToken(io, values["token-file"]);
224
334
  try {
225
335
  return await importFiles({
@@ -227,19 +337,29 @@ async function importContent(argv, io) {
227
337
  token,
228
338
  endpoint,
229
339
  directory,
230
- root,
340
+ corpus,
231
341
  publish: values.publish === true,
232
342
  dryRun: values["dry-run"] === true
233
343
  });
234
344
  } catch (error) {
235
- if (error instanceof CliError) throw new CliError(redact(error.message, token));
236
- const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ");
237
- throw new CliError(`unexpected error: ${redact(printable(reason.slice(0, 300)), token)}. This is a bug in hollware: please report it at https://github.com/hollware/hollware/issues.`);
345
+ throw withoutToken(error, token);
238
346
  }
239
347
  }
240
- async function importFiles({ io, token, endpoint, directory, root, publish: publishToo, dryRun }) {
348
+ /**
349
+ * What a command that holds the token throws: its own `CliError` without the token, not even in a
350
+ * message of the server, and anything else as one line that says it is a bug.
351
+ */
352
+ function withoutToken(error, token) {
353
+ if (error instanceof CliError) return new CliError(redact(error.message, token));
354
+ const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ");
355
+ return new CliError(`unexpected error: ${redact(printable(reason.slice(0, 300)), token)}. This is a bug in hollware: please report it at https://github.com/hollware/hollware/issues.`);
356
+ }
357
+ /** The files of a directory of Markdown, or of the content/ of a Nuxt Content site. */
358
+ function sourceOf(from, root, options) {
359
+ return from === "markdown" ? readCorpus(root) : corpusFromNuxtContent(root, options);
360
+ }
361
+ async function importFiles({ io, token, endpoint, directory, corpus, publish: publishToo, dryRun }) {
241
362
  const out = (message) => io.out(redact(message, token));
242
- const corpus = readCorpus(root);
243
363
  if (corpus.files.length === 0 && corpus.problems.length === 0) throw new CliError(`no entries in ${directory}: expected Markdown files at <collection>/<locale>/<slug>.md, such as blog/en/hello.md.`);
244
364
  if (corpus.files.length > 0) out(`Found ${count(corpus.files.length, "entry", "entries")} in ${directory} (${collectionSummary(corpus.files)}).`);
245
365
  if (corpus.skipped.length > 0) out(`Skipped ${count(corpus.skipped.length, "Markdown file")} that ${corpus.skipped.length === 1 ? "is" : "are"} not at <collection>/<locale>/<slug>.md: ${listSome(corpus.skipped)}.`);
@@ -297,6 +417,10 @@ function isDirectory(path) {
297
417
  return false;
298
418
  }
299
419
  }
420
+ /**
421
+ * The personal token, from `--token-file` (relative to the working directory) or else from stdin,
422
+ * trimmed. Something that is not a personal token is refused, and never printed.
423
+ */
300
424
  async function readToken(io, tokenFile) {
301
425
  let raw;
302
426
  let source;
@@ -543,4 +667,4 @@ function listSome(paths) {
543
667
  return paths.length > shown.length ? `${shown.join(", ")} and ${paths.length - shown.length} more` : shown.join(", ");
544
668
  }
545
669
  //#endregion
546
- export { importContent };
670
+ export { redact as a, openSession as c, readToken as i, importContent as n, withoutToken as o, mcpEndpoint as r, ToolFailure as s, IMPORT_USAGE as t };
@@ -0,0 +1 @@
1
+ export { n as importContent } from "./import-D9LRrYtb.mjs";
package/dist/index.mjs CHANGED
@@ -38,7 +38,7 @@ const COMMANDS = [
38
38
  },
39
39
  {
40
40
  name: "export",
41
- summary: "Export entries as Markdown files (planned for v0.2, not implemented yet)"
41
+ summary: "Export entries as Markdown files over MCP"
42
42
  }
43
43
  ];
44
44
  const NAME_WIDTH = Math.max(...COMMANDS.map((command) => command.name.length));
@@ -77,10 +77,6 @@ async function main(argv, io) {
77
77
  io.out(`hollware ${readVersion()}`);
78
78
  return 0;
79
79
  }
80
- if (first === "export") {
81
- io.err("hollware export: not implemented yet (planned for v0.2)");
82
- return 1;
83
- }
84
80
  if (![
85
81
  "init",
86
82
  "migrate",
@@ -88,7 +84,8 @@ async function main(argv, io) {
88
84
  "doctor",
89
85
  "token",
90
86
  "invite",
91
- "import"
87
+ "import",
88
+ "export"
92
89
  ].includes(first)) {
93
90
  io.err(`hollware: unknown command or flag "${printable(first)}"\n\n${USAGE}`);
94
91
  return 1;
@@ -106,12 +103,13 @@ async function main(argv, io) {
106
103
  async function run(command, args, io) {
107
104
  switch (command) {
108
105
  case "init": return (await import("./init-CBZPzLBs.mjs")).init(args, io);
109
- case "migrate": return (await import("./migrate-Ka2Mm06e.mjs")).migrate(args, io);
106
+ case "migrate": return (await import("./migrate-CRFTlOVG.mjs")).migrate(args, io);
110
107
  case "deploy": return (await import("./deploy-4tusYOMV.mjs")).deploy(args, io);
111
108
  case "doctor": return (await import("./doctor-DiKAktVd.mjs")).doctor(args, io);
112
- case "token": return (await import("./token-BND4Rsxj.mjs")).token(args, io);
109
+ case "token": return (await import("./token-BasKoAEC.mjs")).token(args, io);
113
110
  case "invite": return (await import("./invite-Ck-aqpN8.mjs")).invite(args, io);
114
- default: return (await import("./import-DG8AABZQ.mjs")).importContent(args, io);
111
+ case "export": return (await import("./export-DprcsjU_.mjs")).exportContent(args, io);
112
+ default: return (await import("./import-JNLtBmyD.mjs")).importContent(args, io);
115
113
  }
116
114
  }
117
115
  function isEntryPoint() {
@@ -0,0 +1 @@
1
+ export { i as migrate } from "./migrate-BBfF-mkE.mjs";
@@ -0,0 +1 @@
1
+ export { c as token } from "./token-CLQpD4ed.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hollware",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "The Hollware CLI: init, migrate, token, import and export.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -31,17 +31,17 @@
31
31
  "provenance": true
32
32
  },
33
33
  "dependencies": {
34
- "@hollware/core": "0.9.0",
34
+ "@hollware/core": "0.10.0",
35
35
  "@modelcontextprotocol/client": "2.3.1",
36
- "zod": "4.6.5"
36
+ "zod": "4.6.7"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@modelcontextprotocol/server": "2.3.1",
40
40
  "@types/node": "24.19.2",
41
41
  "publint": "0.3.25",
42
42
  "typescript": "7.0.2",
43
- "vite-plus": "1.1.0",
44
- "wrangler": "4.150.0"
43
+ "vite-plus": "1.2.0",
44
+ "wrangler": "4.151.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=24.11.0"
@@ -1,2 +0,0 @@
1
- import { i as migrate } from "./migrate-BBfF-mkE.mjs";
2
- export { migrate };
@@ -1,2 +0,0 @@
1
- import { c as token } from "./token-CLQpD4ed.mjs";
2
- export { token };