@evcraddock/slug-cli 0.10.0 → 0.11.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
@@ -67,13 +67,50 @@ date: 2020-01-02
67
67
  tags:
68
68
  - writing
69
69
  - updates
70
- banner: images/hero.png
70
+ banner: ./images/hero.png
71
71
  ---
72
72
 
73
73
  The imported body.
74
74
  ```
75
75
 
76
- A relative article `banner` path is resolved from the Markdown file, uploaded through the Slugkit media API under `posts/<slug>/banner.<extension>`, and saved as the post's explicit `bannerUrl` without modifying Markdown content. Link imports map `banner_url` directly to `bannerUrl`. 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.
76
+ An article `banner` starting with `./` is a local file path resolved from the Markdown file. It is uploaded through the Slugkit media API under `posts/<slug>/banner.<extension>` and saved as the post's explicit `bannerUrl` without modifying Markdown content. Re-importing that local banner uploads the current file again to refresh the deterministic media object key. A banner without `./` is an existing same-site media key, not a local file.
77
+
78
+ **Migration:** change existing local banner references such as `banner: images/hero.png` to `banner: ./images/hero.png`. There is no local-file fallback for missing media keys. Full URLs, absolute filesystem paths, `..` path segments (including `../`), empty values, backslashes, and URL-encoded or reserved path characters (`:`, `%`, `?`, `#`) are rejected.
79
+
80
+ The command looks up the post by slug: it creates a missing post and updates an existing one, applying the supplied metadata, body, and tags. A new article without `date` or `publishedAt` is unpublished. Re-importing an existing article preserves its ID, slug, and publication timestamp when dates are omitted. Omitting `banner` leaves an existing banner unchanged; for a new article it means no banner. Link imports continue to map `banner_url` directly to `bannerUrl`.
81
+
82
+ ### Upload once and reuse an article banner
83
+
84
+ Upload a shared image with a stable key:
85
+
86
+ ```bash
87
+ slug --site my-site media upload ./technology.png \
88
+ --key banners/technology.png \
89
+ --alt "Technology banner"
90
+ ```
91
+
92
+ Use that key in any number of articles:
93
+
94
+ ```markdown
95
+ ---
96
+ title: Technology notes
97
+ slug: technology-notes
98
+ type: article
99
+ banner: banners/technology.png
100
+ ---
101
+
102
+ The article body.
103
+ ```
104
+
105
+ ```bash
106
+ slug --site my-site post import ./technology-notes.md
107
+ ```
108
+
109
+ For an API base of `https://your-site.example/api/v1`, the importer verifies `https://your-site.example/media/banners/technology.png`. It uses the API's origin and the site's `/media/` route, not a separately configured external media hostname. It sends an unauthenticated `HEAD` request with a five-second timeout and requires HTTP 200 with an `image/*` content type. No image body is downloaded, no media database lookup is required, and no new upload is made. Redirects are rejected rather than followed.
110
+
111
+ A missing shared image or local file stops import before post or media mutations. Non-image responses, unsupported HEAD requests, network failures, and server errors also stop import with contextual errors; they are not silently treated as missing images or replaced with defaults.
112
+
113
+ Replacing an image at a shared key changes it for every article using that key, subject to browser or proxy caching. Deleting it affects all those articles too: when viewed in a JavaScript-enabled browser, unavailable article banners are removed without a broken-image placeholder or default banner. Articles with no banner have none. URL verification at import time is only a point-in-time availability check; it does not prevent later deletion.
77
114
 
78
115
  Link imports may include one source and one or more authors:
79
116
 
@@ -0,0 +1,12 @@
1
+ export interface ArticleBanner {
2
+ bannerPath?: string;
3
+ bannerKey?: string;
4
+ }
5
+ interface VerifyBannerKeyOptions {
6
+ apiBaseUrl: string;
7
+ key: string;
8
+ fetchImpl?: typeof fetch;
9
+ }
10
+ export declare function readArticleBanner(frontmatter: Record<string, unknown>, filePath: string): ArticleBanner;
11
+ export declare function verifyBannerKey(options: VerifyBannerKeyOptions): Promise<string>;
12
+ export {};
@@ -0,0 +1,62 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
3
+ export function readArticleBanner(frontmatter, filePath) {
4
+ if (!("banner" in frontmatter))
5
+ return {};
6
+ const value = frontmatter.banner;
7
+ if (typeof value !== "string" || value.trim() === "") {
8
+ throw createInvalidUsageError("frontmatter.banner must be a non-empty string");
9
+ }
10
+ const local = value.startsWith("./");
11
+ assertBannerKey(local ? value.slice(2) : value);
12
+ return local ? { bannerPath: resolve(dirname(filePath), value) } : { bannerKey: value };
13
+ }
14
+ function assertBannerKey(key) {
15
+ const invalidSegment = key
16
+ .split("/")
17
+ .some((part) => part === "" || part === "." || part === "..");
18
+ const hasControlCharacter = [...key].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127);
19
+ if (invalidSegment || hasControlCharacter || key !== key.trim() || /[:\\%?#]/u.test(key)) {
20
+ throw createInvalidUsageError("frontmatter.banner must be a media key or ./local-file path; URLs, absolute paths, traversal, and URL-encoded or reserved path characters are not allowed");
21
+ }
22
+ }
23
+ export async function verifyBannerKey(options) {
24
+ assertBannerKey(options.key);
25
+ const key = options.key.split("/").map(encodeURIComponent).join("/");
26
+ const url = new URL(`/media/${key}`, options.apiBaseUrl).toString();
27
+ let response;
28
+ try {
29
+ response = await (options.fetchImpl ?? fetch)(url, {
30
+ method: "HEAD",
31
+ headers: { accept: "image/*" },
32
+ credentials: "omit",
33
+ redirect: "manual",
34
+ signal: AbortSignal.timeout(5_000),
35
+ });
36
+ }
37
+ catch {
38
+ throw new CliError(`Cannot verify banner: network request failed or timed out for ${url}`, ExitCode.NetworkError);
39
+ }
40
+ assertBannerResponse(response, url);
41
+ return url;
42
+ }
43
+ function assertBannerResponse(response, url) {
44
+ let reason;
45
+ if (response.status === 404 || response.status === 410) {
46
+ reason = "Banner image not found";
47
+ }
48
+ else if (response.status >= 300 && response.status < 400) {
49
+ reason = "Cannot verify banner: redirects are not allowed";
50
+ }
51
+ else if (response.status === 405 || response.status === 501) {
52
+ reason = "Cannot verify banner: HEAD requests are not supported";
53
+ }
54
+ else if (response.status !== 200) {
55
+ reason = `Cannot verify banner: HTTP ${response.status}`;
56
+ }
57
+ else if (!/^image\/[a-z0-9.+-]+(?:\s*;|$)/iu.test(response.headers.get("content-type") ?? "")) {
58
+ reason = "Cannot verify banner: response is not an image";
59
+ }
60
+ if (reason !== undefined)
61
+ throw new CliError(`${reason}: ${url}`, ExitCode.ApiError, response.status);
62
+ }
package/dist/commands.js CHANGED
@@ -5,6 +5,7 @@ import AdmZip from "adm-zip";
5
5
  import { parse as parseYaml } from "yaml";
6
6
  import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions, normalizeApiBaseUrl, readSlugkitApiMajorVersion, } from "@evcraddock/slug-core";
7
7
  import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
8
+ import { readArticleBanner, verifyBannerKey } from "./banner-import.js";
8
9
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
9
10
  import { SlugHttpClient } from "./http.js";
10
11
  import { discoverLinkRelationships } from "./link-metadata.js";
@@ -143,7 +144,7 @@ const GENERATED_SITE_SLUGKIT_DEPENDENCIES = {
143
144
  "@evcraddock/slug-auth": "0.1.0",
144
145
  "@evcraddock/slug-core": "0.1.1",
145
146
  "@evcraddock/slug-federation": "0.1.0",
146
- "@evcraddock/slug-media": "0.1.0",
147
+ "@evcraddock/slug-media": "0.1.1",
147
148
  };
148
149
  const INIT_HELP_TEXT = `slug init - create a standalone Slugkit-compatible website
149
150
 
@@ -947,7 +948,13 @@ async function runPostsImportCommand(context, args) {
947
948
  const uploadedBannerUrl = post.bannerPath === undefined
948
949
  ? undefined
949
950
  : await uploadPostImportBanner(api, post, post.bannerPath);
950
- const bannerUrl = uploadedBannerUrl ?? post.bannerUrl;
951
+ const bannerUrl = post.bannerKey === undefined
952
+ ? (uploadedBannerUrl ?? post.bannerUrl)
953
+ : await verifyBannerKey({
954
+ apiBaseUrl: api.apiBaseUrl,
955
+ key: post.bannerKey,
956
+ fetchImpl: context.fetchImpl,
957
+ });
951
958
  writeMutationTarget(context.writer, api.apiBaseUrl, json);
952
959
  const existing = await findImportedPost(api.client, post.slug);
953
960
  const relationships = await resolveMarkdownImportRelationships(api.client, post);
@@ -1024,7 +1031,7 @@ async function readMarkdownPostImport(filePath) {
1024
1031
  }
1025
1032
  const tags = readMarkdownTags(frontmatter);
1026
1033
  const publishedAt = readMarkdownPublishedAt(frontmatter);
1027
- const banner = readMarkdownOptionalString(frontmatter, "banner", "frontmatter");
1034
+ const banner = type === "article" ? readArticleBanner(frontmatter, filePath) : {};
1028
1035
  const linkBannerUrl = readMarkdownOptionalString(frontmatter, "banner_url", "frontmatter");
1029
1036
  return {
1030
1037
  slug,
@@ -1034,9 +1041,7 @@ async function readMarkdownPostImport(filePath) {
1034
1041
  ...(url === undefined ? {} : { url }),
1035
1042
  ...(tags === undefined ? {} : { tags }),
1036
1043
  ...(publishedAt === undefined ? {} : { publishedAt }),
1037
- ...(type !== "article" || banner === undefined
1038
- ? {}
1039
- : { bannerPath: resolve(dirname(filePath), banner) }),
1044
+ ...banner,
1040
1045
  ...(type !== "link" || linkBannerUrl === undefined ? {} : { bannerUrl: linkBannerUrl }),
1041
1046
  ...(source === undefined ? {} : { source }),
1042
1047
  ...(authors === undefined ? {} : { authors }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",