@evcraddock/slug-cli 0.7.0 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +27 -0
  2. package/dist/commands.js +267 -20
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -75,6 +75,33 @@ The imported body.
75
75
 
76
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.
77
77
 
78
+ ## Source imports
79
+
80
+ 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`.
81
+
82
+ ```yaml
83
+ sources:
84
+ - name: Example Blog
85
+ url: https://example.com
86
+ description: Example writing
87
+ accounts:
88
+ - label: Feed
89
+ url: https://example.com/feed.xml
90
+ kind: feed
91
+ protocol: rss
92
+ default: true
93
+ contacts:
94
+ - name: Jane Example
95
+ url: https://example.com/jane
96
+ accounts:
97
+ - label: Mastodon
98
+ url: https://social.example/@jane
99
+ kind: social
100
+ protocol: activitypub
101
+ ```
102
+
103
+ 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.
104
+
78
105
  Use `slug tag list` to list tags and post usage counts through the Slugkit tags API.
79
106
 
80
107
  Example config:
package/dist/commands.js CHANGED
@@ -2,6 +2,7 @@ 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";
@@ -36,6 +37,7 @@ Usage:
36
37
  slug [--config <file>] sources list [--json]
37
38
  slug [--config <file>] sources show <id> [--json]
38
39
  slug [--config <file>] sources create --name <name> [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
40
+ slug [--config <file>] sources import <file.yaml> [--json]
39
41
  slug [--config <file>] sources edit <id> [--name <name>] [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
40
42
  slug [--config <file>] sources delete <id> [--json]
41
43
  slug [--config <file>] accounts list [--owner-type contact|source|site] [--owner-id <id>] [--json]
@@ -344,7 +346,7 @@ function shouldExcludeTemplateEntry(relativePath, name) {
344
346
  parts.includes("data") ||
345
347
  parts.includes("coverage") ||
346
348
  relativePath === "src/__tests__/dev-environment.test.ts" ||
347
- relativePath === "src/federation/__tests__/follow.test.ts" ||
349
+ relativePath === "src/federation/__tests__/follow.integration.test.ts" ||
348
350
  relativePath === ".env" ||
349
351
  name.endsWith(".log") ||
350
352
  name.endsWith(".tsbuildinfo"));
@@ -363,25 +365,24 @@ async function replaceGeneratedSitePlaceholders(directory, options) {
363
365
  `intro: ${JSON.stringify(`${options.siteTitle} is ready for posts, feeds, and federation.`)}`,
364
366
  ],
365
367
  ]);
366
- await replaceTextFile(join(directory, "README.md"), [
367
- ["# Slugkit Template Site", `# ${options.siteTitle}`],
368
- [
369
- "This is the in-repo Slugkit template website. It is toolkit source used for local CLI/API development and for `slug init`; it is not itself the deployable website you hand to a site owner. Generated sites should contain this website code without the `slug` CLI source code.\n\nInitialized sites are standalone Slugkit-compatible websites. After `slug init`, the generated site owns its code and may customize routes, templates, styles, assets, and deployment freely while preserving the Slugkit API contract that the `slug` CLI uses.",
370
- "This is a standalone Slugkit-compatible website generated from the Slugkit template. This site owns its code and can customize routes, templates, styles, assets, and deployment freely while preserving the Slugkit API contract that the `slug` CLI uses.",
371
- ],
372
- [
373
- "Local toolkit development can run `make garage-setup` from the Slugkit repository root after `make dev` starts Garage. Standalone initialized sites should configure these values for their own local or production S3-compatible service before uploading media.",
374
- "Configure these values for your own local or production S3-compatible service before uploading media.",
375
- ],
376
- [
377
- "## In-repository development\n\nWhen working inside the Slugkit toolkit repository, start the full local dev environment from the repository root:\n\n```bash\nmake dev\n```\n\nFrom `template/site`, you can also run the app and Tailwind watcher directly in separate terminals:\n\n```bash\nnpm run dev\nnpm run css:watch\n```\n\nRepository-root workspace commands are useful while developing the template itself:\n\n```bash\nnpm run db:migrate --workspace @slugkit/template-site\nnpm run db:status --workspace @slugkit/template-site\nnpm run test --workspace @slugkit/template-site\n```\n\nStandalone initialized sites should use the non-workspace commands shown in the quick start section.",
378
- "## Local development\n\nInstall Overmind if it is not already available, then run the app and Tailwind watcher together from this site directory:\n\n```bash\nmake dev\n```\n\nThe generated `Procfile.dev` runs `npm run dev` for the Hono app and `npm run css:watch` for Tailwind. Use the non-workspace commands shown in the quick start section for migrations and local checks.",
379
- ],
380
- [
381
- "## Related specs\n\n- [Web Spec 08: Template Setup Documentation](../../docs/web-specs/08-template-setup-documentation.md)\n- [CLI Spec 12: Site Init Command](../../docs/cli-specs/12-site-init-command.md)\n- [CLI Specs](../../docs/cli-specs/README.md)\n- [API Route Specs](../../docs/specs/README.md)",
382
- "## Related Slugkit references\n\n- Slugkit API documentation is available from a running site at `/api/v1/docs`.\n- The CLI compatibility check is `slug doctor`.\n- The generated site marker is `.slugkit-site.json`.",
383
- ],
368
+ await writeGeneratedSiteReadme(directory, options);
369
+ }
370
+ async function writeGeneratedSiteReadme(directory, options) {
371
+ const sourcePath = join(directory, "README.generated.md");
372
+ const source = await readFile(sourcePath, "utf8");
373
+ const replacements = new Map([
374
+ ["{{SITE_NAME}}", options.name],
375
+ ["{{SITE_TITLE}}", options.siteTitle],
384
376
  ]);
377
+ let content = source;
378
+ for (const [placeholder, value] of replacements) {
379
+ if (!content.includes(placeholder)) {
380
+ throw createInvalidUsageError(`Generated README is missing placeholder: ${placeholder}`);
381
+ }
382
+ content = content.replaceAll(placeholder, value);
383
+ }
384
+ await writeFile(join(directory, "README.md"), content, "utf8");
385
+ await rm(sourcePath);
385
386
  }
386
387
  function replaceGeneratedSiteWorkspaceDependencies(value) {
387
388
  if (!isRecord(value))
@@ -1668,12 +1669,14 @@ async function runSourcesCommand(context, args) {
1668
1669
  return runSourcesShowCommand(context, args.slice(1));
1669
1670
  case "create":
1670
1671
  return runSourcesCreateCommand(context, args.slice(1));
1672
+ case "import":
1673
+ return runSourcesImportCommand(context, args.slice(1));
1671
1674
  case "edit":
1672
1675
  return runSourcesEditCommand(context, args.slice(1));
1673
1676
  case "delete":
1674
1677
  return runSourcesDeleteCommand(context, args.slice(1));
1675
1678
  default:
1676
- throw createInvalidUsageError("Usage: slug sources <list|show|create|edit|delete>");
1679
+ throw createInvalidUsageError("Usage: slug sources <list|show|create|import|edit|delete>");
1677
1680
  }
1678
1681
  }
1679
1682
  async function runSourcesListCommand(context, args) {
@@ -1715,6 +1718,250 @@ async function runSourcesCreateCommand(context, args) {
1715
1718
  writeSourceMutationOutput(context.writer, response, parsed.json);
1716
1719
  return { exitCode: ExitCode.Ok };
1717
1720
  }
1721
+ async function runSourcesImportCommand(context, args) {
1722
+ const { filePath, json } = readSourceImportArgs(args);
1723
+ const sources = await readSourceImportFile(filePath);
1724
+ const api = await createConfiguredApiContext(context);
1725
+ const result = {
1726
+ sources: [],
1727
+ totals: { sources: 0, contacts: 0, accounts: 0 },
1728
+ };
1729
+ writeMutationTarget(context.writer, api.apiBaseUrl, json);
1730
+ for (const [sourceIndex, source] of sources.entries()) {
1731
+ result.sources.push(await importSource(api.client, source, sourceIndex, result.totals));
1732
+ }
1733
+ if (json) {
1734
+ writeJson(context.writer, { data: result });
1735
+ }
1736
+ else {
1737
+ writeSourceImportSummary(context.writer, result.totals);
1738
+ }
1739
+ return { exitCode: ExitCode.Ok };
1740
+ }
1741
+ function readSourceImportArgs(args) {
1742
+ const json = args.includes("--json");
1743
+ const positional = args.filter((arg) => arg !== "--json");
1744
+ if (positional.length !== 1 || positional[0] === undefined || positional[0].startsWith("--")) {
1745
+ throw createInvalidUsageError("Usage: slug sources import <file.yaml> [--json]");
1746
+ }
1747
+ return { filePath: positional[0], json };
1748
+ }
1749
+ async function readSourceImportFile(filePath) {
1750
+ let content;
1751
+ try {
1752
+ content = await readFile(filePath, "utf8");
1753
+ }
1754
+ catch {
1755
+ throw createInvalidUsageError(`Cannot read source import file: ${filePath}`);
1756
+ }
1757
+ let document;
1758
+ try {
1759
+ document = parseYaml(content);
1760
+ }
1761
+ catch (error) {
1762
+ const message = error instanceof Error ? error.message : "Unable to parse YAML";
1763
+ throw createInvalidUsageError(`Invalid source import YAML: ${message}`);
1764
+ }
1765
+ return parseSourceImportDocument(document);
1766
+ }
1767
+ function parseSourceImportDocument(value) {
1768
+ const root = readSourceImportRecord(value, "document", ["sources"]);
1769
+ if (!Array.isArray(root.sources) || root.sources.length === 0) {
1770
+ throw createInvalidUsageError("sources must be a non-empty array");
1771
+ }
1772
+ return root.sources.map((source, index) => parseSourceImportSource(source, `sources[${index}]`));
1773
+ }
1774
+ function parseSourceImportSource(value, path) {
1775
+ const record = readSourceImportRecord(value, path, [
1776
+ "name",
1777
+ "url",
1778
+ "description",
1779
+ "imageUrl",
1780
+ "faviconUrl",
1781
+ "contacts",
1782
+ "accounts",
1783
+ ]);
1784
+ return {
1785
+ name: readSourceImportRequiredString(record, "name", path),
1786
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1787
+ ...readSourceImportOptionalString(record, "description", path),
1788
+ ...readSourceImportOptionalUrl(record, "imageUrl", path, ["http:", "https:"]),
1789
+ ...readSourceImportOptionalUrl(record, "faviconUrl", path, ["http:", "https:"]),
1790
+ contacts: readSourceImportArray(record, "contacts", path, parseSourceImportContact),
1791
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1792
+ };
1793
+ }
1794
+ function parseSourceImportContact(value, path) {
1795
+ const record = readSourceImportRecord(value, path, ["name", "url", "accounts"]);
1796
+ return {
1797
+ name: readSourceImportRequiredString(record, "name", path),
1798
+ ...readSourceImportOptionalUrl(record, "url", path, ["http:", "https:"]),
1799
+ accounts: readSourceImportArray(record, "accounts", path, parseSourceImportAccount),
1800
+ };
1801
+ }
1802
+ function parseSourceImportAccount(value, path) {
1803
+ const record = readSourceImportRecord(value, path, [
1804
+ "label",
1805
+ "url",
1806
+ "avatarUrl",
1807
+ "kind",
1808
+ "protocol",
1809
+ "default",
1810
+ "sortOrder",
1811
+ ]);
1812
+ const account = {
1813
+ label: readSourceImportRequiredString(record, "label", path),
1814
+ url: readSourceImportRequiredUrl(record, "url", path, ["http:", "https:", "mailto:"]),
1815
+ ...readSourceImportOptionalUrl(record, "avatarUrl", path, ["http:", "https:"]),
1816
+ ...readSourceImportOptionalString(record, "kind", path),
1817
+ ...readSourceImportOptionalString(record, "protocol", path),
1818
+ };
1819
+ if ("default" in record)
1820
+ account.default = readSourceImportBoolean(record, "default", path);
1821
+ if ("sortOrder" in record)
1822
+ account.sortOrder = readSourceImportInteger(record, "sortOrder", path);
1823
+ return account;
1824
+ }
1825
+ function readSourceImportRecord(value, path, allowedFields) {
1826
+ if (!isRecord(value))
1827
+ throw createInvalidUsageError(`${path} must be an object`);
1828
+ for (const key of Object.keys(value)) {
1829
+ if (!allowedFields.includes(key)) {
1830
+ throw createInvalidUsageError(`${path}.${key} is not supported`);
1831
+ }
1832
+ }
1833
+ return value;
1834
+ }
1835
+ function readSourceImportRequiredString(record, key, path) {
1836
+ const value = record[key];
1837
+ if (typeof value !== "string" || value.trim() === "") {
1838
+ throw createInvalidUsageError(`${path}.${key} must be a non-empty string`);
1839
+ }
1840
+ return value;
1841
+ }
1842
+ function readSourceImportOptionalString(record, key, path) {
1843
+ if (!(key in record))
1844
+ return {};
1845
+ if (typeof record[key] !== "string") {
1846
+ throw createInvalidUsageError(`${path}.${key} must be a string`);
1847
+ }
1848
+ return { [key]: record[key] };
1849
+ }
1850
+ function readSourceImportRequiredUrl(record, key, path, protocols) {
1851
+ const value = readSourceImportRequiredString(record, key, path);
1852
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
1853
+ return value;
1854
+ }
1855
+ function readSourceImportOptionalUrl(record, key, path, protocols) {
1856
+ if (!(key in record))
1857
+ return {};
1858
+ const value = readSourceImportRequiredString(record, key, path);
1859
+ assertSourceImportUrl(value, `${path}.${key}`, protocols);
1860
+ return { [key]: value };
1861
+ }
1862
+ function assertSourceImportUrl(value, path, protocols) {
1863
+ try {
1864
+ const url = new URL(value);
1865
+ if (!protocols.includes(url.protocol))
1866
+ throw new Error("Unsupported protocol");
1867
+ }
1868
+ catch {
1869
+ throw createInvalidUsageError(`${path} must be a valid ${protocols.join(" or ")} URL`);
1870
+ }
1871
+ }
1872
+ function readSourceImportArray(record, key, path, parseItem) {
1873
+ if (!(key in record))
1874
+ return [];
1875
+ const value = record[key];
1876
+ if (!Array.isArray(value))
1877
+ throw createInvalidUsageError(`${path}.${key} must be an array`);
1878
+ return value.map((item, index) => parseItem(item, `${path}.${key}[${index}]`));
1879
+ }
1880
+ function readSourceImportBoolean(record, key, path) {
1881
+ if (typeof record[key] !== "boolean") {
1882
+ throw createInvalidUsageError(`${path}.${key} must be a boolean`);
1883
+ }
1884
+ return record[key];
1885
+ }
1886
+ function readSourceImportInteger(record, key, path) {
1887
+ if (typeof record[key] !== "number" || !Number.isInteger(record[key])) {
1888
+ throw createInvalidUsageError(`${path}.${key} must be an integer`);
1889
+ }
1890
+ return record[key];
1891
+ }
1892
+ async function importSource(client, source, sourceIndex, totals) {
1893
+ const contacts = [];
1894
+ for (const [contactIndex, contact] of source.contacts.entries()) {
1895
+ const response = await requestSourceImport(client, "/contacts", { name: contact.name, ...(contact.url === undefined ? {} : { url: contact.url }) }, `sources[${sourceIndex}].contacts[${contactIndex}]`);
1896
+ contacts.push({ input: contact, document: response.data, accountIds: [] });
1897
+ totals.contacts += 1;
1898
+ }
1899
+ const sourceResponse = await requestSourceImport(client, "/sources", createImportedSourceBody(source, contacts.map((contact) => contact.document.id)), `sources[${sourceIndex}]`);
1900
+ totals.sources += 1;
1901
+ for (const [contactIndex, contact] of contacts.entries()) {
1902
+ contact.accountIds.push(...(await importAccounts(client, contact.input.accounts, "contact", contact.document.id, `sources[${sourceIndex}].contacts[${contactIndex}].accounts`, totals)));
1903
+ }
1904
+ const accountIds = await importAccounts(client, source.accounts, "source", sourceResponse.data.id, `sources[${sourceIndex}].accounts`, totals);
1905
+ return {
1906
+ id: sourceResponse.data.id,
1907
+ name: sourceResponse.data.name,
1908
+ contacts: contacts.map((contact) => ({
1909
+ id: contact.document.id,
1910
+ name: contact.document.name,
1911
+ accountIds: contact.accountIds,
1912
+ })),
1913
+ accountIds,
1914
+ };
1915
+ }
1916
+ function createImportedSourceBody(source, contactIds) {
1917
+ return {
1918
+ name: source.name,
1919
+ ...(source.url === undefined ? {} : { url: source.url }),
1920
+ ...(source.description === undefined ? {} : { description: source.description }),
1921
+ ...(source.imageUrl === undefined ? {} : { imageUrl: source.imageUrl }),
1922
+ ...(source.faviconUrl === undefined ? {} : { faviconUrl: source.faviconUrl }),
1923
+ ...(contactIds.length === 0 ? {} : { contactIds }),
1924
+ };
1925
+ }
1926
+ async function importAccounts(client, accounts, ownerType, ownerId, path, totals) {
1927
+ const ids = [];
1928
+ for (const [index, account] of accounts.entries()) {
1929
+ const response = await requestSourceImport(client, "/accounts", createImportedAccountBody(account, ownerType, ownerId), `${path}[${index}]`);
1930
+ ids.push(response.data.id);
1931
+ totals.accounts += 1;
1932
+ }
1933
+ return ids;
1934
+ }
1935
+ function createImportedAccountBody(account, ownerType, ownerId) {
1936
+ return {
1937
+ ownerType,
1938
+ ownerId,
1939
+ label: account.label,
1940
+ url: account.url,
1941
+ ...(account.avatarUrl === undefined ? {} : { avatarUrl: account.avatarUrl }),
1942
+ ...(account.kind === undefined ? {} : { kind: account.kind }),
1943
+ ...(account.protocol === undefined ? {} : { protocol: account.protocol }),
1944
+ ...(account.default === undefined ? {} : { isDefault: account.default }),
1945
+ ...(account.sortOrder === undefined ? {} : { sortOrder: account.sortOrder }),
1946
+ };
1947
+ }
1948
+ async function requestSourceImport(client, path, body, importPath) {
1949
+ try {
1950
+ return await client.requestJson({ method: "POST", path, body });
1951
+ }
1952
+ catch (error) {
1953
+ if (error instanceof CliError) {
1954
+ throw new CliError(`Failed to import ${importPath}: ${error.message}`, error.exitCode, error.status);
1955
+ }
1956
+ throw error;
1957
+ }
1958
+ }
1959
+ function writeSourceImportSummary(writer, totals) {
1960
+ writer.stdout(`Imported ${formatImportCount(totals.sources, "source")}, ${formatImportCount(totals.contacts, "contact")}, and ${formatImportCount(totals.accounts, "account")}.`);
1961
+ }
1962
+ function formatImportCount(count, label) {
1963
+ return `${count} ${label}${count === 1 ? "" : "s"}`;
1964
+ }
1718
1965
  async function runSourcesEditCommand(context, args) {
1719
1966
  const idArg = args[0];
1720
1967
  if (idArg === undefined || idArg.startsWith("--")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evcraddock/slug-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Command-line tool for Slugkit sites.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -27,6 +27,7 @@
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
+ "yaml": "^2.9.0"
31
32
  }
32
33
  }