@evcraddock/slug-cli 0.6.5 → 0.8.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 +23 -1
- package/dist/commands.js +203 -20
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +3 -1
- package/dist/http.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,29 @@ Use `slug --site <name> site config show` to read supported runtime site configu
|
|
|
51
51
|
|
|
52
52
|
Use `slug --site <name> doctor` to check API compatibility and package metadata. Use `slug --site <name> upgrade` for advisory, non-destructive package update recommendations; it reports suggested dependency changes without rewriting site files.
|
|
53
53
|
|
|
54
|
-
Use `slug --site <name> post list`, `slug --site <name> post show <slug>`, `slug --site <name> post create`, `slug --site <name> post edit
|
|
54
|
+
Use `slug --site <name> post list`, `slug --site <name> post show <slug>`, `slug --site <name> post create`, `slug --site <name> post edit`, `slug --site <name> post publish`, `slug --site <name> post unpublish`, and `slug --site <name> post delete` to manage posts through the Slugkit posts API. For migration imports, pass `--published-at <date-or-datetime>` to `post create` or `post publish` to preserve an original publish date without triggering follower delivery by default.
|
|
55
|
+
|
|
56
|
+
## Markdown imports
|
|
57
|
+
|
|
58
|
+
Import one Markdown file with `slug --site <name> post import <file.md>`. Imports require YAML frontmatter delimited by `---` with `slug` and `type`; articles also require `title`. Supported frontmatter fields are `title`, `slug`, `type`, `excerpt`, `date`, `publishedAt`, `tags`, `url`, and `banner`. `date` is used when `publishedAt` is absent. Dates must be ISO dates or datetimes. Tags support either a YAML list or a flow-style list such as `[writing, notes]`.
|
|
59
|
+
|
|
60
|
+
```markdown
|
|
61
|
+
---
|
|
62
|
+
title: Hello world
|
|
63
|
+
slug: hello-world
|
|
64
|
+
type: article
|
|
65
|
+
excerpt: A short introduction
|
|
66
|
+
date: 2020-01-02
|
|
67
|
+
tags:
|
|
68
|
+
- writing
|
|
69
|
+
- updates
|
|
70
|
+
banner: images/hero.png
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
The imported body.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A relative `banner` path is resolved from the Markdown file, uploaded through the Slugkit media API under `posts/<slug>/banner.<extension>`, and inserted as the first Markdown image in the post body so standard Slugkit article previews render it. The command looks up the post by slug: it creates a missing post and updates an existing one, replacing the imported metadata, body, tags, and publication date. Re-importing a banner uploads the current file again to refresh the deterministic media object key.
|
|
55
77
|
|
|
56
78
|
Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
|
|
57
79
|
|
package/dist/commands.js
CHANGED
|
@@ -19,6 +19,7 @@ Usage:
|
|
|
19
19
|
slug [--config <file>] init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]
|
|
20
20
|
slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
|
|
21
21
|
slug [--config <file>] post show <slug> [--json]
|
|
22
|
+
slug [--config <file>] post import <file.md> [--json]
|
|
22
23
|
slug [--config <file>] post create --type article|link|note --slug <slug> --content <text> [--title <text>] [--url <url>] [--excerpt <text>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
|
|
23
24
|
slug [--config <file>] post edit <slug> [--slug <new-slug>] [--title <text>] [--content <text>] [--url <url>] [--excerpt <text>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
|
|
24
25
|
slug [--config <file>] post delete <slug> [--json]
|
|
@@ -343,7 +344,7 @@ function shouldExcludeTemplateEntry(relativePath, name) {
|
|
|
343
344
|
parts.includes("data") ||
|
|
344
345
|
parts.includes("coverage") ||
|
|
345
346
|
relativePath === "src/__tests__/dev-environment.test.ts" ||
|
|
346
|
-
relativePath === "src/federation/__tests__/follow.test.ts" ||
|
|
347
|
+
relativePath === "src/federation/__tests__/follow.integration.test.ts" ||
|
|
347
348
|
relativePath === ".env" ||
|
|
348
349
|
name.endsWith(".log") ||
|
|
349
350
|
name.endsWith(".tsbuildinfo"));
|
|
@@ -362,25 +363,24 @@ async function replaceGeneratedSitePlaceholders(directory, options) {
|
|
|
362
363
|
`intro: ${JSON.stringify(`${options.siteTitle} is ready for posts, feeds, and federation.`)}`,
|
|
363
364
|
],
|
|
364
365
|
]);
|
|
365
|
-
await
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
[
|
|
372
|
-
|
|
373
|
-
"Configure these values for your own local or production S3-compatible service before uploading media.",
|
|
374
|
-
],
|
|
375
|
-
[
|
|
376
|
-
"## In-repository development\n\nWhen working inside the Slugkit toolkit repository, start the full local dev environment from the repository root:\n\n```bash\nmake dev\n```\n\nFrom `template/site`, you can also run the app and Tailwind watcher directly in separate terminals:\n\n```bash\nnpm run dev\nnpm run css:watch\n```\n\nRepository-root workspace commands are useful while developing the template itself:\n\n```bash\nnpm run db:migrate --workspace @slugkit/template-site\nnpm run db:status --workspace @slugkit/template-site\nnpm run test --workspace @slugkit/template-site\n```\n\nStandalone initialized sites should use the non-workspace commands shown in the quick start section.",
|
|
377
|
-
"## Local development\n\nInstall Overmind if it is not already available, then run the app and Tailwind watcher together from this site directory:\n\n```bash\nmake dev\n```\n\nThe generated `Procfile.dev` runs `npm run dev` for the Hono app and `npm run css:watch` for Tailwind. Use the non-workspace commands shown in the quick start section for migrations and local checks.",
|
|
378
|
-
],
|
|
379
|
-
[
|
|
380
|
-
"## Related specs\n\n- [Web Spec 08: Template Setup Documentation](../../docs/web-specs/08-template-setup-documentation.md)\n- [CLI Spec 12: Site Init Command](../../docs/cli-specs/12-site-init-command.md)\n- [CLI Specs](../../docs/cli-specs/README.md)\n- [API Route Specs](../../docs/specs/README.md)",
|
|
381
|
-
"## Related Slugkit references\n\n- Slugkit API documentation is available from a running site at `/api/v1/docs`.\n- The CLI compatibility check is `slug doctor`.\n- The generated site marker is `.slugkit-site.json`.",
|
|
382
|
-
],
|
|
366
|
+
await writeGeneratedSiteReadme(directory, options);
|
|
367
|
+
}
|
|
368
|
+
async function writeGeneratedSiteReadme(directory, options) {
|
|
369
|
+
const sourcePath = join(directory, "README.generated.md");
|
|
370
|
+
const source = await readFile(sourcePath, "utf8");
|
|
371
|
+
const replacements = new Map([
|
|
372
|
+
["{{SITE_NAME}}", options.name],
|
|
373
|
+
["{{SITE_TITLE}}", options.siteTitle],
|
|
383
374
|
]);
|
|
375
|
+
let content = source;
|
|
376
|
+
for (const [placeholder, value] of replacements) {
|
|
377
|
+
if (!content.includes(placeholder)) {
|
|
378
|
+
throw createInvalidUsageError(`Generated README is missing placeholder: ${placeholder}`);
|
|
379
|
+
}
|
|
380
|
+
content = content.replaceAll(placeholder, value);
|
|
381
|
+
}
|
|
382
|
+
await writeFile(join(directory, "README.md"), content, "utf8");
|
|
383
|
+
await rm(sourcePath);
|
|
384
384
|
}
|
|
385
385
|
function replaceGeneratedSiteWorkspaceDependencies(value) {
|
|
386
386
|
if (!isRecord(value))
|
|
@@ -904,6 +904,8 @@ async function runPostsCommand(context, args) {
|
|
|
904
904
|
return runPostsListCommand(context, args.slice(1));
|
|
905
905
|
case "show":
|
|
906
906
|
return runPostsShowCommand(context, args.slice(1));
|
|
907
|
+
case "import":
|
|
908
|
+
return runPostsImportCommand(context, args.slice(1));
|
|
907
909
|
case "create":
|
|
908
910
|
return runPostsCreateCommand(context, args.slice(1));
|
|
909
911
|
case "edit":
|
|
@@ -915,8 +917,189 @@ async function runPostsCommand(context, args) {
|
|
|
915
917
|
case "unpublish":
|
|
916
918
|
return runPostsLifecycleCommand(context, args.slice(1), "unpublish");
|
|
917
919
|
default:
|
|
918
|
-
throw createInvalidUsageError("Usage: slug post <list|show|create|edit|delete|publish|unpublish>");
|
|
920
|
+
throw createInvalidUsageError("Usage: slug post <list|show|import|create|edit|delete|publish|unpublish>");
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function runPostsImportCommand(context, args) {
|
|
924
|
+
const { filePath, json } = readPostImportArgs(args);
|
|
925
|
+
const post = await readMarkdownPostImport(filePath);
|
|
926
|
+
const api = await createConfiguredApiContext(context);
|
|
927
|
+
const bannerUrl = post.bannerPath === undefined
|
|
928
|
+
? undefined
|
|
929
|
+
: await uploadPostImportBanner(api, post, post.bannerPath);
|
|
930
|
+
const content = bannerUrl === undefined ? post.content : createBannerContent(post, bannerUrl);
|
|
931
|
+
const body = {
|
|
932
|
+
type: post.type,
|
|
933
|
+
slug: post.slug,
|
|
934
|
+
content,
|
|
935
|
+
...(post.title === undefined ? {} : { title: post.title }),
|
|
936
|
+
...(post.excerpt === undefined ? {} : { excerpt: post.excerpt }),
|
|
937
|
+
...(post.url === undefined ? {} : { url: post.url }),
|
|
938
|
+
...(post.tags === undefined ? {} : { tagSlugs: post.tags }),
|
|
939
|
+
...(post.publishedAt === undefined ? {} : { publishedAt: post.publishedAt }),
|
|
940
|
+
};
|
|
941
|
+
writeMutationTarget(context.writer, api.apiBaseUrl, json);
|
|
942
|
+
const existing = await findImportedPost(api.client, post.slug);
|
|
943
|
+
const response = existing === undefined
|
|
944
|
+
? await api.client.requestJson({ method: "POST", path: "/posts", body })
|
|
945
|
+
: await api.client.requestJson({
|
|
946
|
+
method: "PUT",
|
|
947
|
+
path: `/posts/${encodeURIComponent(post.slug)}`,
|
|
948
|
+
body: withoutPostType(body),
|
|
949
|
+
});
|
|
950
|
+
if (json) {
|
|
951
|
+
writeJson(context.writer, response);
|
|
919
952
|
}
|
|
953
|
+
else {
|
|
954
|
+
context.writer.stdout(`Imported post ${response.data.slug} (${existing === undefined ? "created" : "updated"}).`);
|
|
955
|
+
}
|
|
956
|
+
return { exitCode: ExitCode.Ok };
|
|
957
|
+
}
|
|
958
|
+
function readPostImportArgs(args) {
|
|
959
|
+
const json = args.includes("--json");
|
|
960
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
961
|
+
if (positional.length !== 1 || positional[0] === undefined || positional[0].startsWith("--")) {
|
|
962
|
+
throw createInvalidUsageError("Usage: slug post import <file.md> [--json]");
|
|
963
|
+
}
|
|
964
|
+
return { filePath: positional[0], json };
|
|
965
|
+
}
|
|
966
|
+
async function readMarkdownPostImport(filePath) {
|
|
967
|
+
let markdown;
|
|
968
|
+
try {
|
|
969
|
+
markdown = await readFile(filePath, "utf8");
|
|
970
|
+
}
|
|
971
|
+
catch {
|
|
972
|
+
throw createInvalidUsageError(`Cannot read Markdown file: ${filePath}`);
|
|
973
|
+
}
|
|
974
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
|
|
975
|
+
if (match === null) {
|
|
976
|
+
throw createInvalidUsageError("Markdown import requires YAML frontmatter delimited by ---");
|
|
977
|
+
}
|
|
978
|
+
const frontmatter = parseMarkdownFrontmatter(match[1]);
|
|
979
|
+
const slug = readRequiredFrontmatterString(frontmatter, "slug");
|
|
980
|
+
const type = readRequiredFrontmatterString(frontmatter, "type");
|
|
981
|
+
if (type !== "article" && type !== "link" && type !== "note") {
|
|
982
|
+
throw createInvalidUsageError("Frontmatter type must be article, link, or note");
|
|
983
|
+
}
|
|
984
|
+
const title = readOptionalFrontmatterString(frontmatter, "title");
|
|
985
|
+
if (type === "article" && title === undefined) {
|
|
986
|
+
throw createInvalidUsageError("Article imports require frontmatter title");
|
|
987
|
+
}
|
|
988
|
+
const excerpt = readOptionalFrontmatterString(frontmatter, "excerpt");
|
|
989
|
+
const url = readOptionalFrontmatterString(frontmatter, "url");
|
|
990
|
+
const tags = readFrontmatterTags(frontmatter);
|
|
991
|
+
const publishedAt = readImportPublishedAt(frontmatter);
|
|
992
|
+
const banner = readOptionalFrontmatterString(frontmatter, "banner");
|
|
993
|
+
return {
|
|
994
|
+
slug,
|
|
995
|
+
type,
|
|
996
|
+
...(title === undefined ? {} : { title }),
|
|
997
|
+
...(excerpt === undefined ? {} : { excerpt }),
|
|
998
|
+
...(url === undefined ? {} : { url }),
|
|
999
|
+
...(tags === undefined ? {} : { tags }),
|
|
1000
|
+
...(publishedAt === undefined ? {} : { publishedAt }),
|
|
1001
|
+
...(banner === undefined ? {} : { bannerPath: resolve(dirname(filePath), banner) }),
|
|
1002
|
+
content: markdown.slice(match[0].length),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
function parseMarkdownFrontmatter(value) {
|
|
1006
|
+
const frontmatter = new Map();
|
|
1007
|
+
const lines = value.split(/\r?\n/u);
|
|
1008
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1009
|
+
const line = lines[index];
|
|
1010
|
+
const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/u.exec(line);
|
|
1011
|
+
if (match === null || match[1] === undefined)
|
|
1012
|
+
continue;
|
|
1013
|
+
const key = match[1];
|
|
1014
|
+
const rawValue = match[2]?.trim() ?? "";
|
|
1015
|
+
if (key === "tags" && rawValue === "") {
|
|
1016
|
+
const tags = [];
|
|
1017
|
+
while (/^\s+-\s+/u.test(lines[index + 1] ?? "")) {
|
|
1018
|
+
index += 1;
|
|
1019
|
+
tags.push(parseFrontmatterScalar((lines[index] ?? "").replace(/^\s+-\s+/u, "")));
|
|
1020
|
+
}
|
|
1021
|
+
frontmatter.set(key, tags);
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
frontmatter.set(key, key === "tags" ? parseFrontmatterTags(rawValue) : parseFrontmatterScalar(rawValue));
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return frontmatter;
|
|
1028
|
+
}
|
|
1029
|
+
function parseFrontmatterScalar(value) {
|
|
1030
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
1031
|
+
try {
|
|
1032
|
+
const parsed = JSON.parse(value);
|
|
1033
|
+
if (typeof parsed === "string")
|
|
1034
|
+
return parsed;
|
|
1035
|
+
}
|
|
1036
|
+
catch {
|
|
1037
|
+
// Report the unparsed value below so the API can validate it where applicable.
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
1041
|
+
return value.slice(1, -1).replace(/''/gu, "'");
|
|
1042
|
+
}
|
|
1043
|
+
return value;
|
|
1044
|
+
}
|
|
1045
|
+
function parseFrontmatterTags(value) {
|
|
1046
|
+
const trimmed = value.trim();
|
|
1047
|
+
const items = trimmed.startsWith("[") && trimmed.endsWith("]")
|
|
1048
|
+
? trimmed.slice(1, -1).split(",")
|
|
1049
|
+
: trimmed.split(",");
|
|
1050
|
+
return items.map((item) => parseFrontmatterScalar(item.trim())).filter((item) => item !== "");
|
|
1051
|
+
}
|
|
1052
|
+
function readRequiredFrontmatterString(frontmatter, key) {
|
|
1053
|
+
const value = readOptionalFrontmatterString(frontmatter, key);
|
|
1054
|
+
if (value === undefined)
|
|
1055
|
+
throw createInvalidUsageError(`Markdown frontmatter requires ${key}`);
|
|
1056
|
+
return value;
|
|
1057
|
+
}
|
|
1058
|
+
function readOptionalFrontmatterString(frontmatter, key) {
|
|
1059
|
+
const value = frontmatter.get(key);
|
|
1060
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
1061
|
+
}
|
|
1062
|
+
function readFrontmatterTags(frontmatter) {
|
|
1063
|
+
const value = frontmatter.get("tags");
|
|
1064
|
+
return Array.isArray(value) ? value : undefined;
|
|
1065
|
+
}
|
|
1066
|
+
function readImportPublishedAt(frontmatter) {
|
|
1067
|
+
const publishedAt = readOptionalFrontmatterString(frontmatter, "publishedAt") ??
|
|
1068
|
+
readOptionalFrontmatterString(frontmatter, "date");
|
|
1069
|
+
return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
|
|
1070
|
+
}
|
|
1071
|
+
async function uploadPostImportBanner(api, post, bannerPath) {
|
|
1072
|
+
const file = await readMediaUploadFile(bannerPath);
|
|
1073
|
+
const fileBody = file.body.buffer.slice(file.body.byteOffset, file.body.byteOffset + file.body.byteLength);
|
|
1074
|
+
const body = new FormData();
|
|
1075
|
+
body.set("file", new File([fileBody], file.filename, { type: file.mimeType }));
|
|
1076
|
+
body.set("altText", `${post.title ?? post.slug} banner`);
|
|
1077
|
+
body.set("key", `posts/${post.slug}/banner${extname(file.filename).toLowerCase()}`);
|
|
1078
|
+
const response = await api.client.requestJson({
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
path: "/media",
|
|
1081
|
+
body,
|
|
1082
|
+
});
|
|
1083
|
+
return response.data.url;
|
|
1084
|
+
}
|
|
1085
|
+
function createBannerContent(post, bannerUrl) {
|
|
1086
|
+
return `\n\n${post.content}`;
|
|
1087
|
+
}
|
|
1088
|
+
async function findImportedPost(client, slug) {
|
|
1089
|
+
try {
|
|
1090
|
+
return (await client.requestJson({ path: `/posts/${encodeURIComponent(slug)}` }))
|
|
1091
|
+
.data;
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
if (error instanceof CliError && error.status === 404)
|
|
1095
|
+
return undefined;
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function withoutPostType(input) {
|
|
1100
|
+
const { type, ...update } = input;
|
|
1101
|
+
void type;
|
|
1102
|
+
return update;
|
|
920
1103
|
}
|
|
921
1104
|
async function runPostsListCommand(context, args) {
|
|
922
1105
|
const parsed = parsePostFlags(args, ["type", "status", "tag"]);
|
package/dist/errors.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export declare const ExitCode: {
|
|
|
7
7
|
};
|
|
8
8
|
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode];
|
|
9
9
|
export declare class CliError extends Error {
|
|
10
|
+
readonly status?: number | undefined;
|
|
10
11
|
readonly exitCode: ExitCode;
|
|
11
|
-
constructor(message: string, exitCode: ExitCode);
|
|
12
|
+
constructor(message: string, exitCode: ExitCode, status?: number | undefined);
|
|
12
13
|
}
|
|
13
14
|
export declare function createInvalidUsageError(message: string): CliError;
|
package/dist/errors.js
CHANGED
|
@@ -6,9 +6,11 @@ export const ExitCode = {
|
|
|
6
6
|
ApiError: 12,
|
|
7
7
|
};
|
|
8
8
|
export class CliError extends Error {
|
|
9
|
+
status;
|
|
9
10
|
exitCode;
|
|
10
|
-
constructor(message, exitCode) {
|
|
11
|
+
constructor(message, exitCode, status) {
|
|
11
12
|
super(message);
|
|
13
|
+
this.status = status;
|
|
12
14
|
this.name = "CliError";
|
|
13
15
|
this.exitCode = exitCode;
|
|
14
16
|
}
|
package/dist/http.js
CHANGED
|
@@ -25,10 +25,10 @@ export class SlugHttpClient {
|
|
|
25
25
|
body: createRequestBody(options.body),
|
|
26
26
|
});
|
|
27
27
|
if (response.status === 401 || response.status === 403) {
|
|
28
|
-
throw new CliError(await readApiErrorMessage(response, "Authentication failed"), ExitCode.AuthenticationError);
|
|
28
|
+
throw new CliError(await readApiErrorMessage(response, "Authentication failed"), ExitCode.AuthenticationError, response.status);
|
|
29
29
|
}
|
|
30
30
|
if (!response.ok) {
|
|
31
|
-
throw new CliError(await readApiErrorMessage(response, `API request failed with status ${response.status}`), ExitCode.ApiError);
|
|
31
|
+
throw new CliError(await readApiErrorMessage(response, `API request failed with status ${response.status}`), ExitCode.ApiError, response.status);
|
|
32
32
|
}
|
|
33
33
|
return response;
|
|
34
34
|
}
|