@evcraddock/slug-cli 0.9.0 → 0.10.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
@@ -55,7 +55,7 @@ Use `slug --site <name> post list`, `slug --site <name> post show <slug>`, `slug
55
55
 
56
56
  ## Markdown imports
57
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]`.
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`, `banner`, `banner_url`, `source`, and `authors`. `source` and `authors` are supported only for links. `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
59
 
60
60
  ```markdown
61
61
  ---
@@ -73,7 +73,51 @@ banner: images/hero.png
73
73
  The imported body.
74
74
  ```
75
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.
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.
77
+
78
+ Link imports may include one source and one or more authors:
79
+
80
+ ```markdown
81
+ ---
82
+ type: link
83
+ slug: how-to-stop-being-boring
84
+ title: How to stop being boring
85
+ url: https://www.joanwestenberg.com/how-to-stop-being-boring/
86
+ banner_url: https://example.com/banner.jpg
87
+ excerpt: A short description.
88
+ tags: [adulting]
89
+ publishedAt: 2026-02-07
90
+ source:
91
+ name: Joan Westenberg
92
+ url: https://www.joanwestenberg.com
93
+ authors:
94
+ - name: Joan Westenberg
95
+ url: https://www.joanwestenberg.com/about
96
+ ---
97
+
98
+ Optional commentary about the link.
99
+ ```
100
+
101
+ Link files may omit either or both relationship fields:
102
+
103
+ ```markdown
104
+ ---
105
+ type: link
106
+ slug: discovered-link
107
+ title: Discovered link
108
+ url: https://publication.example/posts/discovered-link
109
+ ---
110
+
111
+ Optional commentary.
112
+ ```
113
+
114
+ Explicit `source` and `authors` values override discovery independently. For each missing field, the importer fetches the link page without sending Slugkit API credentials. Source discovery prefers JSON-LD publisher data, then standard site-name metadata. Author discovery uses JSON-LD author data and standard author metadata. If source metadata is unavailable, the normalized destination hostname and origin become the source. Missing or malformed author metadata does not block the import; the link is imported without author contacts.
115
+
116
+ Remote discovery accepts HTML responses, uses a five-second timeout, and reads at most 1 MiB. Network errors, non-HTML responses, malformed metadata, timeouts, and oversized responses use the hostname source fallback. Duplicate discovered authors are collapsed by canonical URL or normalized name, preferring a direct author URL over fragment identifiers.
117
+
118
+ When a relationship URL is available, the importer matches existing sources and contacts by a canonical URL with fragments and trailing slashes removed. Without a URL, it matches a unique name after normalizing case and whitespace. Ambiguous matches fail instead of silently selecting a record. Missing authors are created as contacts, then associated with the resolved or newly created source and credited on the post. Existing source contacts and post credits are preserved. Re-importing the same file reuses records and relationships without duplicates.
119
+
120
+ All explicit source and author metadata is validated before API mutations. For relationship imports, missing contacts are created first, the source is created or updated second, and the post is created or updated last. The API does not provide a bulk transaction, so a later failure stops the import but records created by earlier successful requests remain; relationship mutation errors identify the failed frontmatter path.
77
121
 
78
122
  ## Source imports
79
123
 
package/dist/commands.js CHANGED
@@ -7,6 +7,7 @@ import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions,
7
7
  import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
8
8
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
9
9
  import { SlugHttpClient } from "./http.js";
10
+ import { discoverLinkRelationships } from "./link-metadata.js";
10
11
  import { writeJson } from "./output.js";
11
12
  const HELP_TEXT = `slug - manage Slugkit sites
12
13
 
@@ -21,8 +22,8 @@ Usage:
21
22
  slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
22
23
  slug [--config <file>] post show <slug> [--json]
23
24
  slug [--config <file>] post import <file.md> [--json]
24
- 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]
25
- 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]
25
+ slug [--config <file>] post create --type article|link|note --slug <slug> --content <text> [--title <text>] [--url <url>] [--excerpt <text>] [--banner-url <url>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
26
+ slug [--config <file>] post edit <slug> [--slug <new-slug>] [--title <text>] [--content <text>] [--url <url>] [--excerpt <text>] [--banner-url <url>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
26
27
  slug [--config <file>] post delete <slug> [--json]
27
28
  slug [--config <file>] post publish <slug> [--published-at <datetime>] [--json]
28
29
  slug [--config <file>] post unpublish <slug> [--json]
@@ -924,24 +925,46 @@ async function runPostsCommand(context, args) {
924
925
  }
925
926
  async function runPostsImportCommand(context, args) {
926
927
  const { filePath, json } = readPostImportArgs(args);
927
- const post = await readMarkdownPostImport(filePath);
928
+ let post = await readMarkdownPostImport(filePath);
929
+ if (post.type === "link" && post.url !== undefined) {
930
+ const discovered = await discoverLinkRelationships({
931
+ url: post.url,
932
+ source: post.source === undefined,
933
+ authors: post.authors === undefined,
934
+ fetchImpl: context.fetchImpl,
935
+ });
936
+ post = {
937
+ ...post,
938
+ ...(post.source !== undefined || discovered.source === undefined
939
+ ? {}
940
+ : { source: discovered.source }),
941
+ ...(post.authors !== undefined || discovered.authors === undefined
942
+ ? {}
943
+ : { authors: discovered.authors }),
944
+ };
945
+ }
928
946
  const api = await createConfiguredApiContext(context);
929
- const bannerUrl = post.bannerPath === undefined
947
+ const uploadedBannerUrl = post.bannerPath === undefined
930
948
  ? undefined
931
949
  : await uploadPostImportBanner(api, post, post.bannerPath);
932
- const content = bannerUrl === undefined ? post.content : createBannerContent(post, bannerUrl);
950
+ const bannerUrl = uploadedBannerUrl ?? post.bannerUrl;
951
+ writeMutationTarget(context.writer, api.apiBaseUrl, json);
952
+ const existing = await findImportedPost(api.client, post.slug);
953
+ const relationships = await resolveMarkdownImportRelationships(api.client, post);
954
+ const creditContactIds = mergeIds(existing?.creditContactIds ?? [], relationships.authorContactIds);
933
955
  const body = {
934
956
  type: post.type,
935
957
  slug: post.slug,
936
- content,
958
+ content: post.content,
937
959
  ...(post.title === undefined ? {} : { title: post.title }),
938
960
  ...(post.excerpt === undefined ? {} : { excerpt: post.excerpt }),
939
961
  ...(post.url === undefined ? {} : { url: post.url }),
962
+ ...(bannerUrl === undefined ? {} : { bannerUrl }),
963
+ ...(relationships.sourceId === undefined ? {} : { sourceId: relationships.sourceId }),
964
+ ...(post.authors === undefined ? {} : { creditContactIds }),
940
965
  ...(post.tags === undefined ? {} : { tagSlugs: post.tags }),
941
966
  ...(post.publishedAt === undefined ? {} : { publishedAt: post.publishedAt }),
942
967
  };
943
- writeMutationTarget(context.writer, api.apiBaseUrl, json);
944
- const existing = await findImportedPost(api.client, post.slug);
945
968
  const response = existing === undefined
946
969
  ? await api.client.requestJson({ method: "POST", path: "/posts", body })
947
970
  : await api.client.requestJson({
@@ -977,21 +1000,32 @@ async function readMarkdownPostImport(filePath) {
977
1000
  if (match === null) {
978
1001
  throw createInvalidUsageError("Markdown import requires YAML frontmatter delimited by ---");
979
1002
  }
980
- const frontmatter = parseMarkdownFrontmatter(match[1]);
981
- const slug = readRequiredFrontmatterString(frontmatter, "slug");
982
- const type = readRequiredFrontmatterString(frontmatter, "type");
1003
+ const frontmatter = parseMarkdownPostFrontmatter(match[1]);
1004
+ const slug = readMarkdownRequiredString(frontmatter, "slug", "frontmatter");
1005
+ const type = readMarkdownRequiredString(frontmatter, "type", "frontmatter");
983
1006
  if (type !== "article" && type !== "link" && type !== "note") {
984
- throw createInvalidUsageError("Frontmatter type must be article, link, or note");
1007
+ throw createInvalidUsageError("frontmatter.type must be article, link, or note");
985
1008
  }
986
- const title = readOptionalFrontmatterString(frontmatter, "title");
1009
+ const title = readMarkdownOptionalString(frontmatter, "title", "frontmatter");
987
1010
  if (type === "article" && title === undefined) {
988
- throw createInvalidUsageError("Article imports require frontmatter title");
989
- }
990
- const excerpt = readOptionalFrontmatterString(frontmatter, "excerpt");
991
- const url = readOptionalFrontmatterString(frontmatter, "url");
992
- const tags = readFrontmatterTags(frontmatter);
993
- const publishedAt = readImportPublishedAt(frontmatter);
994
- const banner = readOptionalFrontmatterString(frontmatter, "banner");
1011
+ throw createInvalidUsageError("Article imports require frontmatter.title");
1012
+ }
1013
+ const source = readMarkdownRelationship(frontmatter, "source", "frontmatter");
1014
+ const authors = readMarkdownAuthors(frontmatter);
1015
+ if (type !== "link" && (source !== undefined || authors !== undefined)) {
1016
+ throw createInvalidUsageError("frontmatter.source and frontmatter.authors require type: link");
1017
+ }
1018
+ const excerpt = readMarkdownOptionalString(frontmatter, "excerpt", "frontmatter");
1019
+ const url = readMarkdownOptionalString(frontmatter, "url", "frontmatter");
1020
+ if (type === "link") {
1021
+ if (url === undefined)
1022
+ throw createInvalidUsageError("Link imports require frontmatter.url");
1023
+ assertSourceImportUrl(url, "frontmatter.url", ["http:", "https:"]);
1024
+ }
1025
+ const tags = readMarkdownTags(frontmatter);
1026
+ const publishedAt = readMarkdownPublishedAt(frontmatter);
1027
+ const banner = readMarkdownOptionalString(frontmatter, "banner", "frontmatter");
1028
+ const linkBannerUrl = readMarkdownOptionalString(frontmatter, "banner_url", "frontmatter");
995
1029
  return {
996
1030
  slug,
997
1031
  type,
@@ -1000,75 +1034,181 @@ async function readMarkdownPostImport(filePath) {
1000
1034
  ...(url === undefined ? {} : { url }),
1001
1035
  ...(tags === undefined ? {} : { tags }),
1002
1036
  ...(publishedAt === undefined ? {} : { publishedAt }),
1003
- ...(banner === undefined ? {} : { bannerPath: resolve(dirname(filePath), banner) }),
1037
+ ...(type !== "article" || banner === undefined
1038
+ ? {}
1039
+ : { bannerPath: resolve(dirname(filePath), banner) }),
1040
+ ...(type !== "link" || linkBannerUrl === undefined ? {} : { bannerUrl: linkBannerUrl }),
1041
+ ...(source === undefined ? {} : { source }),
1042
+ ...(authors === undefined ? {} : { authors }),
1004
1043
  content: markdown.slice(match[0].length),
1005
1044
  };
1006
1045
  }
1007
- function parseMarkdownFrontmatter(value) {
1008
- const frontmatter = new Map();
1009
- const lines = value.split(/\r?\n/u);
1010
- for (let index = 0; index < lines.length; index += 1) {
1011
- const line = lines[index];
1012
- const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/u.exec(line);
1013
- if (match === null || match[1] === undefined)
1014
- continue;
1015
- const key = match[1];
1016
- const rawValue = match[2]?.trim() ?? "";
1017
- if (key === "tags" && rawValue === "") {
1018
- const tags = [];
1019
- while (/^\s+-\s+/u.test(lines[index + 1] ?? "")) {
1020
- index += 1;
1021
- tags.push(parseFrontmatterScalar((lines[index] ?? "").replace(/^\s+-\s+/u, "")));
1022
- }
1023
- frontmatter.set(key, tags);
1046
+ function parseMarkdownPostFrontmatter(value) {
1047
+ let parsed;
1048
+ try {
1049
+ parsed = parseYaml(value);
1050
+ }
1051
+ catch (error) {
1052
+ const message = error instanceof Error ? error.message : "Unable to parse YAML";
1053
+ throw createInvalidUsageError(`Invalid Markdown frontmatter YAML: ${message}`);
1054
+ }
1055
+ if (!isRecord(parsed))
1056
+ throw createInvalidUsageError("frontmatter must be an object");
1057
+ return parsed;
1058
+ }
1059
+ function readMarkdownRequiredString(record, key, path) {
1060
+ const value = readMarkdownOptionalString(record, key, path);
1061
+ if (value === undefined) {
1062
+ throw createInvalidUsageError(`${path}.${key} must be a non-empty string`);
1063
+ }
1064
+ return value;
1065
+ }
1066
+ function readMarkdownOptionalString(record, key, path) {
1067
+ if (!(key in record))
1068
+ return undefined;
1069
+ const value = record[key];
1070
+ if (typeof value !== "string") {
1071
+ throw createInvalidUsageError(`${path}.${key} must be a string`);
1072
+ }
1073
+ return value.trim() === "" ? undefined : value;
1074
+ }
1075
+ function readMarkdownTags(record) {
1076
+ if (!("tags" in record))
1077
+ return undefined;
1078
+ if (!Array.isArray(record.tags)) {
1079
+ throw createInvalidUsageError("frontmatter.tags must be an array");
1080
+ }
1081
+ return record.tags.map((tag, index) => {
1082
+ if (typeof tag !== "string" || tag.trim() === "") {
1083
+ throw createInvalidUsageError(`frontmatter.tags[${index}] must be a non-empty string`);
1024
1084
  }
1025
- else {
1026
- frontmatter.set(key, key === "tags" ? parseFrontmatterTags(rawValue) : parseFrontmatterScalar(rawValue));
1085
+ return tag;
1086
+ });
1087
+ }
1088
+ function readMarkdownRelationship(record, key, path) {
1089
+ if (!(key in record))
1090
+ return undefined;
1091
+ return parseMarkdownRelationship(record[key], `${path}.${key}`);
1092
+ }
1093
+ function parseMarkdownRelationship(value, path) {
1094
+ if (!isRecord(value))
1095
+ throw createInvalidUsageError(`${path} must be an object`);
1096
+ for (const field of Object.keys(value)) {
1097
+ if (field !== "name" && field !== "url") {
1098
+ throw createInvalidUsageError(`${path}.${field} is not supported`);
1027
1099
  }
1028
1100
  }
1029
- return frontmatter;
1101
+ const name = readMarkdownRequiredString(value, "name", path);
1102
+ const url = readMarkdownOptionalString(value, "url", path);
1103
+ if (url !== undefined)
1104
+ assertSourceImportUrl(url, `${path}.url`, ["http:", "https:"]);
1105
+ return { name, ...(url === undefined ? {} : { url }) };
1030
1106
  }
1031
- function parseFrontmatterScalar(value) {
1032
- if (value.startsWith('"') && value.endsWith('"')) {
1033
- try {
1034
- const parsed = JSON.parse(value);
1035
- if (typeof parsed === "string")
1036
- return parsed;
1037
- }
1038
- catch {
1039
- // Report the unparsed value below so the API can validate it where applicable.
1107
+ function readMarkdownAuthors(record) {
1108
+ if (!("authors" in record))
1109
+ return undefined;
1110
+ if (!Array.isArray(record.authors) || record.authors.length === 0) {
1111
+ throw createInvalidUsageError("frontmatter.authors must be a non-empty array");
1112
+ }
1113
+ return record.authors.map((author, index) => parseMarkdownRelationship(author, `frontmatter.authors[${index}]`));
1114
+ }
1115
+ function readMarkdownPublishedAt(record) {
1116
+ const publishedAt = readMarkdownOptionalString(record, "publishedAt", "frontmatter") ??
1117
+ readMarkdownOptionalString(record, "date", "frontmatter");
1118
+ return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
1119
+ }
1120
+ async function resolveMarkdownImportRelationships(client, post) {
1121
+ if (post.source === undefined && post.authors === undefined)
1122
+ return { authorContactIds: [] };
1123
+ const sources = post.source === undefined
1124
+ ? []
1125
+ : (await client.requestJson({ path: "/sources" })).data;
1126
+ const contacts = post.authors === undefined
1127
+ ? []
1128
+ : (await client.requestJson({ path: "/contacts" })).data;
1129
+ if (post.source !== undefined) {
1130
+ findMarkdownRelationshipMatch(sources, post.source, "frontmatter.source", "sources");
1131
+ }
1132
+ post.authors?.forEach((author, index) => findMarkdownRelationshipMatch(contacts, author, `frontmatter.authors[${index}]`, "contacts"));
1133
+ const authorContactIds = await resolveMarkdownAuthors(client, post.authors ?? [], contacts);
1134
+ const sourceId = await resolveMarkdownSource(client, post.source, sources, authorContactIds);
1135
+ return { ...(sourceId === undefined ? {} : { sourceId }), authorContactIds };
1136
+ }
1137
+ async function resolveMarkdownAuthors(client, authors, contacts) {
1138
+ const ids = [];
1139
+ for (const [index, author] of authors.entries()) {
1140
+ let contact = findMarkdownRelationshipMatch(contacts, author, `frontmatter.authors[${index}]`, "contacts");
1141
+ if (contact === undefined) {
1142
+ const response = await requestMarkdownRelationship(client, "POST", "/contacts", { name: author.name, ...(author.url === undefined ? {} : { url: author.url }) }, `frontmatter.authors[${index}]`);
1143
+ contact = response.data;
1144
+ contacts.push(contact);
1040
1145
  }
1146
+ ids.push(contact.id);
1147
+ }
1148
+ return [...new Set(ids)];
1149
+ }
1150
+ async function resolveMarkdownSource(client, input, sources, authorContactIds) {
1151
+ if (input === undefined)
1152
+ return undefined;
1153
+ const existing = findMarkdownRelationshipMatch(sources, input, "frontmatter.source", "sources");
1154
+ if (existing === undefined)
1155
+ return createMarkdownSource(client, input, authorContactIds);
1156
+ await addMarkdownSourceContacts(client, existing, authorContactIds);
1157
+ return existing.id;
1158
+ }
1159
+ async function createMarkdownSource(client, input, contactIds) {
1160
+ const response = await requestMarkdownRelationship(client, "POST", "/sources", {
1161
+ name: input.name,
1162
+ ...(input.url === undefined ? {} : { url: input.url }),
1163
+ ...(contactIds.length === 0 ? {} : { contactIds }),
1164
+ }, "frontmatter.source");
1165
+ return response.data.id;
1166
+ }
1167
+ async function addMarkdownSourceContacts(client, source, authorContactIds) {
1168
+ const existingIds = source.contacts?.map((contact) => contact.id) ?? [];
1169
+ const contactIds = mergeIds(existingIds, authorContactIds);
1170
+ if (contactIds.length === existingIds.length)
1171
+ return;
1172
+ await requestMarkdownRelationship(client, "PUT", `/sources/${encodeURIComponent(source.id.toString())}`, { contactIds }, "frontmatter.source.contacts");
1173
+ }
1174
+ function findMarkdownRelationshipMatch(records, input, path, label) {
1175
+ const matches = findMarkdownRelationshipMatches(records, input);
1176
+ if (matches.length > 1) {
1177
+ throw createInvalidUsageError(`${path} matches multiple ${label} by ${input.url === undefined ? "name" : "URL"}`);
1041
1178
  }
1042
- if (value.startsWith("'") && value.endsWith("'")) {
1043
- return value.slice(1, -1).replace(/''/gu, "'");
1179
+ return matches[0];
1180
+ }
1181
+ function findMarkdownRelationshipMatches(records, input) {
1182
+ if (input.url === undefined) {
1183
+ const name = normalizeImportName(input.name);
1184
+ return records.filter((record) => normalizeImportName(record.name) === name);
1044
1185
  }
1045
- return value;
1186
+ const url = canonicalizeImportUrl(input.url);
1187
+ return records.filter((record) => record.url !== undefined && record.url !== null && canonicalizeImportUrl(record.url) === url);
1046
1188
  }
1047
- function parseFrontmatterTags(value) {
1048
- const trimmed = value.trim();
1049
- const items = trimmed.startsWith("[") && trimmed.endsWith("]")
1050
- ? trimmed.slice(1, -1).split(",")
1051
- : trimmed.split(",");
1052
- return items.map((item) => parseFrontmatterScalar(item.trim())).filter((item) => item !== "");
1053
- }
1054
- function readRequiredFrontmatterString(frontmatter, key) {
1055
- const value = readOptionalFrontmatterString(frontmatter, key);
1056
- if (value === undefined)
1057
- throw createInvalidUsageError(`Markdown frontmatter requires ${key}`);
1058
- return value;
1189
+ function canonicalizeImportUrl(value) {
1190
+ const url = new URL(value);
1191
+ url.hash = "";
1192
+ if (url.pathname !== "/")
1193
+ url.pathname = url.pathname.replace(/\/+$/u, "");
1194
+ return url.toString();
1059
1195
  }
1060
- function readOptionalFrontmatterString(frontmatter, key) {
1061
- const value = frontmatter.get(key);
1062
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
1196
+ function normalizeImportName(value) {
1197
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
1063
1198
  }
1064
- function readFrontmatterTags(frontmatter) {
1065
- const value = frontmatter.get("tags");
1066
- return Array.isArray(value) ? value : undefined;
1199
+ function mergeIds(existing, added) {
1200
+ return [...new Set([...existing, ...added])];
1067
1201
  }
1068
- function readImportPublishedAt(frontmatter) {
1069
- const publishedAt = readOptionalFrontmatterString(frontmatter, "publishedAt") ??
1070
- readOptionalFrontmatterString(frontmatter, "date");
1071
- return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
1202
+ async function requestMarkdownRelationship(client, method, path, body, importPath) {
1203
+ try {
1204
+ return await client.requestJson({ method, path, body });
1205
+ }
1206
+ catch (error) {
1207
+ if (error instanceof CliError) {
1208
+ throw new CliError(`Failed to import ${importPath}: ${error.message}`, error.exitCode, error.status);
1209
+ }
1210
+ throw error;
1211
+ }
1072
1212
  }
1073
1213
  async function uploadPostImportBanner(api, post, bannerPath) {
1074
1214
  const file = await readMediaUploadFile(bannerPath);
@@ -1082,10 +1222,7 @@ async function uploadPostImportBanner(api, post, bannerPath) {
1082
1222
  path: "/media",
1083
1223
  body,
1084
1224
  });
1085
- return response.data.url;
1086
- }
1087
- function createBannerContent(post, bannerUrl) {
1088
- return `![${post.title ?? post.slug} banner](${bannerUrl})\n\n${post.content}`;
1225
+ return new URL(response.data.url, api.apiBaseUrl).toString();
1089
1226
  }
1090
1227
  async function findImportedPost(client, slug) {
1091
1228
  try {
@@ -1142,6 +1279,7 @@ async function runPostsCreateCommand(context, args) {
1142
1279
  "title",
1143
1280
  "url",
1144
1281
  "excerpt",
1282
+ "banner-url",
1145
1283
  "tag",
1146
1284
  "source-id",
1147
1285
  "credit-contact-id",
@@ -1169,6 +1307,7 @@ async function runPostsEditCommand(context, args) {
1169
1307
  "title",
1170
1308
  "url",
1171
1309
  "excerpt",
1310
+ "banner-url",
1172
1311
  "tag",
1173
1312
  "source-id",
1174
1313
  "credit-contact-id",
@@ -1255,6 +1394,7 @@ function createPostMutationInput(options, requireCreateFields) {
1255
1394
  copyStringOption(options, input, "content", "content");
1256
1395
  copyStringOption(options, input, "url", "url");
1257
1396
  copyStringOption(options, input, "excerpt", "excerpt");
1397
+ copyStringOption(options, input, "banner-url", "bannerUrl");
1258
1398
  copyNumberOption(options, input, "source-id", "sourceId");
1259
1399
  if (typeof options["published-at"] === "string") {
1260
1400
  input.publishedAt = normalizeCliPublishedAt(options["published-at"]);
@@ -1351,6 +1491,7 @@ function writePost(writer, post) {
1351
1491
  writer.stdout(`slug: ${post.slug}`);
1352
1492
  writer.stdout(`type: ${post.type}`);
1353
1493
  writer.stdout(`title: ${post.title ?? ""}`);
1494
+ writer.stdout(`bannerUrl: ${post.bannerUrl ?? ""}`);
1354
1495
  writer.stdout(`publishedAt: ${post.publishedAt ?? "draft"}`);
1355
1496
  }
1356
1497
  function writePostMutationOutput(writer, response, json) {
@@ -0,0 +1,18 @@
1
+ export interface LinkRelationshipMetadata {
2
+ name: string;
3
+ url?: string;
4
+ }
5
+ export interface DiscoveredLinkRelationships {
6
+ source?: LinkRelationshipMetadata;
7
+ authors?: LinkRelationshipMetadata[];
8
+ }
9
+ interface DiscoverLinkRelationshipsOptions {
10
+ url: string;
11
+ source: boolean;
12
+ authors: boolean;
13
+ fetchImpl?: typeof fetch;
14
+ timeoutMs?: number;
15
+ maxResponseBytes?: number;
16
+ }
17
+ export declare function discoverLinkRelationships(options: DiscoverLinkRelationshipsOptions): Promise<DiscoveredLinkRelationships>;
18
+ export {};
@@ -0,0 +1,234 @@
1
+ import { load } from "cheerio";
2
+ const defaultTimeoutMs = 5_000;
3
+ const defaultMaxResponseBytes = 1_048_576;
4
+ export async function discoverLinkRelationships(options) {
5
+ if (!options.source && !options.authors)
6
+ return {};
7
+ const fallbackSource = options.source ? createHostnameSource(options.url) : undefined;
8
+ const html = await fetchLinkHtml(options);
9
+ if (html === undefined) {
10
+ return fallbackSource === undefined ? {} : { source: fallbackSource };
11
+ }
12
+ const document = load(html);
13
+ const jsonLd = readJsonLdRecords(document("script[type='application/ld+json']")
14
+ .toArray()
15
+ .map((element) => document(element).text()));
16
+ const source = options.source
17
+ ? (readJsonLdSource(jsonLd) ?? readMetaSource(document, options.url) ?? fallbackSource)
18
+ : undefined;
19
+ const authors = options.authors
20
+ ? deduplicateRelationships([...readJsonLdAuthors(jsonLd), ...readMetaAuthors(document)])
21
+ : [];
22
+ return {
23
+ ...(source === undefined ? {} : { source }),
24
+ ...(authors.length === 0 ? {} : { authors }),
25
+ };
26
+ }
27
+ async function fetchLinkHtml(options) {
28
+ const fetchImpl = options.fetchImpl ?? fetch;
29
+ let response;
30
+ try {
31
+ response = await fetchImpl(options.url, {
32
+ headers: {
33
+ accept: "text/html,application/xhtml+xml",
34
+ "user-agent": "Slugkit link importer",
35
+ },
36
+ signal: AbortSignal.timeout(options.timeoutMs ?? defaultTimeoutMs),
37
+ });
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ if (!response.ok || !isHtmlResponse(response))
43
+ return undefined;
44
+ try {
45
+ return await readBoundedText(response, options.maxResponseBytes ?? defaultMaxResponseBytes);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function isHtmlResponse(response) {
52
+ const contentType = response.headers.get("content-type");
53
+ return (contentType !== null &&
54
+ (contentType.toLowerCase().includes("text/html") ||
55
+ contentType.toLowerCase().includes("application/xhtml+xml")));
56
+ }
57
+ async function readBoundedText(response, maxBytes) {
58
+ const declaredLength = Number.parseInt(response.headers.get("content-length") ?? "0", 10);
59
+ if (declaredLength > maxBytes)
60
+ throw new Error("Link metadata response is too large");
61
+ if (response.body === null)
62
+ return "";
63
+ const reader = response.body.getReader();
64
+ const chunks = [];
65
+ let totalBytes = 0;
66
+ while (true) {
67
+ const result = await reader.read();
68
+ if (result.done)
69
+ break;
70
+ totalBytes += result.value.byteLength;
71
+ if (totalBytes > maxBytes) {
72
+ await reader.cancel();
73
+ throw new Error("Link metadata response is too large");
74
+ }
75
+ chunks.push(result.value);
76
+ }
77
+ const body = new Uint8Array(totalBytes);
78
+ let offset = 0;
79
+ for (const chunk of chunks) {
80
+ body.set(chunk, offset);
81
+ offset += chunk.byteLength;
82
+ }
83
+ return new TextDecoder().decode(body);
84
+ }
85
+ function readJsonLdRecords(values) {
86
+ const records = [];
87
+ for (const value of values) {
88
+ try {
89
+ collectJsonLdRecords(JSON.parse(value), records);
90
+ }
91
+ catch {
92
+ // Ignore malformed structured metadata and continue with other discovery signals.
93
+ }
94
+ }
95
+ return records;
96
+ }
97
+ function collectJsonLdRecords(value, records) {
98
+ if (Array.isArray(value)) {
99
+ value.forEach((item) => collectJsonLdRecords(item, records));
100
+ return;
101
+ }
102
+ if (!isRecord(value))
103
+ return;
104
+ records.push(value);
105
+ if ("@graph" in value)
106
+ collectJsonLdRecords(value["@graph"], records);
107
+ }
108
+ function readJsonLdSource(records) {
109
+ for (const record of records) {
110
+ const relationships = readJsonLdRelationships(record.publisher, records);
111
+ if (relationships.length > 0)
112
+ return relationships[0];
113
+ }
114
+ return undefined;
115
+ }
116
+ function readJsonLdAuthors(records) {
117
+ return records.flatMap((record) => readJsonLdRelationships(record.author, records));
118
+ }
119
+ function readJsonLdRelationships(value, records, visitedIds = new Set()) {
120
+ if (Array.isArray(value)) {
121
+ return value.flatMap((item) => readJsonLdRelationships(item, records, visitedIds));
122
+ }
123
+ if (typeof value === "string" && value.trim() !== "") {
124
+ if (!isHttpUrl(value))
125
+ return [{ name: value }];
126
+ return readJsonLdReference(value, records, visitedIds);
127
+ }
128
+ if (!isRecord(value))
129
+ return [];
130
+ if (typeof value.name !== "string" || value.name.trim() === "") {
131
+ const id = typeof value["@id"] === "string" ? value["@id"] : undefined;
132
+ return id === undefined ? [] : readJsonLdReference(id, records, visitedIds);
133
+ }
134
+ const url = readJsonLdUrl(value);
135
+ return [{ name: value.name, ...(url === undefined ? {} : { url }) }];
136
+ }
137
+ function readJsonLdReference(id, records, visitedIds) {
138
+ if (visitedIds.has(id))
139
+ return [];
140
+ const referenced = records.find((record) => record["@id"] === id);
141
+ if (referenced === undefined)
142
+ return [];
143
+ const visited = new Set(visitedIds);
144
+ visited.add(id);
145
+ return readJsonLdRelationships(referenced, records, visited);
146
+ }
147
+ function readJsonLdUrl(value) {
148
+ for (const candidate of [value.url, value["@id"]]) {
149
+ if (typeof candidate === "string" && isHttpUrl(candidate))
150
+ return candidate;
151
+ }
152
+ return undefined;
153
+ }
154
+ function readMetaSource(document, destinationUrl) {
155
+ const name = readFirstMeta(document, [
156
+ "meta[property='og:site_name']",
157
+ "meta[name='application-name']",
158
+ "meta[name='apple-mobile-web-app-title']",
159
+ ]);
160
+ return name === undefined ? undefined : { name, url: new URL(destinationUrl).origin };
161
+ }
162
+ function readMetaAuthors(document) {
163
+ return [
164
+ ...readMetaValues(document, "meta[name='author']"),
165
+ ...readMetaValues(document, "meta[property='article:author']"),
166
+ ]
167
+ .filter((value) => !isHttpUrl(value))
168
+ .map((name) => ({ name }));
169
+ }
170
+ function readFirstMeta(document, selectors) {
171
+ for (const selector of selectors) {
172
+ const value = document(selector).first().attr("content")?.trim();
173
+ if (value !== undefined && value !== "")
174
+ return value;
175
+ }
176
+ return undefined;
177
+ }
178
+ function readMetaValues(document, selector) {
179
+ return document(selector)
180
+ .toArray()
181
+ .map((element) => document(element).attr("content")?.trim() ?? "")
182
+ .filter((value) => value !== "");
183
+ }
184
+ function createHostnameSource(value) {
185
+ const url = new URL(value);
186
+ return { name: url.hostname.replace(/^www\./iu, ""), url: url.origin };
187
+ }
188
+ function deduplicateRelationships(relationships) {
189
+ const unique = [];
190
+ for (const relationship of relationships) {
191
+ const index = unique.findIndex((candidate) => relationshipsMatch(candidate, relationship));
192
+ if (index === -1) {
193
+ unique.push(relationship);
194
+ }
195
+ else if (relationshipUrlScore(relationship) > relationshipUrlScore(unique[index])) {
196
+ unique[index] = relationship;
197
+ }
198
+ }
199
+ return unique;
200
+ }
201
+ function relationshipsMatch(left, right) {
202
+ if (normalizeName(left.name) === normalizeName(right.name))
203
+ return true;
204
+ return (left.url !== undefined &&
205
+ right.url !== undefined &&
206
+ canonicalizeUrl(left.url) === canonicalizeUrl(right.url));
207
+ }
208
+ function relationshipUrlScore(relationship) {
209
+ if (relationship.url === undefined)
210
+ return 0;
211
+ return new URL(relationship.url).hash === "" ? 2 : 1;
212
+ }
213
+ function normalizeName(value) {
214
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
215
+ }
216
+ function canonicalizeUrl(value) {
217
+ const url = new URL(value);
218
+ url.hash = "";
219
+ if (url.pathname !== "/")
220
+ url.pathname = url.pathname.replace(/\/+$/u, "");
221
+ return url.toString();
222
+ }
223
+ function isHttpUrl(value) {
224
+ try {
225
+ const url = new URL(value);
226
+ return url.protocol === "http:" || url.protocol === "https:";
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ function isRecord(value) {
233
+ return typeof value === "object" && value !== null && !Array.isArray(value);
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@evcraddock/slug-core": "0.1.1",
30
30
  "adm-zip": "^0.5.18",
31
+ "cheerio": "^1.2.0",
31
32
  "yaml": "^2.9.0"
32
33
  }
33
34
  }