@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 +39 -2
- package/dist/banner-import.d.ts +12 -0
- package/dist/banner-import.js +62 -0
- package/dist/commands.js +11 -6
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
-
...
|
|
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 }),
|