@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 +46 -2
- package/dist/commands.js +220 -79
- package/dist/link-metadata.d.ts +18 -0
- package/dist/link-metadata.js +234 -0
- package/package.json +2 -1
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
947
|
+
const uploadedBannerUrl = post.bannerPath === undefined
|
|
930
948
|
? undefined
|
|
931
949
|
: await uploadPostImportBanner(api, post, post.bannerPath);
|
|
932
|
-
const
|
|
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 =
|
|
981
|
-
const slug =
|
|
982
|
-
const 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("
|
|
1007
|
+
throw createInvalidUsageError("frontmatter.type must be article, link, or note");
|
|
985
1008
|
}
|
|
986
|
-
const title =
|
|
1009
|
+
const title = readMarkdownOptionalString(frontmatter, "title", "frontmatter");
|
|
987
1010
|
if (type === "article" && title === undefined) {
|
|
988
|
-
throw createInvalidUsageError("Article imports require frontmatter
|
|
989
|
-
}
|
|
990
|
-
const
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
...(
|
|
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
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
|
1032
|
-
if (
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
return
|
|
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
|
|
1061
|
-
|
|
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
|
|
1065
|
-
|
|
1066
|
-
return Array.isArray(value) ? value : undefined;
|
|
1199
|
+
function mergeIds(existing, added) {
|
|
1200
|
+
return [...new Set([...existing, ...added])];
|
|
1067
1201
|
}
|
|
1068
|
-
function
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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 `\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.
|
|
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
|
}
|