@evcraddock/slug-cli 0.6.5 → 0.7.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
@@ -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 <slug>`, `slug --site <name> post publish <slug>`, `slug --site <name> post unpublish <slug>`, and `slug --site <name> post delete <slug>` 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.
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]
@@ -904,6 +905,8 @@ async function runPostsCommand(context, args) {
904
905
  return runPostsListCommand(context, args.slice(1));
905
906
  case "show":
906
907
  return runPostsShowCommand(context, args.slice(1));
908
+ case "import":
909
+ return runPostsImportCommand(context, args.slice(1));
907
910
  case "create":
908
911
  return runPostsCreateCommand(context, args.slice(1));
909
912
  case "edit":
@@ -915,9 +918,190 @@ async function runPostsCommand(context, args) {
915
918
  case "unpublish":
916
919
  return runPostsLifecycleCommand(context, args.slice(1), "unpublish");
917
920
  default:
918
- throw createInvalidUsageError("Usage: slug post <list|show|create|edit|delete|publish|unpublish>");
921
+ throw createInvalidUsageError("Usage: slug post <list|show|import|create|edit|delete|publish|unpublish>");
919
922
  }
920
923
  }
924
+ async function runPostsImportCommand(context, args) {
925
+ const { filePath, json } = readPostImportArgs(args);
926
+ const post = await readMarkdownPostImport(filePath);
927
+ const api = await createConfiguredApiContext(context);
928
+ const bannerUrl = post.bannerPath === undefined
929
+ ? undefined
930
+ : await uploadPostImportBanner(api, post, post.bannerPath);
931
+ const content = bannerUrl === undefined ? post.content : createBannerContent(post, bannerUrl);
932
+ const body = {
933
+ type: post.type,
934
+ slug: post.slug,
935
+ content,
936
+ ...(post.title === undefined ? {} : { title: post.title }),
937
+ ...(post.excerpt === undefined ? {} : { excerpt: post.excerpt }),
938
+ ...(post.url === undefined ? {} : { url: post.url }),
939
+ ...(post.tags === undefined ? {} : { tagSlugs: post.tags }),
940
+ ...(post.publishedAt === undefined ? {} : { publishedAt: post.publishedAt }),
941
+ };
942
+ writeMutationTarget(context.writer, api.apiBaseUrl, json);
943
+ const existing = await findImportedPost(api.client, post.slug);
944
+ const response = existing === undefined
945
+ ? await api.client.requestJson({ method: "POST", path: "/posts", body })
946
+ : await api.client.requestJson({
947
+ method: "PUT",
948
+ path: `/posts/${encodeURIComponent(post.slug)}`,
949
+ body: withoutPostType(body),
950
+ });
951
+ if (json) {
952
+ writeJson(context.writer, response);
953
+ }
954
+ else {
955
+ context.writer.stdout(`Imported post ${response.data.slug} (${existing === undefined ? "created" : "updated"}).`);
956
+ }
957
+ return { exitCode: ExitCode.Ok };
958
+ }
959
+ function readPostImportArgs(args) {
960
+ const json = args.includes("--json");
961
+ const positional = args.filter((arg) => arg !== "--json");
962
+ if (positional.length !== 1 || positional[0] === undefined || positional[0].startsWith("--")) {
963
+ throw createInvalidUsageError("Usage: slug post import <file.md> [--json]");
964
+ }
965
+ return { filePath: positional[0], json };
966
+ }
967
+ async function readMarkdownPostImport(filePath) {
968
+ let markdown;
969
+ try {
970
+ markdown = await readFile(filePath, "utf8");
971
+ }
972
+ catch {
973
+ throw createInvalidUsageError(`Cannot read Markdown file: ${filePath}`);
974
+ }
975
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
976
+ if (match === null) {
977
+ throw createInvalidUsageError("Markdown import requires YAML frontmatter delimited by ---");
978
+ }
979
+ const frontmatter = parseMarkdownFrontmatter(match[1]);
980
+ const slug = readRequiredFrontmatterString(frontmatter, "slug");
981
+ const type = readRequiredFrontmatterString(frontmatter, "type");
982
+ if (type !== "article" && type !== "link" && type !== "note") {
983
+ throw createInvalidUsageError("Frontmatter type must be article, link, or note");
984
+ }
985
+ const title = readOptionalFrontmatterString(frontmatter, "title");
986
+ if (type === "article" && title === undefined) {
987
+ throw createInvalidUsageError("Article imports require frontmatter title");
988
+ }
989
+ const excerpt = readOptionalFrontmatterString(frontmatter, "excerpt");
990
+ const url = readOptionalFrontmatterString(frontmatter, "url");
991
+ const tags = readFrontmatterTags(frontmatter);
992
+ const publishedAt = readImportPublishedAt(frontmatter);
993
+ const banner = readOptionalFrontmatterString(frontmatter, "banner");
994
+ return {
995
+ slug,
996
+ type,
997
+ ...(title === undefined ? {} : { title }),
998
+ ...(excerpt === undefined ? {} : { excerpt }),
999
+ ...(url === undefined ? {} : { url }),
1000
+ ...(tags === undefined ? {} : { tags }),
1001
+ ...(publishedAt === undefined ? {} : { publishedAt }),
1002
+ ...(banner === undefined ? {} : { bannerPath: resolve(dirname(filePath), banner) }),
1003
+ content: markdown.slice(match[0].length),
1004
+ };
1005
+ }
1006
+ function parseMarkdownFrontmatter(value) {
1007
+ const frontmatter = new Map();
1008
+ const lines = value.split(/\r?\n/u);
1009
+ for (let index = 0; index < lines.length; index += 1) {
1010
+ const line = lines[index];
1011
+ const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/u.exec(line);
1012
+ if (match === null || match[1] === undefined)
1013
+ continue;
1014
+ const key = match[1];
1015
+ const rawValue = match[2]?.trim() ?? "";
1016
+ if (key === "tags" && rawValue === "") {
1017
+ const tags = [];
1018
+ while (/^\s+-\s+/u.test(lines[index + 1] ?? "")) {
1019
+ index += 1;
1020
+ tags.push(parseFrontmatterScalar((lines[index] ?? "").replace(/^\s+-\s+/u, "")));
1021
+ }
1022
+ frontmatter.set(key, tags);
1023
+ }
1024
+ else {
1025
+ frontmatter.set(key, key === "tags" ? parseFrontmatterTags(rawValue) : parseFrontmatterScalar(rawValue));
1026
+ }
1027
+ }
1028
+ return frontmatter;
1029
+ }
1030
+ function parseFrontmatterScalar(value) {
1031
+ if (value.startsWith('"') && value.endsWith('"')) {
1032
+ try {
1033
+ const parsed = JSON.parse(value);
1034
+ if (typeof parsed === "string")
1035
+ return parsed;
1036
+ }
1037
+ catch {
1038
+ // Report the unparsed value below so the API can validate it where applicable.
1039
+ }
1040
+ }
1041
+ if (value.startsWith("'") && value.endsWith("'")) {
1042
+ return value.slice(1, -1).replace(/''/gu, "'");
1043
+ }
1044
+ return value;
1045
+ }
1046
+ function parseFrontmatterTags(value) {
1047
+ const trimmed = value.trim();
1048
+ const items = trimmed.startsWith("[") && trimmed.endsWith("]")
1049
+ ? trimmed.slice(1, -1).split(",")
1050
+ : trimmed.split(",");
1051
+ return items.map((item) => parseFrontmatterScalar(item.trim())).filter((item) => item !== "");
1052
+ }
1053
+ function readRequiredFrontmatterString(frontmatter, key) {
1054
+ const value = readOptionalFrontmatterString(frontmatter, key);
1055
+ if (value === undefined)
1056
+ throw createInvalidUsageError(`Markdown frontmatter requires ${key}`);
1057
+ return value;
1058
+ }
1059
+ function readOptionalFrontmatterString(frontmatter, key) {
1060
+ const value = frontmatter.get(key);
1061
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
1062
+ }
1063
+ function readFrontmatterTags(frontmatter) {
1064
+ const value = frontmatter.get("tags");
1065
+ return Array.isArray(value) ? value : undefined;
1066
+ }
1067
+ function readImportPublishedAt(frontmatter) {
1068
+ const publishedAt = readOptionalFrontmatterString(frontmatter, "publishedAt") ??
1069
+ readOptionalFrontmatterString(frontmatter, "date");
1070
+ return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
1071
+ }
1072
+ async function uploadPostImportBanner(api, post, bannerPath) {
1073
+ const file = await readMediaUploadFile(bannerPath);
1074
+ const fileBody = file.body.buffer.slice(file.body.byteOffset, file.body.byteOffset + file.body.byteLength);
1075
+ const body = new FormData();
1076
+ body.set("file", new File([fileBody], file.filename, { type: file.mimeType }));
1077
+ body.set("altText", `${post.title ?? post.slug} banner`);
1078
+ body.set("key", `posts/${post.slug}/banner${extname(file.filename).toLowerCase()}`);
1079
+ const response = await api.client.requestJson({
1080
+ method: "POST",
1081
+ path: "/media",
1082
+ body,
1083
+ });
1084
+ return response.data.url;
1085
+ }
1086
+ function createBannerContent(post, bannerUrl) {
1087
+ return `![${post.title ?? post.slug} banner](${bannerUrl})\n\n${post.content}`;
1088
+ }
1089
+ async function findImportedPost(client, slug) {
1090
+ try {
1091
+ return (await client.requestJson({ path: `/posts/${encodeURIComponent(slug)}` }))
1092
+ .data;
1093
+ }
1094
+ catch (error) {
1095
+ if (error instanceof CliError && error.status === 404)
1096
+ return undefined;
1097
+ throw error;
1098
+ }
1099
+ }
1100
+ function withoutPostType(input) {
1101
+ const { type, ...update } = input;
1102
+ void type;
1103
+ return update;
1104
+ }
921
1105
  async function runPostsListCommand(context, args) {
922
1106
  const parsed = parsePostFlags(args, ["type", "status", "tag"]);
923
1107
  const api = await createConfiguredApiContext(context);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",