@evcraddock/slug-cli 0.6.4 → 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
@@ -25,7 +25,7 @@ Create a standalone site with:
25
25
  slug init ./my-site --name my-site --site-title "My Site"
26
26
  ```
27
27
 
28
- Run `slug init --help` for template options. By default, `slug init` downloads the default template zip artifact. `--template-dir` remains available for local template development, `--template-url` supports alternate remote zip artifacts, and `--template` reserves the selection path for future named templates.
28
+ Run `slug init --help` for template options. By default, `slug init` downloads the latest template zip artifact and falls back to the template artifact published with the installed CLI version if the latest artifact is missing. `--template-dir` remains available for local template development, `--template-url` supports alternate remote zip artifacts, and `--template` reserves the selection path for future named templates. Explicit `--template-url` and `SLUGKIT_TEMPLATE_URL` overrides do not fall back.
29
29
 
30
30
  ## Login and configuration
31
31
 
@@ -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]
@@ -155,7 +156,7 @@ Options:
155
156
  --json Print command output as JSON.
156
157
  --help, -h Show this help.
157
158
 
158
- By default, slug init downloads the default template zip artifact. Set SLUGKIT_TEMPLATE_URL or pass --template-url to use a different zip artifact. Generated sites do not depend on the template artifact at runtime.`;
159
+ By default, slug init downloads the latest template zip artifact and falls back to the CLI-versioned artifact if latest is missing. Set SLUGKIT_TEMPLATE_URL or pass --template-url to use a different zip artifact without fallback. Generated sites do not depend on the template artifact at runtime.`;
159
160
  const SITE_INIT_NEXT_STEPS = [
160
161
  "Install dependencies",
161
162
  "Copy and edit .env",
@@ -438,17 +439,37 @@ async function resolveInitTemplateSource(context, options) {
438
439
  if (options.template !== undefined && options.template !== DEFAULT_TEMPLATE_NAME) {
439
440
  throw createInvalidUsageError(`Unsupported template: ${options.template}. Use --template-url for external template zip artifacts.`);
440
441
  }
441
- return downloadTemplateSite(context, getDefaultTemplateUrl(context));
442
+ return downloadDefaultTemplateSite(context, getDefaultTemplateResolution(context));
442
443
  }
443
- function getDefaultTemplateUrl(context) {
444
- if (context.templateAssetBaseUrl !== undefined) {
445
- return `${context.templateAssetBaseUrl.replace(/\/+$/u, "")}/slugkit-site-template.zip`;
446
- }
444
+ function getDefaultTemplateResolution(context) {
447
445
  const environmentUrl = process.env.SLUGKIT_TEMPLATE_URL?.trim();
448
446
  if (environmentUrl !== undefined && environmentUrl !== "") {
449
- return environmentUrl;
447
+ return { primaryUrl: environmentUrl };
448
+ }
449
+ const primaryUrl = context.templateAssetBaseUrl !== undefined
450
+ ? `${context.templateAssetBaseUrl.replace(/\/+$/u, "")}/slugkit-site-template.zip`
451
+ : DEFAULT_TEMPLATE_URL;
452
+ return {
453
+ primaryUrl,
454
+ fallbackUrl: getVersionedTemplateUrl(context.packageVersion),
455
+ };
456
+ }
457
+ function getVersionedTemplateUrl(packageVersion) {
458
+ const version = packageVersion.trim().replace(/^v/u, "");
459
+ return `https://forge.caradoc.com/erik/slugkit/releases/download/v${version}/slugkit-site-template-v${version}.zip`;
460
+ }
461
+ async function downloadDefaultTemplateSite(context, resolution) {
462
+ try {
463
+ return await downloadTemplateSite(context, resolution.primaryUrl);
464
+ }
465
+ catch (error) {
466
+ if (resolution.fallbackUrl === undefined ||
467
+ !(error instanceof TemplateDownloadError) ||
468
+ error.status !== 404) {
469
+ throw error;
470
+ }
471
+ return downloadTemplateSite(context, resolution.fallbackUrl);
450
472
  }
451
- return DEFAULT_TEMPLATE_URL;
452
473
  }
453
474
  async function downloadTemplateSite(context, templateUrl) {
454
475
  let parsedUrl;
@@ -464,7 +485,7 @@ async function downloadTemplateSite(context, templateUrl) {
464
485
  const fetchImpl = context.fetchImpl ?? fetch;
465
486
  const response = await fetchImpl(parsedUrl);
466
487
  if (!response.ok) {
467
- throw new CliError(`Failed to download Slugkit template from ${parsedUrl.toString()}: HTTP ${response.status}`, ExitCode.NetworkError);
488
+ throw new TemplateDownloadError(parsedUrl.toString(), response.status);
468
489
  }
469
490
  const tempDirectory = await mkdtemp(join(tmpdir(), "slug-template-"));
470
491
  const archivePath = join(tempDirectory, "template.zip");
@@ -479,6 +500,13 @@ async function downloadTemplateSite(context, templateUrl) {
479
500
  cleanup: () => rm(tempDirectory, { force: true, recursive: true }),
480
501
  };
481
502
  }
503
+ class TemplateDownloadError extends CliError {
504
+ status;
505
+ constructor(templateUrl, status) {
506
+ super(`Failed to download Slugkit template from ${templateUrl}: HTTP ${status}`, ExitCode.NetworkError);
507
+ this.status = status;
508
+ }
509
+ }
482
510
  async function findExtractedTemplateDirectory(extractDirectory) {
483
511
  if (await isTemplateSiteDirectory(extractDirectory)) {
484
512
  return extractDirectory;
@@ -877,6 +905,8 @@ async function runPostsCommand(context, args) {
877
905
  return runPostsListCommand(context, args.slice(1));
878
906
  case "show":
879
907
  return runPostsShowCommand(context, args.slice(1));
908
+ case "import":
909
+ return runPostsImportCommand(context, args.slice(1));
880
910
  case "create":
881
911
  return runPostsCreateCommand(context, args.slice(1));
882
912
  case "edit":
@@ -888,8 +918,189 @@ async function runPostsCommand(context, args) {
888
918
  case "unpublish":
889
919
  return runPostsLifecycleCommand(context, args.slice(1), "unpublish");
890
920
  default:
891
- 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>");
922
+ }
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
+ }
892
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;
893
1104
  }
894
1105
  async function runPostsListCommand(context, args) {
895
1106
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",