pageship 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Pageship CLI
2
2
 
3
- Pageship is an HTML publishing service in development. This CLI signs you in to the hosted Pageship service through a browser, publishes HTML files as documents behind stable links, and lists, downloads, restores and changes the visibility of the documents you published. It also installs the Pageship skills for your agents.
3
+ Pageship is an HTML publishing service in development. This CLI signs you in to the hosted Pageship service through a browser, publishes HTML files as documents behind stable links, and lists, downloads, restores and changes the visibility of the documents you published. It also reads documents other people shared with you, by their links. It also installs the Pageship skills for your agents.
4
4
 
5
5
  The full reference, with every command, option and result, is at https://pageship.dev/docs/cli.
6
6
 
package/dist/cli.mjs CHANGED
@@ -675,6 +675,18 @@ const documentsRestore = (options) => (options.client ?? client).post({
675
675
  ...options.headers
676
676
  }
677
677
  });
678
+ const installationsList = (options) => (options?.client ?? client).get({
679
+ url: "/api/v1/installations",
680
+ ...options
681
+ });
682
+ const installationsRevoke = (options) => (options.client ?? client).post({
683
+ url: "/api/v1/installations/revoke",
684
+ ...options,
685
+ headers: {
686
+ "Content-Type": "application/json",
687
+ ...options.headers
688
+ }
689
+ });
678
690
  const viewersList = (options) => (options.client ?? client).get({
679
691
  url: "/api/v1/documents/{id}/viewers",
680
692
  ...options
@@ -701,6 +713,23 @@ const workspaceDomainPattern = workspaceDomain.replaceAll(".", "\\.");
701
713
  const workspaceHostname = (subdomain) => `${subdomain}.${workspaceDomain}`;
702
714
  /** A valid workspace label: the subdomain of one workspace address, as a DNS label. */
703
715
  const workspaceLabelPattern = /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/u;
716
+ /**
717
+ * Service names that are never a workspace. Migration `0002_personal_subdomains.sql` reserves
718
+ * them in the address table, and readiness checks that the table still holds each one.
719
+ */
720
+ const reservedWorkspaceSubdomains = [
721
+ "www",
722
+ "api",
723
+ "app",
724
+ "auth",
725
+ "admin",
726
+ "dashboard",
727
+ "docs",
728
+ "status",
729
+ "support",
730
+ "mail",
731
+ "pageship"
732
+ ];
704
733
  //#endregion
705
734
  //#region ../../packages/api-client/src/address.ts
706
735
  /** Generated TypeScript does not validate remote JSON, so check an address before showing it. */
@@ -858,7 +887,11 @@ async function getDocumentRevisions(baseUrl, id, options = {}) {
858
887
  };
859
888
  throw new Error("Invalid document revision listing response.");
860
889
  }
861
- /** Exact owner-only bytes of the current revision, or of one selected historical revision. */
890
+ /**
891
+ * Exact bytes of the current revision, or of one selected historical revision. The owner reads
892
+ * any revision; another admitted account reads the current one of a public document, or of a
893
+ * private one it is a viewer of.
894
+ */
862
895
  async function getDocumentContent(baseUrl, id, options = {}) {
863
896
  const request = {
864
897
  ...requestOptions(baseUrl, options),
@@ -957,7 +990,21 @@ function isGitHubAccountId(value) {
957
990
  return typeof value === "string" && /^[1-9][0-9]{0,19}(?![\s\S])/u.test(value);
958
991
  }
959
992
  function validViewer(value) {
960
- return record(value) && isGitHubAccountId(value.githubAccountId) && isGitHubLogin(value.login) && (value.name === null || typeof value.name === "string") && typeof value.addedAt === "number" && Number.isSafeInteger(value.addedAt) && value.addedAt >= 0;
993
+ return record(value) && isGitHubAccountId(value.githubAccountId) && isGitHubLogin(value.login) && (value.name === null || typeof value.name === "string") && typeof value.addedAt === "number" && Number.isSafeInteger(value.addedAt) && value.addedAt >= 0 && typeof value.viewCount === "number" && Number.isSafeInteger(value.viewCount) && value.viewCount >= 0 && (value.lastViewedAt === null || typeof value.lastViewedAt === "number" && Number.isSafeInteger(value.lastViewedAt) && value.lastViewedAt >= 0);
994
+ }
995
+ function normalizeViewer(value) {
996
+ if (!record(value)) return;
997
+ const normalized = {
998
+ ...value,
999
+ ...value.viewCount === void 0 ? { viewCount: 0 } : {},
1000
+ ...value.lastViewedAt === void 0 ? { lastViewedAt: null } : {}
1001
+ };
1002
+ return validViewer(normalized) ? normalized : void 0;
1003
+ }
1004
+ function normalizeViewers(value) {
1005
+ if (!Array.isArray(value)) return;
1006
+ const viewers = value.map(normalizeViewer);
1007
+ return viewers.every((viewer) => viewer !== void 0) ? viewers : void 0;
961
1008
  }
962
1009
  function validUserMatch(value) {
963
1010
  return record(value) && isGitHubAccountId(value.githubAccountId) && isGitHubLogin(value.login);
@@ -997,7 +1044,11 @@ async function getDocumentViewers(baseUrl, id, options = {}) {
997
1044
  path: { id }
998
1045
  });
999
1046
  if (!response?.ok) viewerFailed(response, error);
1000
- if (data?.status === "listed" && Array.isArray(data.viewers) && data.viewers.every(validViewer)) return data;
1047
+ const viewers = data?.status === "listed" ? normalizeViewers(data.viewers) : void 0;
1048
+ if (data && viewers) return {
1049
+ ...data,
1050
+ viewers
1051
+ };
1001
1052
  throw new Error("Invalid document viewer listing response.");
1002
1053
  }
1003
1054
  /**
@@ -1019,14 +1070,17 @@ async function updateDocumentSharing(baseUrl, id, change, options = {}) {
1019
1070
  body: change
1020
1071
  });
1021
1072
  if (!response?.ok) viewerFailed(response, error);
1022
- const document = data?.status === "sharing_updated" ? normalizeDocument(data.document) : void 0;
1023
- if (data && document && (change.visibility === void 0 || document.visibility === change.visibility) && [
1024
- data.viewers,
1025
- data.added,
1026
- data.removed
1027
- ].every((list) => Array.isArray(list) && list.every(validViewer))) return {
1028
- ...data,
1029
- document
1073
+ const updated = data?.status === "sharing_updated" ? data : void 0;
1074
+ const document = updated && normalizeDocument(updated.document);
1075
+ const viewers = updated && normalizeViewers(updated.viewers);
1076
+ const added = updated && normalizeViewers(updated.added);
1077
+ const removed = updated && normalizeViewers(updated.removed);
1078
+ if (updated && document && viewers && added && removed && (change.visibility === void 0 || document.visibility === change.visibility)) return {
1079
+ ...updated,
1080
+ document,
1081
+ viewers,
1082
+ added,
1083
+ removed
1030
1084
  };
1031
1085
  throw new Error("Invalid document sharing response.");
1032
1086
  }
@@ -1040,6 +1094,31 @@ async function getGitHubUser(baseUrl, login, options = {}) {
1040
1094
  throw new Error("Invalid GitHub user response.");
1041
1095
  }
1042
1096
  //#endregion
1097
+ //#region ../../packages/api-client/src/installations.ts
1098
+ async function requestInstallations(baseUrl, installationId, { signal, token } = {}) {
1099
+ const timeout = AbortSignal.timeout(1e4);
1100
+ const options = {
1101
+ baseUrl,
1102
+ credentials: token ? "omit" : "include",
1103
+ headers: token ? { Authorization: `Bearer ${token}` } : void 0,
1104
+ redirect: "error",
1105
+ cache: "no-store",
1106
+ throwOnError: false,
1107
+ signal: signal ? AbortSignal.any([timeout, signal]) : timeout
1108
+ };
1109
+ const { data, response, error } = installationId === void 0 ? await installationsList(options) : await installationsRevoke({
1110
+ ...options,
1111
+ cache: "default",
1112
+ body: { installationId }
1113
+ });
1114
+ if (!response?.ok) throw new Error("Could not update CLI installations. Try again.", { cause: error });
1115
+ if (data?.status === "signed_out" || data?.status === "uninvited" || data?.status === "unavailable" || data?.status === "revoked") return { status: data.status };
1116
+ if (data?.status === "listed" && Array.isArray(data.installations) && data.installations.every((entry) => entry && typeof entry.id === "string" && entry.id.length > 0 && typeof entry.createdAt === "string" && Number.isFinite(Date.parse(entry.createdAt)) && typeof entry.expiresAt === "string" && Number.isFinite(Date.parse(entry.expiresAt)))) return data;
1117
+ throw new Error("Invalid CLI installations response.");
1118
+ }
1119
+ /** Revoke one CLI installation. With `token`, a CLI revokes its own installation. */
1120
+ const revokeInstallation = (baseUrl, installationId, options) => requestInstallations(baseUrl, installationId, options);
1121
+ //#endregion
1043
1122
  //#region ../../packages/api-client/src/index.ts
1044
1123
  async function getWorkspace(baseUrl, options = {}) {
1045
1124
  const timeout = AbortSignal.timeout(1e4);
@@ -1415,12 +1494,14 @@ const outcomes = {
1415
1494
  published: "succeeded",
1416
1495
  listed: "succeeded",
1417
1496
  downloaded: "succeeded",
1497
+ read: "succeeded",
1418
1498
  visibility_updated: "succeeded",
1419
1499
  restored: "succeeded",
1420
1500
  viewers_updated: "succeeded",
1421
1501
  skills_installed: "succeeded",
1422
1502
  cli_updated: "succeeded",
1423
1503
  cli_current: "succeeded",
1504
+ logged_out: "succeeded",
1424
1505
  authorization_pending: "pending",
1425
1506
  expired: "pending",
1426
1507
  signed_out: "pending",
@@ -1447,6 +1528,7 @@ const outcomes = {
1447
1528
  invalid_attempt: "failed",
1448
1529
  invalid_action: "failed",
1449
1530
  invalid_file: "failed",
1531
+ invalid_document: "failed",
1450
1532
  invalid_output: "failed",
1451
1533
  invalid_restore: "failed",
1452
1534
  output_exists: "failed",
@@ -1472,10 +1554,12 @@ const commandNames = [
1472
1554
  "status",
1473
1555
  "login",
1474
1556
  "whoami",
1557
+ "logout",
1475
1558
  "publish",
1476
1559
  "list",
1477
1560
  "history",
1478
1561
  "download",
1562
+ "read",
1479
1563
  "visibility",
1480
1564
  "viewers",
1481
1565
  "restore",
@@ -1530,7 +1614,7 @@ function commandLine(command, args) {
1530
1614
  }
1531
1615
  //#endregion
1532
1616
  //#region package.json
1533
- var version = "0.6.0";
1617
+ var version = "0.7.0";
1534
1618
  //#endregion
1535
1619
  //#region src/publish.ts
1536
1620
  const execute = promisify(execFile);
@@ -2249,6 +2333,48 @@ async function identify(serverValue, signal) {
2249
2333
  }
2250
2334
  }
2251
2335
  //#endregion
2336
+ //#region src/document-reference.ts
2337
+ const documentPath = /^\/d\/([^/-]+)(?:-[^/]+)?(?:\/revisions\/([^/]+))?$/u;
2338
+ /** True for any URL, so the value is read as a link rather than as a malformed ID. */
2339
+ const isUrl = (value) => URL.canParse(value);
2340
+ /**
2341
+ * Reads a document ID from `list`, or a document or revision link the user pasted.
2342
+ * A link must be one a workspace host would serve, on any workspace: the server decides
2343
+ * access, so a link to a document this account can't read gets the same answer as an
2344
+ * unknown ID.
2345
+ * `revisionId` is an explicit `--revision`; it must agree with a revision link.
2346
+ */
2347
+ function documentReference(value, revisionId) {
2348
+ const named = isDocumentId(value) ? { documentId: value } : documentLink(value);
2349
+ if (!named) return {
2350
+ status: "invalid_document",
2351
+ message: `${value} is not a Pageship document. Use a six-character document ID from list, or a document link such as https://<workspace>.${workspaceDomain}/d/<id>-<slug>.`
2352
+ };
2353
+ if (revisionId && named.revisionId && revisionId.toLowerCase() !== named.revisionId.toLowerCase()) return {
2354
+ status: "invalid_document",
2355
+ message: `The link names revision ${named.revisionId}, but --revision names ${revisionId}. Use one of them.`
2356
+ };
2357
+ const revision = named.revisionId ?? revisionId;
2358
+ return {
2359
+ documentId: named.documentId,
2360
+ ...revision ? { revisionId: revision } : {}
2361
+ };
2362
+ }
2363
+ function documentLink(value) {
2364
+ if (!isUrl(value)) return;
2365
+ const url = new URL(value);
2366
+ const suffix = `.${workspaceDomain}`;
2367
+ const label = url.hostname.slice(0, -suffix.length);
2368
+ if (url.protocol !== "https:" || url.port || url.username || url.password || !url.hostname.endsWith(suffix) || !workspaceLabelPattern.test(label) || reservedWorkspaceSubdomains.some((name) => name === label)) return;
2369
+ const [, documentId, revisionId] = documentPath.exec(url.pathname) ?? [];
2370
+ if (!isDocumentId(documentId)) return;
2371
+ if (revisionId === void 0) return { documentId };
2372
+ return isRevisionId(revisionId) ? {
2373
+ documentId,
2374
+ revisionId
2375
+ } : void 0;
2376
+ }
2377
+ //#endregion
2252
2378
  //#region src/documents.ts
2253
2379
  /** `1 viewer`, `2 viewers`. */
2254
2380
  const viewerCount = (count) => `${count} viewer${count === 1 ? "" : "s"}`;
@@ -2365,12 +2491,16 @@ function invalidOutput(output, error) {
2365
2491
  message: `Could not write ${output}. ${error instanceof Error ? error.message : "Check the path and try again."}`
2366
2492
  };
2367
2493
  }
2368
- function downloadDocument(options) {
2494
+ /** `document` is an ID from `list` or a document or revision link; see `documentReference`. */
2495
+ async function downloadDocument(options) {
2496
+ const reference = documentReference(options.document, options.revisionId);
2497
+ if ("status" in reference) return reference;
2498
+ const { documentId, revisionId } = reference;
2369
2499
  const output = resolve(options.output);
2370
2500
  return asOwner(options.server, options.signal, async ({ origin }, request) => {
2371
- const bytes = await getDocumentContent(origin, options.documentId, {
2501
+ const bytes = await getDocumentContent(origin, documentId, {
2372
2502
  ...request,
2373
- revisionId: options.revisionId
2503
+ revisionId
2374
2504
  });
2375
2505
  let file;
2376
2506
  try {
@@ -2393,11 +2523,48 @@ function downloadDocument(options) {
2393
2523
  await file.close();
2394
2524
  return {
2395
2525
  status: "downloaded",
2396
- documentId: options.documentId,
2397
- revisionId: options.revisionId ?? null,
2526
+ documentId,
2527
+ revisionId: revisionId ?? null,
2398
2528
  output,
2399
2529
  byteLength: bytes.byteLength,
2400
- message: `Saved ${bytes.byteLength} bytes of ${options.documentId}${options.revisionId ? ` revision ${options.revisionId}` : ""} to ${output}.`
2530
+ message: `Saved ${bytes.byteLength} bytes of ${documentId}${revisionId ? ` revision ${revisionId}` : ""} to ${output}.`
2531
+ };
2532
+ });
2533
+ }
2534
+ /**
2535
+ * Reads a document's HTML for printing. `document` is an ID or a link, as for download. The
2536
+ * owner reads any revision; anyone reads a public document's current one, and a viewer a
2537
+ * private one's. The server answers a document this
2538
+ * account can't read like a missing one, so one message covers all three.
2539
+ */
2540
+ async function readDocument(options) {
2541
+ const reference = documentReference(options.document);
2542
+ if ("status" in reference) return reference;
2543
+ const { documentId, revisionId } = reference;
2544
+ return asOwner(options.server, options.signal, async ({ origin }, request) => {
2545
+ let bytes;
2546
+ try {
2547
+ bytes = await getDocumentContent(origin, documentId, {
2548
+ ...request,
2549
+ revisionId
2550
+ });
2551
+ } catch (error) {
2552
+ if (isRefusal(error) && error.status === "unavailable") return {
2553
+ status: "unavailable",
2554
+ documentId,
2555
+ revisionId: revisionId ?? null,
2556
+ message: `${options.document} is unavailable. It doesn't exist, was deleted, or this account can't read it.${revisionId ? " Only the owner can read an earlier revision." : ""} Check the link, and that this installation is signed in as the owner or a viewer if it is private.`
2557
+ };
2558
+ throw error;
2559
+ }
2560
+ return {
2561
+ status: "read",
2562
+ documentId,
2563
+ revisionId: revisionId ?? null,
2564
+ byteLength: bytes.byteLength,
2565
+ html: new TextDecoder().decode(bytes),
2566
+ bytes,
2567
+ message: `Read ${bytes.byteLength} bytes of ${documentId}${revisionId ? ` revision ${revisionId}` : ""}.`
2401
2568
  };
2402
2569
  });
2403
2570
  }
@@ -2468,6 +2635,111 @@ function restoreRevision(options) {
2468
2635
  });
2469
2636
  }
2470
2637
  //#endregion
2638
+ //#region src/logout.ts
2639
+ const errorText = (error) => error instanceof Error ? error.message : String(error);
2640
+ const exists = (path) => lstat(path).then(() => true, (error) => {
2641
+ if (error.code === "ENOENT") return false;
2642
+ throw error;
2643
+ });
2644
+ /**
2645
+ * Sign this installation out: revoke its session on the server, as the dashboard's Revoke
2646
+ * access does, then delete the stored credential. The credential is deleted only once the
2647
+ * server confirms the session is gone, so a failure never leaves a live session that the user
2648
+ * believes has ended.
2649
+ */
2650
+ async function logout(serverValue, signal) {
2651
+ const selection = selectServer(serverValue);
2652
+ const server = selection.origin;
2653
+ const retry = {
2654
+ command: "logout",
2655
+ args: [...selection.args]
2656
+ };
2657
+ const retryLine = commandLine(retry.command, retry.args);
2658
+ /** A failure that keeps the credential. Only a retry that can succeed gets `next`. */
2659
+ const credentialKept = (status, reason, retryable = true) => ({
2660
+ status,
2661
+ message: `${reason} Your credential is kept, so this installation is still signed in.${retryable ? ` Run ${retryLine} to try again.` : ""}`,
2662
+ ...retryable ? { next: retry } : {}
2663
+ });
2664
+ const notSignedIn = "This installation is not signed in, so there was nothing to sign out of.";
2665
+ let store;
2666
+ try {
2667
+ const directory = configDirectory();
2668
+ if (isAbsolute(directory) && !await exists(directory)) return {
2669
+ status: "logged_out",
2670
+ revoked: false,
2671
+ message: notSignedIn
2672
+ };
2673
+ store = await credentialStore(server);
2674
+ } catch (error) {
2675
+ return credentialKept("request_failed", `Could not open the stored credential. ${errorText(error)}`);
2676
+ }
2677
+ try {
2678
+ const credentials = await store.read();
2679
+ const discarded = credentials.pending ? " An authorization code waiting for approval was discarded. If you approved it, its session may still be listed under CLI installations in the dashboard; revoke it there." : "";
2680
+ if (!credentials.token) {
2681
+ if (credentials.pending) await store.write({});
2682
+ return {
2683
+ status: "logged_out",
2684
+ revoked: false,
2685
+ message: `${notSignedIn}${discarded}`
2686
+ };
2687
+ }
2688
+ const token = credentials.token;
2689
+ let revoked;
2690
+ try {
2691
+ const compatibility = await checkCompatibility(server, { signal });
2692
+ if (compatibility.status !== "compatible") return {
2693
+ ...compatibility,
2694
+ ...credentialKept(compatibility.status, compatibility.message, false)
2695
+ };
2696
+ const access = await getWorkspace(server, {
2697
+ token,
2698
+ signal
2699
+ });
2700
+ const notAdmitted = {
2701
+ status: "uninvited",
2702
+ message: `This account is no longer admitted to this Pageship server, so the server already refuses every request from this installation. It did not remove the installation, though. To sign out fully, ask the server owner to admit the account again and then run ${retryLine}, or to remove the installation. Your credential is kept.`
2703
+ };
2704
+ if (access.status === "uninvited") return notAdmitted;
2705
+ if (access.status === "signed_out") revoked = false;
2706
+ else if (!access.installation) return credentialKept("unsupported", "This server does not report the CLI installation, so the CLI cannot revoke it. Update the server, or revoke the installation in the dashboard under CLI installations.", false);
2707
+ else {
2708
+ const result = await revokeInstallation(server, access.installation.id, {
2709
+ token,
2710
+ signal
2711
+ });
2712
+ if (result.status === "uninvited") return notAdmitted;
2713
+ if (result.status === "revoked") revoked = true;
2714
+ else if (result.status === "signed_out") revoked = false;
2715
+ else return credentialKept("request_failed", "The server did not revoke this installation.");
2716
+ }
2717
+ } catch (error) {
2718
+ if (signal.aborted) return credentialKept("interrupted", "Sign-out was interrupted, and the server may not have revoked this installation.");
2719
+ return credentialKept("request_failed", `Could not sign out through the Pageship server. ${errorText(error)}`);
2720
+ }
2721
+ try {
2722
+ await store.write({});
2723
+ } catch (error) {
2724
+ return {
2725
+ status: "request_failed",
2726
+ revoked,
2727
+ message: `The server ${revoked ? "revoked this installation" : "had already revoked or expired this credential"}, but the stored credential could not be deleted. ${errorText(error)} Run ${retryLine} to delete it.`,
2728
+ next: retry
2729
+ };
2730
+ }
2731
+ return {
2732
+ status: "logged_out",
2733
+ revoked,
2734
+ message: `${revoked ? "Signed out. The server revoked this installation, and its stored credential is deleted." : "Signed out. The server had already revoked or expired this credential, and the stored copy is deleted."}${discarded}`
2735
+ };
2736
+ } catch (error) {
2737
+ return credentialKept("request_failed", `Could not use the stored credential. ${errorText(error)}`);
2738
+ } finally {
2739
+ await store.close();
2740
+ }
2741
+ }
2742
+ //#endregion
2471
2743
  //#region src/skill-files.ts
2472
2744
  const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/u;
2473
2745
  /** The CLI version a packed SKILL.md was stamped with, or undefined for an unstamped one. */
@@ -3265,6 +3537,7 @@ const login = Command.make("login", {
3265
3537
  });
3266
3538
  })).pipe(Command.withDescription("Authorize this installation through a browser and choose your workspace address."));
3267
3539
  const whoami = Command.make("whoami", { server: serverFlag }, ({ server }) => authCommand("whoami", (signal) => identify(Option.getOrUndefined(server), signal))).pipe(Command.withDescription("Identify your personal workspace using the stored credential."));
3540
+ const logoutCommand = Command.make("logout", { server: serverFlag }, ({ server }) => authCommand("logout", (signal) => logout(Option.getOrUndefined(server), signal))).pipe(Command.withDescription("Sign this installation out: revoke its access on the server, then delete the stored credential."));
3268
3541
  const idempotencyKeyFlag = Flag.string("idempotency-key").pipe(Flag.filter((value) => /^[A-Za-z0-9_-]{8,128}$/u.test(value), () => "Use an 8-128 character idempotency key containing letters, digits, underscores or hyphens."), Flag.withDescription("Retry an uncertain operation with the key its result returned."), Flag.optional);
3269
3542
  const publishCommand = Command.make("publish", {
3270
3543
  file: Argument.string("file"),
@@ -3309,18 +3582,34 @@ const history = Command.make("history", {
3309
3582
  cursor: Option.getOrUndefined(cursor),
3310
3583
  signal
3311
3584
  }))).pipe(Command.withDescription("List a document's revisions, newest first, 50 per page."));
3585
+ const documentArgument = Argument.string("document").pipe(Argument.filter((value) => isDocumentId(value) || isUrl(value), () => "Use a six-character document ID from list, or the document's https:// link."), Argument.withDescription("Six-character document ID from list, or the document's link, including a revision link."));
3312
3586
  const download = Command.make("download", {
3313
- documentId: documentIdArgument,
3587
+ document: documentArgument,
3314
3588
  server: serverFlag,
3315
3589
  output: Flag.string("output").pipe(Flag.withDescription("New file to write the exact HTML bytes to. Existing files are kept.")),
3316
3590
  revision: revisionFlag("revision", "Download this historical revision instead of the current one.").pipe(Flag.optional)
3317
- }, ({ documentId, server, output, revision }) => authCommand("download", (signal) => downloadDocument({
3591
+ }, ({ document, server, output, revision }) => authCommand("download", (signal) => downloadDocument({
3318
3592
  server: Option.getOrUndefined(server),
3319
- documentId,
3593
+ document,
3320
3594
  output,
3321
3595
  revisionId: Option.getOrUndefined(revision),
3322
3596
  signal
3323
- }))).pipe(Command.withDescription("Save a document's current or historical HTML to a new file. Visibility is unchanged."));
3597
+ }))).pipe(Command.withDescription("Save a document's current or historical HTML to a new file, by ID or link. Visibility is unchanged."));
3598
+ const read = Command.make("read", {
3599
+ document: documentArgument,
3600
+ server: serverFlag
3601
+ }, ({ document, server }) => {
3602
+ let bytes;
3603
+ return authCommand("read", async (signal) => {
3604
+ const { bytes: read, ...result } = await readDocument({
3605
+ server: Option.getOrUndefined(server),
3606
+ document,
3607
+ signal
3608
+ });
3609
+ bytes = read;
3610
+ return result;
3611
+ }, (result) => bytes ? process.stdout.write(bytes) : console.error(result.message));
3612
+ }).pipe(Command.withDescription("Print a document's current HTML to stdout, by ID or link: your own, a public one or one shared with you. The owner can also read a revision link."));
3324
3613
  const visibility = Command.make("visibility", {
3325
3614
  documentId: documentIdArgument,
3326
3615
  visibility: Argument.choice("visibility", ["private", "public"]),
@@ -3381,7 +3670,8 @@ function sameCommand(name) {
3381
3670
  args: rest
3382
3671
  };
3383
3672
  }
3384
- function authCommand(name, operation) {
3673
+ /** Runs one command and reports its result. `print` replaces the human output of the message. */
3674
+ function authCommand(name, operation, print) {
3385
3675
  return Effect.promise(async () => {
3386
3676
  const controller = new AbortController();
3387
3677
  const interrupt = () => controller.abort();
@@ -3407,6 +3697,7 @@ function authCommand(name, operation) {
3407
3697
  console.error(`${name === "status" ? "Could not check status" : name === "skills" ? "Could not install the Pageship skills" : name === "update" ? "Could not update Pageship" : "Pageship request failed"}: ${result.message}`);
3408
3698
  return;
3409
3699
  }
3700
+ if (print) return print(result);
3410
3701
  console.log(result.message);
3411
3702
  if (typeof result.verificationUriComplete === "string" && typeof result.userCode === "string" && result.next) console.log(`Open ${result.verificationUriComplete}\nCheck code ${result.userCode}.\nThen run: ${commandLine(result.next.command, result.next.args)}`);
3412
3703
  } finally {
@@ -3415,14 +3706,16 @@ function authCommand(name, operation) {
3415
3706
  }
3416
3707
  });
3417
3708
  }
3418
- const command = Command.make("pageship").pipe(Command.withDescription("Pageship publishes private HTML documents on the hosted Pageship service.\n\nEarly development: check whether this installation is ready with the status command. Authorize this installation with login, then publish one HTML file with publish. Manage published documents with list, history, download, visibility, viewers and restore. Install the Pageship skills for your agents with skills install, and update the CLI with update."), Command.withSubcommands([
3709
+ const command = Command.make("pageship").pipe(Command.withDescription("Pageship publishes private HTML documents on the hosted Pageship service.\n\nEarly development: check whether this installation is ready with the status command. Authorize this installation with login, then publish one HTML file with publish. Sign it out with logout. Manage published documents with list, history, download, visibility, viewers and restore. Read a document you own or were shared, by ID or link, with read. Install the Pageship skills for your agents with skills install, and update the CLI with update."), Command.withSubcommands([
3419
3710
  status,
3420
3711
  login,
3421
3712
  whoami,
3713
+ logoutCommand,
3422
3714
  publishCommand,
3423
3715
  list,
3424
3716
  history,
3425
3717
  download,
3718
+ read,
3426
3719
  visibility,
3427
3720
  viewers,
3428
3721
  restore,
@@ -3430,6 +3723,17 @@ const command = Command.make("pageship").pipe(Command.withDescription("Pageship
3430
3723
  updateCommand
3431
3724
  ]), Command.withGlobalFlags([jsonFlag]));
3432
3725
  const effectiveArgs = args.every((arg, index) => arg === "--json" || arg.startsWith("--json=") && booleanValue(arg.slice(7)) !== void 0 || index > 0 && args[index - 1] === "--json" && booleanValue(arg) !== void 0) ? [...args, "--help"] : args;
3726
+ /** The subcommand named before `--`, skipping global flags and the values they take. */
3727
+ function subcommandName() {
3728
+ for (let index = 0; index < flagArgs.length; index++) {
3729
+ const arg = flagArgs[index];
3730
+ if (arg === "--json" && booleanValue(flagArgs[index + 1]) !== void 0) index++;
3731
+ else if (arg === "--log-level" || arg === "--completions") index++;
3732
+ else if (!arg.startsWith("-")) return arg;
3733
+ }
3734
+ }
3735
+ const builtInFlag = /^(?:--help|-h|--version|-v|--completions|--wizard)(?:=|$)/u;
3736
+ const readsDocument = !json && subcommandName() === "read" && !flagArgs.some((arg) => builtInFlag.test(arg));
3433
3737
  const program = Command.runWith(command, {
3434
3738
  version,
3435
3739
  renderErrors: !json
@@ -3454,7 +3758,10 @@ await Effect.runPromise(json ? program.pipe(Effect.provideService(CliOutput.Form
3454
3758
  ...console,
3455
3759
  log: () => {},
3456
3760
  error: () => {}
3457
- }), Effect.provide(CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version] }))) : program);
3761
+ }), Effect.provide(CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version] }))) : readsDocument ? program.pipe(Effect.provideService(Console.Console, {
3762
+ ...console,
3763
+ log: console.error
3764
+ })) : program);
3458
3765
  if (json) {
3459
3766
  if (!envelope) settle(null, {
3460
3767
  status: "internal_error",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: pageship
3
- description: Publish an HTML file to Pageship and manage what is published. Use when the user asks to publish, share or get a link to a document, update its published copy, change its visibility or viewers, list, inspect history, download or restore published documents, or sign in to Pageship. To write, edit, rename or move a document, load pageship-docs instead.
3
+ description: Publish an HTML file to Pageship, read Pageship documents and manage what is published. Use when the user pastes or names a Pageship document link (https://<workspace>.pageship.dev/d/<id>, not a link to the pageship.dev site, the dashboard or the API), or asks to publish, share or get a link to a document, update its published copy, change its visibility or viewers, list, inspect history, download or restore published documents, or sign in to or out of Pageship. To write, edit, rename or move a document, load pageship-docs instead.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Pageship
@@ -91,6 +91,16 @@ On `reauthentication_required`, ask the user to sign in to the Pageship dashboar
91
91
 
92
92
  When `login` returns `cancelled`, `interrupted` or `request_failed` with a `user`, authorization succeeded and the credential is kept, but no address is selected. Run the returned `next` when the user is ready.
93
93
 
94
+ ## Sign out
95
+
96
+ Only when the user asks to sign this installation out of Pageship, run:
97
+
98
+ ```sh
99
+ pageship --json logout
100
+ ```
101
+
102
+ On `logged_out`, the installation is signed out and its credential is deleted. `revoked: false` means there was nothing to revoke, which also counts as signed out. Report the `message` either way: it can ask the user to revoke a leftover session in the dashboard. On any other status, report the `message`, which says whether the installation is still signed in. Run the returned `next`, when there is one, only when the user wants to retry.
103
+
94
104
  ## Publish
95
105
 
96
106
  Set the shell placeholder `PAGESHIP_FILE` to one nonempty `.html` or `.htm` file. Pageship uses the server's configured per-file size limit. A new document is private. An existing source-path match does not overwrite a document.
@@ -141,6 +151,10 @@ When the user names the people to share it with, keep it private and add them as
141
151
 
142
152
  When the user names no one and the result is private, ask whether to make it public, and say in the question that anyone with the link can then read it. Mention that it can instead stay private and be shared with named GitHub users. When it is already public, because the user made it public earlier, say so and don't ask. Change visibility only after the user agrees, as in [visibility](references/visibility.md).
143
153
 
154
+ ## Read a document link
155
+
156
+ A document link is `https://<workspace>.pageship.dev/d/<id>`, often followed by `-<slug>` and sometimes by `/revisions/<revisionId>`. When the user pastes one or asks about one, read it with `pageship read`, as in [read](references/read.md), instead of fetching it: a private document's link answers a fetch like a missing page, and `read` also reads public documents. Fetch a document link only when the CLI isn't installed or the user doesn't want to sign in; that works for public documents alone. Links to `pageship.dev` itself, `app.pageship.dev` or `api.pageship.dev` are not document links; fetch or open them as usual.
157
+
144
158
  ## Use another server
145
159
 
146
160
  The CLI uses the hosted Pageship service by default. Only when the user names another Pageship server, such as a local development server, add `--server <origin>` to every command you start yourself. Returned `next` and `resolutions` arguments already carry that server, so run them unchanged.
@@ -150,12 +164,13 @@ The CLI uses the hosted Pageship service by default. Only when the user names an
150
164
  Load the reference for the task:
151
165
 
152
166
  - [List and history](references/list-and-history.md): find a document's `id`, `visibility`, current `revisionId` and `url`, and inspect its revisions.
153
- - [Download](references/download.md): read a document's HTML, current or from an earlier revision.
167
+ - [Read](references/read.md): print a document's current HTML, by its ID or the link the user pasted, for a document the user owns, a public one, or one the user was added to as a viewer.
168
+ - [Download](references/download.md): save a document's HTML to a file, current or from an earlier revision, by its ID or the link the user pasted.
154
169
  - [Visibility](references/visibility.md): make a document public or private.
155
170
  - [Viewers](references/viewers.md): list, add or remove the GitHub users who can read a private document.
156
171
  - [Restore](references/restore.md): make an earlier revision current again.
157
172
 
158
- Deleting a document, recovering a deleted one, changing a document's display title and revoking a CLI installation happen in the dashboard, at https://app.pageship.dev for the hosted service. The CLI has no commands for them yet, so give the user the dashboard link.
173
+ Deleting a document, recovering a deleted one, changing a document's display title and revoking another CLI installation happen in the dashboard, at https://app.pageship.dev for the hosted service. The CLI has no commands for them yet, so give the user the dashboard link.
159
174
 
160
175
  ## Handle other outcomes
161
176
 
@@ -1,9 +1,9 @@
1
1
  # Download
2
2
 
3
- To read a document's HTML, run:
3
+ To save a document's HTML to a file, run:
4
4
 
5
5
  ```sh
6
6
  pageship --json download "$PAGESHIP_DOCUMENT" --output "$PAGESHIP_OUTPUT"
7
7
  ```
8
8
 
9
- Set `PAGESHIP_DOCUMENT` to the document's `id` from `list`, and `PAGESHIP_OUTPUT` to a path that does not exist. For a historical revision, add `--revision <revisionId>` with an ID from `history`. The bytes go only to that file. On `output_exists`, nothing was written or replaced; choose another path. A download never changes the document or its visibility.
9
+ Set `PAGESHIP_DOCUMENT` to the document's `id` from `list`, or to the document link the user pasted, such as `https://<workspace>.pageship.dev/d/<id>-<slug>`; you don't need `list` to find the ID. Set `PAGESHIP_OUTPUT` to a path that does not exist. For a historical revision, add `--revision <revisionId>` with an ID from `history`, or pass the revision link, which ends in `/revisions/<revisionId>`. On `invalid_document`, the value is not a Pageship document link; ask the user for the right link or ID. The bytes go only to that file. On `output_exists`, nothing was written or replaced; choose another path. A download never changes the document or its visibility. For another person's document, public or shared with the user, download gets the current revision only. To read HTML without writing a file, use [read](read.md).
@@ -0,0 +1,17 @@
1
+ # Read
2
+
3
+ To read a document's HTML, run:
4
+
5
+ ```sh
6
+ pageship --json read "$PAGESHIP_DOCUMENT"
7
+ ```
8
+
9
+ Set `PAGESHIP_DOCUMENT` to the document link the user pasted, such as `https://<workspace>.pageship.dev/d/<id>-<slug>`, or to the document's `id` from `list`. On `read`, the document's HTML is in `html`, and `revisionId` is `null` for the current revision. Without `--json`, the command prints the HTML alone on standard output, so you can redirect it to a file.
10
+
11
+ Reading works for any public document, and for a private document's owner and its viewers, the GitHub accounts the owner shared it with, signed in to this installation. The owner can also pass a revision link, which ends in `/revisions/<revisionId>`, to read that revision; a viewer reads the current revision only.
12
+
13
+ - `invalid_document`: the value isn't a document link. Links to the pageship.dev site, the dashboard and the API are refused before any request. Fetch or open those as usual.
14
+ - `unavailable`: the document doesn't exist, was deleted, or the signed-in account can't read it. Pageship doesn't say which. Tell the user, and ask them to check the link and which GitHub account they signed in with; `pageship --json whoami` shows it. Someone who should have access needs the owner to add their account as a viewer.
15
+ - `signed_out`: sign in as [Sign in](../SKILL.md#sign-in) describes, then read again.
16
+
17
+ Reading never changes the document, and doesn't count as a view.
@@ -3,7 +3,7 @@ name: pageship-chart
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for charts in a Pageship HTML document, loaded by a pageship- kind skill before it draws a chart."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Chart
@@ -3,7 +3,7 @@ name: pageship-code-block
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for code in a Pageship HTML document, such as source code, commands, configuration or logs, loaded by a pageship- kind skill before the first code block."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Code block
@@ -3,7 +3,7 @@ name: pageship-decision-record
3
3
  description: HTML decision records, as the second step of pageship-docs. For any request to record, write, regenerate, edit or move a decision, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Decision record
@@ -3,7 +3,7 @@ name: pageship-diagram
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for diagrams in a Pageship HTML document, such as a process, a timeline, a system map or states, loaded by a pageship- kind skill before it draws a diagram."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Diagram
@@ -3,7 +3,7 @@ name: pageship-docs
3
3
  description: First step for any document people read, such as a report, plan, spec, explainer, review, decision record or slide deck, in HTML or Markdown. Load it before any other pageship- skill. Use when the user asks for one of these documents, asks to regenerate, redesign, edit, rename, move or copy an HTML source file, or asks to record whether they want HTML or Markdown.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Pageship documents
@@ -12,7 +12,7 @@ Run the linter, html-validate with the config and rules in this folder:
12
12
  npx --yes html-validate@11.17.0 --config <pageship-docs folder>/references/html-validate.json <file>
13
13
  ```
14
14
 
15
- It reports HTML errors, accessibility problems such as a missing `lang` or `alt` or a skipped heading level, and breaks of three rules: [one standalone file](file.md#one-standalone-file) and [trusted libraries only](file.md#trusted-libraries-only) in the file rules, and **Height** in [the design rules](design.md#base-rules-every-file-meets). Fix each problem it reports and run it again. Done when it reports nothing. When `npx` can't run, such as without Node or network, check the file against those three rules by reading it, and say in the hand-over that the linter didn't run.
15
+ It reports HTML errors, accessibility problems such as a missing `lang` or `alt` or a skipped heading level, and breaks of four rules: [one standalone file](file.md#one-standalone-file), [trusted libraries only](file.md#trusted-libraries-only) and [link previews](file.md#link-previews) in the file rules, and **Height** in [the design rules](design.md#base-rules-every-file-meets). Fix each problem it reports and run it again. Done when it reports nothing. When `npx` can't run, such as without Node or network, check the file against those four rules by reading it, and say in the hand-over that the linter didn't run.
16
16
 
17
17
  Then:
18
18
 
@@ -51,7 +51,7 @@ Then use every control by keyboard and confirm it works and shows focus. Done wh
51
51
 
52
52
  ## Claims
53
53
 
54
- Read the file sentence by sentence beside the source, captions, labels and tooltips included. Keep each claim the source states. A claim one step past it goes, or becomes an open question: a cause, when something starts or ends, a count, how far a rule reaches, or a new name for something the source names. A caption claims only what its figure draws. Done when you can point to the source's words behind every sentence.
54
+ Read the file sentence by sentence beside the source, captions, labels, tooltips and the link preview's description included. Keep each claim the source states. A claim one step past it goes, or becomes an open question: a cause, when something starts or ends, a count, how far a rule reaches, or a new name for something the source names. A caption claims only what its figure draws. Done when you can point to the source's words behind every sentence.
55
55
 
56
56
  ## The structure you added
57
57
 
@@ -32,6 +32,39 @@ Mermaid, only when a pattern skill calls for it:
32
32
 
33
33
  Hosted fonts from `https://fonts.googleapis.com/css2?...` through a `<link rel="stylesheet">`, which can't carry `integrity`.
34
34
 
35
+ ## Link previews
36
+
37
+ Chat apps and social sites show a card for a shared link, built from tags in the file's `<head>`. Every file carries them, after `<meta charset>`, the viewport and `<title>`:
38
+
39
+ ```html
40
+ <title>Q3 support report</title>
41
+ <meta
42
+ name="description"
43
+ content="Ticket volume rose 18% in Q3 while first-reply time held at 2 hours."
44
+ />
45
+ <meta property="og:type" content="article" />
46
+ <meta property="og:title" content="Q3 support report" />
47
+ <meta
48
+ property="og:description"
49
+ content="Ticket volume rose 18% in Q3 while first-reply time held at 2 hours."
50
+ />
51
+ <meta property="og:image" content="https://example.com/q3/tickets-by-week.png" />
52
+ <meta property="og:image:alt" content="Tickets by week, July to September" />
53
+ <meta property="og:image:width" content="1200" />
54
+ <meta property="og:image:height" content="630" />
55
+ <meta name="twitter:card" content="summary_large_image" />
56
+ <meta name="twitter:image:alt" content="Tickets by week, July to September" />
57
+ ```
58
+
59
+ - **Title.** `<title>` and `og:title` hold the same text: the document's title, as its `h1` gives it.
60
+ - **Description.** `description` and `og:description` hold the same one or two sentences, up to about 160 characters, that state the document's main point as its first lines do. It follows the [content rules](content.md) like any other text in the file.
61
+ - **Type.** `og:type` is `article`.
62
+ - **Image.** The card's image is the first of these that is a PNG, JPEG or GIF file at an `https://` URL: the image or chart that leads the page, then the first image in the page with alt text. Its `alt` becomes `og:image:alt` and `twitter:image:alt`. Its size in pixels, when you measured the image file itself, becomes `og:image:width` and `og:image:height`; a size the page displays it at is not that size, so without a measured size leave those two out. Sites can't show an inline SVG or a `data:` URL, so a page whose images and charts are only those leaves out every image tag.
63
+ - **Card.** `twitter:card` is `summary_large_image` with an image and `summary` without. X reads the title, description and image from the `og:` tags, so the only other `twitter:` tag is `twitter:image:alt`.
64
+ - **No address.** Leave out `og:url` and `<link rel="canonical">`: the file doesn't know where it will be published, and sites use the address they fetched.
65
+
66
+ An edit that changes the title, the main point or the image updates these tags to match.
67
+
35
68
  ## The Pageship sandbox
36
69
 
37
70
  A published file runs with scripts allowed and everything else blocked, so it works without these:
@@ -316,11 +316,105 @@ class NoViewportHeight extends Rule {
316
316
  }
317
317
  }
318
318
 
319
+ const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
320
+
321
+ /** Text with its character references decoded and its runs of white space as single spaces. */
322
+ const plainText = (text) =>
323
+ text
324
+ .replace(/&(?:#(\d+)|#x([\da-f]+)|(\w+));/giu, (match, decimal, hex, name) =>
325
+ decimal || hex
326
+ ? String.fromCodePoint(Number.parseInt(decimal ?? hex, decimal ? 10 : 16))
327
+ : (entities[name.toLowerCase()] ?? match),
328
+ )
329
+ .replace(/\s+/gu, " ")
330
+ .trim();
331
+
332
+ /** The tags a link preview reads. Others, such as one `theme-color` per colour scheme, may repeat. */
333
+ const previewTags = /^(?:description|og:.+|twitter:.+)$/u;
334
+
335
+ /** An `https://` URL whose path doesn't end in `.svg`, which link previews don't show. */
336
+ const previewImage = /^https:\/\/(?![^?#]*\.svg(?:[?#]|$))/iu;
337
+
338
+ class LinkPreview extends Rule {
339
+ documentation() {
340
+ return {
341
+ description:
342
+ "Give the file the link preview tags in the file rules (references/file.md): a description, og:type, og:title and og:description matching the title and description, twitter:card, and an og:image only at an https:// URL to an image that isn't SVG, with og:image:alt and twitter:image:alt.",
343
+ };
344
+ }
345
+
346
+ setup() {
347
+ this.on("dom:ready", ({ document }) => {
348
+ const head = document.querySelector("head");
349
+ if (!head) return;
350
+ /** Each `<meta>` content by its `name` or `property`, with the element that has it. */
351
+ const metas = new Map();
352
+ for (const element of document.querySelectorAll("meta")) {
353
+ const key = attributeValue(element, "property") ?? attributeValue(element, "name");
354
+ if (!previewTags.test(key ?? "")) continue;
355
+ if (metas.has(key)) {
356
+ this.report(element, `The file has a second ${key} tag: keep one`);
357
+ continue;
358
+ }
359
+ metas.set(key, { element, content: plainText(attributeValue(element, "content") ?? "") });
360
+ }
361
+ const content = (key) => metas.get(key)?.content ?? "";
362
+ const at = (key) => metas.get(key)?.element ?? head;
363
+
364
+ for (const key of ["description", "og:type", "og:title", "og:description", "twitter:card"]) {
365
+ if (!content(key)) {
366
+ this.report(
367
+ at(key),
368
+ `The file needs the ${key} tag with content, as the file rules give`,
369
+ );
370
+ }
371
+ }
372
+ const title = document.querySelector("title");
373
+ if (title && content("og:title") && content("og:title") !== plainText(title.textContent)) {
374
+ this.report(at("og:title"), "og:title must hold the same text as <title>");
375
+ }
376
+ if (content("og:description") && content("og:description") !== content("description")) {
377
+ this.report(at("og:description"), "og:description must hold the same text as description");
378
+ }
379
+
380
+ const image = content("og:image");
381
+ if (metas.has("og:image") && !image) {
382
+ this.report(
383
+ at("og:image"),
384
+ "og:image is empty: give the image's URL, or leave out the image tags",
385
+ );
386
+ }
387
+ if (image && !previewImage.test(image)) {
388
+ this.report(
389
+ at("og:image"),
390
+ `og:image "${image}" is not an https:// URL to an image a link preview shows, such as a PNG or JPEG file: use one, or leave out the image tags`,
391
+ );
392
+ }
393
+ for (const key of ["og:image:alt", "twitter:image:alt"]) {
394
+ if (image && !content(key)) {
395
+ this.report(at("og:image"), `og:image needs ${key}, the image's alt text`);
396
+ }
397
+ }
398
+ if (!image && (metas.has("og:image:alt") || metas.has("twitter:image:alt"))) {
399
+ this.report(head, "The file has image alt tags but no og:image: leave them out too");
400
+ }
401
+ const card = image ? "summary_large_image" : "summary";
402
+ if (content("twitter:card") && content("twitter:card") !== card) {
403
+ this.report(
404
+ at("twitter:card"),
405
+ `twitter:card must be "${card}" for a file ${image ? "with" : "without"} og:image`,
406
+ );
407
+ }
408
+ });
409
+ }
410
+ }
411
+
319
412
  export default {
320
413
  name: "pageship",
321
414
  rules: {
322
415
  "pageship/trusted-libraries": TrustedLibraries,
323
416
  "pageship/no-relative-paths": NoRelativePaths,
324
417
  "pageship/no-viewport-height": NoViewportHeight,
418
+ "pageship/link-preview": LinkPreview,
325
419
  },
326
420
  };
@@ -8,6 +8,7 @@
8
8
  "heading-level": ["error", { "allowMultipleH1": false }],
9
9
  "pageship/trusted-libraries": "error",
10
10
  "pageship/no-relative-paths": "error",
11
- "pageship/no-viewport-height": "error"
11
+ "pageship/no-viewport-height": "error",
12
+ "pageship/link-preview": "error"
12
13
  }
13
14
  }
@@ -3,7 +3,7 @@ name: pageship-explainer
3
3
  description: HTML explainers, as the second step of pageship-docs. For any request to explain how something works, or to write, regenerate, edit or move an explainer, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Explainer
@@ -3,7 +3,7 @@ name: pageship-interaction
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for controls in a Pageship HTML document, such as stepping through stages, tracing a path, tabs, filters, revealing detail and pointer states on charts and diagrams, loaded by a pageship- kind skill before it adds any control."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Interaction
@@ -3,7 +3,7 @@ name: pageship-key-figures
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for the few numbers and callouts a Pageship HTML document opens with, loaded by a pageship- kind skill before it writes key figures."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Key figures
@@ -3,7 +3,7 @@ name: pageship-media
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for images, video and audio in a Pageship HTML document, loaded by a pageship- kind skill before it shows media."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Media
@@ -3,7 +3,7 @@ name: pageship-motion
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for animation and transitions in a Pageship HTML document, such as a chart that grows into view, a sequence that plays through a diagram, or a marker moving along a path, loaded by a pageship- kind skill before it draws a chart, a graphic or a sequence, or before anything else on the page moves."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Motion
@@ -3,7 +3,7 @@ name: pageship-plan
3
3
  description: HTML plans, as the second step of pageship-docs. For any request to write, regenerate, edit or move a plan, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Plan
@@ -3,7 +3,7 @@ name: pageship-report
3
3
  description: HTML reports, as the second step of pageship-docs. For any request to write, regenerate, edit or move a report, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Report
@@ -3,7 +3,7 @@ name: pageship-review-report
3
3
  description: HTML review reports, as the second step of pageship-docs. For any request to review a change or a plan and write up the findings, or to write, regenerate, edit or move a review report, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Review report
@@ -3,7 +3,7 @@ name: pageship-slide-deck
3
3
  description: HTML slide decks, as the second step of pageship-docs. For any request to make slides or a presentation, or to regenerate, edit or move a deck, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Slide deck
@@ -3,7 +3,7 @@ name: pageship-spec
3
3
  description: HTML specs, as the second step of pageship-docs. For any request to write, regenerate, edit or move a spec, load pageship-docs first; load this skill first only when the user names it.
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Spec
@@ -3,7 +3,7 @@ name: pageship-table
3
3
  description: "Later step of pageship-docs: for a request to write or edit a document, load pageship-docs first; load this skill first only when the user names it. Pattern for tables in a Pageship HTML document, loaded by a pageship- kind skill before it writes a table."
4
4
  license: MIT
5
5
  metadata:
6
- version: "0.6.0"
6
+ version: "0.7.0"
7
7
  ---
8
8
 
9
9
  # Table
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pageship",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pageship CLI for checking server API compatibility.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {