@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 +73 -2
- package/dist/commands.js +470 -81
- package/dist/link-metadata.d.ts +18 -0
- package/dist/link-metadata.js +234 -0
- package/package.json +4 -2
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,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
|
|
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
|
-
|
|
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
|
|
947
|
+
const uploadedBannerUrl = post.bannerPath === undefined
|
|
928
948
|
? undefined
|
|
929
949
|
: await uploadPostImportBanner(api, post, post.bannerPath);
|
|
930
|
-
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);
|
|
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 =
|
|
979
|
-
const slug =
|
|
980
|
-
const 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("
|
|
1007
|
+
throw createInvalidUsageError("frontmatter.type must be article, link, or note");
|
|
983
1008
|
}
|
|
984
|
-
const title =
|
|
1009
|
+
const title = readMarkdownOptionalString(frontmatter, "title", "frontmatter");
|
|
985
1010
|
if (type === "article" && title === undefined) {
|
|
986
|
-
throw createInvalidUsageError("Article imports require frontmatter
|
|
987
|
-
}
|
|
988
|
-
const
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
...(
|
|
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
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
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
|
|
1113
|
+
return record.authors.map((author, index) => parseMarkdownRelationship(author, `frontmatter.authors[${index}]`));
|
|
1044
1114
|
}
|
|
1045
|
-
function
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
|
1063
|
-
const
|
|
1064
|
-
|
|
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
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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 `\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.
|
|
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
|
}
|