@evcraddock/slug-cli 0.8.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,78 @@ 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.
121
+
122
+ ## Source imports
123
+
124
+ Import link sources and their optional contacts and accounts from YAML with `slug --site <name> sources import <file.yaml>`. The file has one required top-level `sources` array. Each source requires `name` and may include `url`, `description`, `imageUrl`, `faviconUrl`, `contacts`, and `accounts`. Contacts require `name` and may include `url` and `accounts`. Accounts require `label` and `url` and may include `avatarUrl`, `kind`, `protocol`, `default`, and `sortOrder`.
125
+
126
+ ```yaml
127
+ sources:
128
+ - name: Example Blog
129
+ url: https://example.com
130
+ description: Example writing
131
+ accounts:
132
+ - label: Feed
133
+ url: https://example.com/feed.xml
134
+ kind: feed
135
+ protocol: rss
136
+ default: true
137
+ contacts:
138
+ - name: Jane Example
139
+ url: https://example.com/jane
140
+ accounts:
141
+ - label: Mastodon
142
+ url: https://social.example/@jane
143
+ kind: social
144
+ protocol: activitypub
145
+ ```
146
+
147
+ The CLI validates the complete file before sending requests. It creates contacts first, associates their returned IDs when creating each source, and then creates accounts with the returned contact or source owner ID. Sources may omit both `contacts` and `accounts`. An API failure stops the import; because the API does not provide a bulk transaction, records created by earlier successful requests remain.
77
148
 
78
149
  Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
79
150
 
package/dist/commands.js CHANGED
@@ -2,10 +2,12 @@ import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, basename, extname, join, resolve } from "node:path";
4
4
  import AdmZip from "adm-zip";
5
+ import { parse as parseYaml } from "yaml";
5
6
  import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions, normalizeApiBaseUrl, readSlugkitApiMajorVersion, } from "@evcraddock/slug-core";
6
7
  import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
7
8
  import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
8
9
  import { SlugHttpClient } from "./http.js";
10
+ import { discoverLinkRelationships } from "./link-metadata.js";
9
11
  import { writeJson } from "./output.js";
10
12
  const HELP_TEXT = `slug - manage Slugkit sites
11
13
 
@@ -20,8 +22,8 @@ Usage:
20
22
  slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
21
23
  slug [--config <file>] post show <slug> [--json]
22
24
  slug [--config <file>] post import <file.md> [--json]
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]
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]
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]
25
27
  slug [--config <file>] post delete <slug> [--json]
26
28
  slug [--config <file>] post publish <slug> [--published-at <datetime>] [--json]
27
29
  slug [--config <file>] post unpublish <slug> [--json]
@@ -36,6 +38,7 @@ Usage:
36
38
  slug [--config <file>] sources list [--json]
37
39
  slug [--config <file>] sources show <id> [--json]
38
40
  slug [--config <file>] sources create --name <name> [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
41
+ slug [--config <file>] sources import <file.yaml> [--json]
39
42
  slug [--config <file>] sources edit <id> [--name <name>] [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
40
43
  slug [--config <file>] sources delete <id> [--json]
41
44
  slug [--config <file>] accounts list [--owner-type contact|source|site] [--owner-id <id>] [--json]
@@ -922,24 +925,46 @@ async function runPostsCommand(context, args) {
922
925
  }
923
926
  async function runPostsImportCommand(context, args) {
924
927
  const { filePath, json } = readPostImportArgs(args);
925
- 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
+ }
926
946
  const api = await createConfiguredApiContext(context);
927
- const bannerUrl = post.bannerPath === undefined
947
+ const uploadedBannerUrl = post.bannerPath === undefined
928
948
  ? undefined
929
949
  : await uploadPostImportBanner(api, post, post.bannerPath);
930
- 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);
931
955
  const body = {
932
956
  type: post.type,
933
957
  slug: post.slug,
934
- content,
958
+ content: post.content,
935
959
  ...(post.title === undefined ? {} : { title: post.title }),
936
960
  ...(post.excerpt === undefined ? {} : { excerpt: post.excerpt }),
937
961
  ...(post.url === undefined ? {} : { url: post.url }),
962
+ ...(bannerUrl === undefined ? {} : { bannerUrl }),
963
+ ...(relationships.sourceId === undefined ? {} : { sourceId: relationships.sourceId }),
964
+ ...(post.authors === undefined ? {} : { creditContactIds }),
938
965
  ...(post.tags === undefined ? {} : { tagSlugs: post.tags }),
939
966
  ...(post.publishedAt === undefined ? {} : { publishedAt: post.publishedAt }),
940
967
  };
941
- writeMutationTarget(context.writer, api.apiBaseUrl, json);
942
- const existing = await findImportedPost(api.client, post.slug);
943
968
  const response = existing === undefined
944
969
  ? await api.client.requestJson({ method: "POST", path: "/posts", body })
945
970
  : await api.client.requestJson({
@@ -975,21 +1000,32 @@ async function readMarkdownPostImport(filePath) {
975
1000
  if (match === null) {
976
1001
  throw createInvalidUsageError("Markdown import requires YAML frontmatter delimited by ---");
977
1002
  }
978
- const frontmatter = parseMarkdownFrontmatter(match[1]);
979
- const slug = readRequiredFrontmatterString(frontmatter, "slug");
980
- 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");
981
1006
  if (type !== "article" && type !== "link" && type !== "note") {
982
- throw createInvalidUsageError("Frontmatter type must be article, link, or note");
1007
+ throw createInvalidUsageError("frontmatter.type must be article, link, or note");
983
1008
  }
984
- const title = readOptionalFrontmatterString(frontmatter, "title");
1009
+ const title = readMarkdownOptionalString(frontmatter, "title", "frontmatter");
985
1010
  if (type === "article" && title === undefined) {
986
- throw createInvalidUsageError("Article imports require frontmatter title");
987
- }
988
- const excerpt = readOptionalFrontmatterString(frontmatter, "excerpt");
989
- const url = readOptionalFrontmatterString(frontmatter, "url");
990
- const tags = readFrontmatterTags(frontmatter);
991
- const publishedAt = readImportPublishedAt(frontmatter);
992
- 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");
993
1029
  return {
994
1030
  slug,
995
1031
  type,
@@ -998,75 +1034,181 @@ async function readMarkdownPostImport(filePath) {
998
1034
  ...(url === undefined ? {} : { url }),
999
1035
  ...(tags === undefined ? {} : { tags }),
1000
1036
  ...(publishedAt === undefined ? {} : { publishedAt }),
1001
- ...(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 }),
1002
1043
  content: markdown.slice(match[0].length),
1003
1044
  };
1004
1045
  }
1005
- function parseMarkdownFrontmatter(value) {
1006
- const frontmatter = new Map();
1007
- const lines = value.split(/\r?\n/u);
1008
- for (let index = 0; index < lines.length; index += 1) {
1009
- const line = lines[index];
1010
- const match = /^([A-Za-z][A-Za-z0-9]*):(?:\s*(.*))?$/u.exec(line);
1011
- if (match === null || match[1] === undefined)
1012
- continue;
1013
- const key = match[1];
1014
- const rawValue = match[2]?.trim() ?? "";
1015
- if (key === "tags" && rawValue === "") {
1016
- const tags = [];
1017
- while (/^\s+-\s+/u.test(lines[index + 1] ?? "")) {
1018
- index += 1;
1019
- tags.push(parseFrontmatterScalar((lines[index] ?? "").replace(/^\s+-\s+/u, "")));
1020
- }
1021
- frontmatter.set(key, tags);
1022
- }
1023
- else {
1024
- frontmatter.set(key, key === "tags" ? parseFrontmatterTags(rawValue) : parseFrontmatterScalar(rawValue));
1025
- }
1046
+ function parseMarkdownPostFrontmatter(value) {
1047
+ let parsed;
1048
+ try {
1049
+ parsed = parseYaml(value);
1026
1050
  }
1027
- return frontmatter;
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;
1028
1058
  }
1029
- function parseFrontmatterScalar(value) {
1030
- if (value.startsWith('"') && value.endsWith('"')) {
1031
- try {
1032
- const parsed = JSON.parse(value);
1033
- if (typeof parsed === "string")
1034
- return parsed;
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`);
1035
1084
  }
1036
- catch {
1037
- // Report the unparsed value below so the API can validate it where applicable.
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`);
1038
1099
  }
1039
1100
  }
1040
- if (value.startsWith("'") && value.endsWith("'")) {
1041
- return value.slice(1, -1).replace(/''/gu, "'");
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 }) };
1106
+ }
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");
1042
1112
  }
1043
- return value;
1113
+ return record.authors.map((author, index) => parseMarkdownRelationship(author, `frontmatter.authors[${index}]`));
1044
1114
  }
1045
- function parseFrontmatterTags(value) {
1046
- const trimmed = value.trim();
1047
- const items = trimmed.startsWith("[") && trimmed.endsWith("]")
1048
- ? trimmed.slice(1, -1).split(",")
1049
- : trimmed.split(",");
1050
- return items.map((item) => parseFrontmatterScalar(item.trim())).filter((item) => item !== "");
1051
- }
1052
- function readRequiredFrontmatterString(frontmatter, key) {
1053
- const value = readOptionalFrontmatterString(frontmatter, key);
1054
- if (value === undefined)
1055
- throw createInvalidUsageError(`Markdown frontmatter requires ${key}`);
1056
- return value;
1115
+ function readMarkdownPublishedAt(record) {
1116
+ const publishedAt = readMarkdownOptionalString(record, "publishedAt", "frontmatter") ??
1117
+ readMarkdownOptionalString(record, "date", "frontmatter");
1118
+ return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
1057
1119
  }
1058
- function readOptionalFrontmatterString(frontmatter, key) {
1059
- const value = frontmatter.get(key);
1060
- return typeof value === "string" && value.trim() !== "" ? value : undefined;
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);
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");
1061
1173
  }
1062
- function readFrontmatterTags(frontmatter) {
1063
- const value = frontmatter.get("tags");
1064
- return Array.isArray(value) ? value : undefined;
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"}`);
1178
+ }
1179
+ return matches[0];
1065
1180
  }
1066
- function readImportPublishedAt(frontmatter) {
1067
- const publishedAt = readOptionalFrontmatterString(frontmatter, "publishedAt") ??
1068
- readOptionalFrontmatterString(frontmatter, "date");
1069
- return publishedAt === undefined ? undefined : normalizeCliPublishedAt(publishedAt);
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);
1185
+ }
1186
+ const url = canonicalizeImportUrl(input.url);
1187
+ return records.filter((record) => record.url !== undefined && record.url !== null && canonicalizeImportUrl(record.url) === url);
1188
+ }
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();
1195
+ }
1196
+ function normalizeImportName(value) {
1197
+ return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
1198
+ }
1199
+ function mergeIds(existing, added) {
1200
+ return [...new Set([...existing, ...added])];
1201
+ }
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
+ }
1070
1212
  }
1071
1213
  async function uploadPostImportBanner(api, post, bannerPath) {
1072
1214
  const file = await readMediaUploadFile(bannerPath);
@@ -1080,10 +1222,7 @@ async function uploadPostImportBanner(api, post, bannerPath) {
1080
1222
  path: "/media",
1081
1223
  body,
1082
1224
  });
1083
- return response.data.url;
1084
- }
1085
- function createBannerContent(post, bannerUrl) {
1086
- return `![${post.title ?? post.slug} banner](${bannerUrl})\n\n${post.content}`;
1225
+ return new URL(response.data.url, api.apiBaseUrl).toString();
1087
1226
  }
1088
1227
  async function findImportedPost(client, slug) {
1089
1228
  try {
@@ -1140,6 +1279,7 @@ async function runPostsCreateCommand(context, args) {
1140
1279
  "title",
1141
1280
  "url",
1142
1281
  "excerpt",
1282
+ "banner-url",
1143
1283
  "tag",
1144
1284
  "source-id",
1145
1285
  "credit-contact-id",
@@ -1167,6 +1307,7 @@ async function runPostsEditCommand(context, args) {
1167
1307
  "title",
1168
1308
  "url",
1169
1309
  "excerpt",
1310
+ "banner-url",
1170
1311
  "tag",
1171
1312
  "source-id",
1172
1313
  "credit-contact-id",
@@ -1253,6 +1394,7 @@ function createPostMutationInput(options, requireCreateFields) {
1253
1394
  copyStringOption(options, input, "content", "content");
1254
1395
  copyStringOption(options, input, "url", "url");
1255
1396
  copyStringOption(options, input, "excerpt", "excerpt");
1397
+ copyStringOption(options, input, "banner-url", "bannerUrl");
1256
1398
  copyNumberOption(options, input, "source-id", "sourceId");
1257
1399
  if (typeof options["published-at"] === "string") {
1258
1400
  input.publishedAt = normalizeCliPublishedAt(options["published-at"]);
@@ -1349,6 +1491,7 @@ function writePost(writer, post) {
1349
1491
  writer.stdout(`slug: ${post.slug}`);
1350
1492
  writer.stdout(`type: ${post.type}`);
1351
1493
  writer.stdout(`title: ${post.title ?? ""}`);
1494
+ writer.stdout(`bannerUrl: ${post.bannerUrl ?? ""}`);
1352
1495
  writer.stdout(`publishedAt: ${post.publishedAt ?? "draft"}`);
1353
1496
  }
1354
1497
  function writePostMutationOutput(writer, response, json) {
@@ -1667,12 +1810,14 @@ async function runSourcesCommand(context, args) {
1667
1810
  return runSourcesShowCommand(context, args.slice(1));
1668
1811
  case "create":
1669
1812
  return runSourcesCreateCommand(context, args.slice(1));
1813
+ case "import":
1814
+ return runSourcesImportCommand(context, args.slice(1));
1670
1815
  case "edit":
1671
1816
  return runSourcesEditCommand(context, args.slice(1));
1672
1817
  case "delete":
1673
1818
  return runSourcesDeleteCommand(context, args.slice(1));
1674
1819
  default:
1675
- throw createInvalidUsageError("Usage: slug sources <list|show|create|edit|delete>");
1820
+ throw createInvalidUsageError("Usage: slug sources <list|show|create|import|edit|delete>");
1676
1821
  }
1677
1822
  }
1678
1823
  async function runSourcesListCommand(context, args) {
@@ -1714,6 +1859,250 @@ async function runSourcesCreateCommand(context, args) {
1714
1859
  writeSourceMutationOutput(context.writer, response, parsed.json);
1715
1860
  return { exitCode: ExitCode.Ok };
1716
1861
  }
1862
+ async function runSourcesImportCommand(context, args) {
1863
+ const { filePath, json } = readSourceImportArgs(args);
1864
+ const sources = await readSourceImportFile(filePath);
1865
+ const api = await createConfiguredApiContext(context);
1866
+ const result = {
1867
+ sources: [],
1868
+ totals: { sources: 0, contacts: 0, accounts: 0 },
1869
+ };
1870
+ writeMutationTarget(context.writer, api.apiBaseUrl, json);
1871
+ for (const [sourceIndex, source] of sources.entries()) {
1872
+ result.sources.push(await importSource(api.client, source, sourceIndex, result.totals));
1873
+ }
1874
+ if (json) {
1875
+ writeJson(context.writer, { data: result });
1876
+ }
1877
+ else {
1878
+ writeSourceImportSummary(context.writer, result.totals);
1879
+ }
1880
+ return { exitCode: ExitCode.Ok };
1881
+ }
1882
+ function readSourceImportArgs(args) {
1883
+ const json = args.includes("--json");
1884
+ const positional = args.filter((arg) => arg !== "--json");
1885
+ if (positional.length !== 1 || positional[0] === undefined || positional[0].startsWith("--")) {
1886
+ throw createInvalidUsageError("Usage: slug sources import <file.yaml> [--json]");
1887
+ }
1888
+ return { filePath: positional[0], json };
1889
+ }
1890
+ async function readSourceImportFile(filePath) {
1891
+ let content;
1892
+ try {
1893
+ content = await readFile(filePath, "utf8");
1894
+ }
1895
+ catch {
1896
+ throw createInvalidUsageError(`Cannot read source import file: ${filePath}`);
1897
+ }
1898
+ let document;
1899
+ try {
1900
+ document = parseYaml(content);
1901
+ }
1902
+ catch (error) {
1903
+ const message = error instanceof Error ? error.message : "Unable to parse YAML";
1904
+ throw createInvalidUsageError(`Invalid source import YAML: ${message}`);
1905
+ }
1906
+ return parseSourceImportDocument(document);
1907
+ }
1908
+ function parseSourceImportDocument(value) {
1909
+ const root = readSourceImportRecord(value, "document", ["sources"]);
1910
+ if (!Array.isArray(root.sources) || root.sources.length === 0) {
1911
+ throw createInvalidUsageError("sources must be a non-empty array");
1912
+ }
1913
+ return root.sources.map((source, index) => parseSourceImportSource(source, `sources[${index}]`));
1914
+ }
1915
+ function parseSourceImportSource(value, path) {
1916
+ const record = readSourceImportRecord(value, path, [
1917
+ "name",
1918
+ "url",
1919
+ "description",
1920
+ "imageUrl",
1921
+ "faviconUrl",
1922
+ "contacts",
1923
+ "accounts",
1924
+ ]);
1925
+ return {
1926
+ name: readSourceImportRequiredString(record, "name", path),
1927
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1928
+ ...readSourceImportOptionalString(record, "description", path),
1929
+ ...readSourceImportOptionalUrl(record, "imageUrl", path, ["http:", "https:"]),
1930
+ ...readSourceImportOptionalUrl(record, "faviconUrl", path, ["http:", "https:"]),
1931
+ contacts: readSourceImportArray(record, "contacts", path, parseSourceImportContact),
1932
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1933
+ };
1934
+ }
1935
+ function parseSourceImportContact(value, path) {
1936
+ const record = readSourceImportRecord(value, path, ["name", "url", "accounts"]);
1937
+ return {
1938
+ name: readSourceImportRequiredString(record, "name", path),
1939
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1940
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1941
+ };
1942
+ }
1943
+ function parseSourceImportAccount(value, path) {
1944
+ const record = readSourceImportRecord(value, path, [
1945
+ "label",
1946
+ "url",
1947
+ "avatarUrl",
1948
+ "kind",
1949
+ "protocol",
1950
+ "default",
1951
+ "sortOrder",
1952
+ ]);
1953
+ const account = {
1954
+ label: readSourceImportRequiredString(record, "label", path),
1955
+ url: readSourceImportRequiredUrl(record, "url", path, ["http:", "https:", "mailto:"]),
1956
+ ...readSourceImportOptionalUrl(record, "avatarUrl", path, ["http:", "https:"]),
1957
+ ...readSourceImportOptionalString(record, "kind", path),
1958
+ ...readSourceImportOptionalString(record, "protocol", path),
1959
+ };
1960
+ if ("default" in record)
1961
+ account.default = readSourceImportBoolean(record, "default", path);
1962
+ if ("sortOrder" in record)
1963
+ account.sortOrder = readSourceImportInteger(record, "sortOrder", path);
1964
+ return account;
1965
+ }
1966
+ function readSourceImportRecord(value, path, allowedFields) {
1967
+ if (!isRecord(value))
1968
+ throw createInvalidUsageError(`${path} must be an object`);
1969
+ for (const key of Object.keys(value)) {
1970
+ if (!allowedFields.includes(key)) {
1971
+ throw createInvalidUsageError(`${path}.${key} is not supported`);
1972
+ }
1973
+ }
1974
+ return value;
1975
+ }
1976
+ function readSourceImportRequiredString(record, key, path) {
1977
+ const value = record[key];
1978
+ if (typeof value !== "string" || value.trim() === "") {
1979
+ throw createInvalidUsageError(`${path}.${key} must be a non-empty string`);
1980
+ }
1981
+ return value;
1982
+ }
1983
+ function readSourceImportOptionalString(record, key, path) {
1984
+ if (!(key in record))
1985
+ return {};
1986
+ if (typeof record[key] !== "string") {
1987
+ throw createInvalidUsageError(`${path}.${key} must be a string`);
1988
+ }
1989
+ return { [key]: record[key] };
1990
+ }
1991
+ function readSourceImportRequiredUrl(record, key, path, protocols) {
1992
+ const value = readSourceImportRequiredString(record, key, path);
1993
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
1994
+ return value;
1995
+ }
1996
+ function readSourceImportOptionalUrl(record, key, path, protocols) {
1997
+ if (!(key in record))
1998
+ return {};
1999
+ const value = readSourceImportRequiredString(record, key, path);
2000
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
2001
+ return { [key]: value };
2002
+ }
2003
+ function assertSourceImportUrl(value, path, protocols) {
2004
+ try {
2005
+ const url = new URL(value);
2006
+ if (!protocols.includes(url.protocol))
2007
+ throw new Error("Unsupported protocol");
2008
+ }
2009
+ catch {
2010
+ throw createInvalidUsageError(`${path} must be a valid ${protocols.join(" or ")} URL`);
2011
+ }
2012
+ }
2013
+ function readSourceImportArray(record, key, path, parseItem) {
2014
+ if (!(key in record))
2015
+ return [];
2016
+ const value = record[key];
2017
+ if (!Array.isArray(value))
2018
+ throw createInvalidUsageError(`${path}.${key} must be an array`);
2019
+ return value.map((item, index) => parseItem(item, `${path}.${key}[${index}]`));
2020
+ }
2021
+ function readSourceImportBoolean(record, key, path) {
2022
+ if (typeof record[key] !== "boolean") {
2023
+ throw createInvalidUsageError(`${path}.${key} must be a boolean`);
2024
+ }
2025
+ return record[key];
2026
+ }
2027
+ function readSourceImportInteger(record, key, path) {
2028
+ if (typeof record[key] !== "number" || !Number.isInteger(record[key])) {
2029
+ throw createInvalidUsageError(`${path}.${key} must be an integer`);
2030
+ }
2031
+ return record[key];
2032
+ }
2033
+ async function importSource(client, source, sourceIndex, totals) {
2034
+ const contacts = [];
2035
+ for (const [contactIndex, contact] of source.contacts.entries()) {
2036
+ const response = await requestSourceImport(client, "/contacts", { name: contact.name, ...(contact.url === undefined ? {} : { url: contact.url }) }, `sources[${sourceIndex}].contacts[${contactIndex}]`);
2037
+ contacts.push({ input: contact, document: response.data, accountIds: [] });
2038
+ totals.contacts += 1;
2039
+ }
2040
+ const sourceResponse = await requestSourceImport(client, "/sources", createImportedSourceBody(source, contacts.map((contact) => contact.document.id)), `sources[${sourceIndex}]`);
2041
+ totals.sources += 1;
2042
+ for (const [contactIndex, contact] of contacts.entries()) {
2043
+ contact.accountIds.push(...(await importAccounts(client, contact.input.accounts, "contact", contact.document.id, `sources[${sourceIndex}].contacts[${contactIndex}].accounts`, totals)));
2044
+ }
2045
+ const accountIds = await importAccounts(client, source.accounts, "source", sourceResponse.data.id, `sources[${sourceIndex}].accounts`, totals);
2046
+ return {
2047
+ id: sourceResponse.data.id,
2048
+ name: sourceResponse.data.name,
2049
+ contacts: contacts.map((contact) => ({
2050
+ id: contact.document.id,
2051
+ name: contact.document.name,
2052
+ accountIds: contact.accountIds,
2053
+ })),
2054
+ accountIds,
2055
+ };
2056
+ }
2057
+ function createImportedSourceBody(source, contactIds) {
2058
+ return {
2059
+ name: source.name,
2060
+ ...(source.url === undefined ? {} : { url: source.url }),
2061
+ ...(source.description === undefined ? {} : { description: source.description }),
2062
+ ...(source.imageUrl === undefined ? {} : { imageUrl: source.imageUrl }),
2063
+ ...(source.faviconUrl === undefined ? {} : { faviconUrl: source.faviconUrl }),
2064
+ ...(contactIds.length === 0 ? {} : { contactIds }),
2065
+ };
2066
+ }
2067
+ async function importAccounts(client, accounts, ownerType, ownerId, path, totals) {
2068
+ const ids = [];
2069
+ for (const [index, account] of accounts.entries()) {
2070
+ const response = await requestSourceImport(client, "/accounts", createImportedAccountBody(account, ownerType, ownerId), `${path}[${index}]`);
2071
+ ids.push(response.data.id);
2072
+ totals.accounts += 1;
2073
+ }
2074
+ return ids;
2075
+ }
2076
+ function createImportedAccountBody(account, ownerType, ownerId) {
2077
+ return {
2078
+ ownerType,
2079
+ ownerId,
2080
+ label: account.label,
2081
+ url: account.url,
2082
+ ...(account.avatarUrl === undefined ? {} : { avatarUrl: account.avatarUrl }),
2083
+ ...(account.kind === undefined ? {} : { kind: account.kind }),
2084
+ ...(account.protocol === undefined ? {} : { protocol: account.protocol }),
2085
+ ...(account.default === undefined ? {} : { isDefault: account.default }),
2086
+ ...(account.sortOrder === undefined ? {} : { sortOrder: account.sortOrder }),
2087
+ };
2088
+ }
2089
+ async function requestSourceImport(client, path, body, importPath) {
2090
+ try {
2091
+ return await client.requestJson({ method: "POST", path, body });
2092
+ }
2093
+ catch (error) {
2094
+ if (error instanceof CliError) {
2095
+ throw new CliError(`Failed to import ${importPath}: ${error.message}`, error.exitCode, error.status);
2096
+ }
2097
+ throw error;
2098
+ }
2099
+ }
2100
+ function writeSourceImportSummary(writer, totals) {
2101
+ writer.stdout(`Imported ${formatImportCount(totals.sources, "source")}, ${formatImportCount(totals.contacts, "contact")}, and ${formatImportCount(totals.accounts, "account")}.`);
2102
+ }
2103
+ function formatImportCount(count, label) {
2104
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
2105
+ }
1717
2106
  async function runSourcesEditCommand(context, args) {
1718
2107
  const idArg = args[0];
1719
2108
  if (idArg === undefined || idArg.startsWith("--")) {
@@ -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.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,8 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@evcraddock/slug-core": "0.1.1",
30
- "adm-zip": "^0.5.18"
30
+ "adm-zip": "^0.5.18",
31
+ "cheerio": "^1.2.0",
32
+ "yaml": "^2.9.0"
31
33
  }
32
34
  }