@plitzi/cli 0.38.3 → 0.38.4

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/dist/index.js CHANGED
@@ -12,15 +12,27 @@ import { createServer } from "node:http";
12
12
  import tls from "node:tls";
13
13
  import readline from "node:readline/promises";
14
14
  import { readFileSync, readdirSync, watch } from "node:fs";
15
- import { EXPLAIN_KINDS, actionSpecFromEntry, actionToSource, authorSpace, blankSpaceSource, catalogTemplateFiles, comparePictures, darkScheme, elementTriggers, elementTypeNames, emptySpaceSource, explain, explainKindOf, explainList, explanationText, failedFlowText, importProbe, importedFiles, inspectDocument, inspectPage, pageRegions, planFixes, readDevTools, refusalOf } from "@plitzi/sdk-authoring";
15
+ import { EXPLAIN_KINDS, PAGE_TEXT_LIMIT, actionSpecFromEntry, actionToSource, authorSpace, blankSpaceSource, catalogTemplateFiles, comparePictures, compareTexts, darkScheme, dataIssues, elementTriggers, elementTypeNames, emptySpaceSource, explain, explainKindOf, explainList, explanationText, failedFlowText, importProbe, importedFiles, inspectDocument, inspectPage, loadImages, pageRegions, pageTexts, planFixes, readDevTools, refusalOf, unrollPage } from "@plitzi/sdk-authoring";
16
16
  import { promisify } from "node:util";
17
17
  import { lookup } from "node:dns/promises";
18
18
  import { build } from "esbuild";
19
19
  import { unzipSync, zipSync } from "fflate";
20
20
  import { gzipSync } from "node:zlib";
21
+ import { emitKeypressEvents } from "node:readline";
22
+ var t$3 = `[data-plitzi-devtools] { display: none !important; }`;
23
+ //#endregion
21
24
  //#region ../../packages/sdk-shared/dist/helpers/isRecord.mjs
22
25
  var e$1 = (e) => typeof e == "object" && !!e && !Array.isArray(e);
23
26
  //#endregion
27
+ //#region ../../packages/sdk-shared/dist/theme/themeCookie.mjs
28
+ var t$2 = "theme";
29
+ /** The port `npm start` took and the name its `/health` answers with: what `check`, `shot` and `visual` find it by. */
30
+ var DEV_SERVER_FILE = `tmp/dev-server.json`;
31
+ /** The space as last authored while developing, re-read by the server on every change: never the source of it. */
32
+ var DEV_SPACE_FILE = `tmp/space.json`;
33
+ /** What Playwright leaves of a run: traces, failures' pictures. */
34
+ var VISUAL_OUTPUT = `tmp/visual`;
35
+ //#endregion
24
36
  //#region src/browser/index.ts
25
37
  /**
26
38
  * A browser on the project's own Playwright, and the project's own server to point it at — what `plitzi check`,
@@ -54,7 +66,7 @@ var launchBrowser = async (root) => {
54
66
  /** What `npm start` wrote down when it took a port: the port, and the name its `/health` answers with. */
55
67
  var recorded = async (root) => {
56
68
  try {
57
- const value = JSON.parse(await readFile(path.join(root, ".plitzi/dev-server.json"), "utf8"));
69
+ const value = JSON.parse(await readFile(path.join(root, DEV_SERVER_FILE), "utf8"));
58
70
  if (!e$1(value)) return {};
59
71
  return {
60
72
  ...typeof value.port === "number" ? { port: value.port } : {},
@@ -83,6 +95,39 @@ var projectOrigin = async (root, project) => {
83
95
  var dataUrl = (png) => `data:image/png;base64,${Buffer.from(png).toString("base64")}`;
84
96
  /** A data URL's picture back as bytes, to write to a file. */
85
97
  var fromDataUrl = (url) => Buffer.from(url.slice(url.indexOf(",") + 1), "base64");
98
+ /**
99
+ * A page for the project's own server, in the theme asked for. The machine's preference alone is not enough: a space
100
+ * whose default is dark paints dark whatever the machine prefers, as it would for any visitor. So a scheme asked for
101
+ * is also the space's own choice — the `theme` cookie a visitor's `themeToggle` writes and the server paints `<html>`
102
+ * by — set before the page is asked for, so the first paint is already in it.
103
+ */
104
+ var openProjectPage = async (browser, origin, view) => {
105
+ const page = await browser.newPage({
106
+ viewport: {
107
+ width: view.width,
108
+ height: view.height
109
+ },
110
+ colorScheme: view.scheme ?? "light",
111
+ reducedMotion: view.reducedMotion ? "reduce" : "no-preference"
112
+ });
113
+ if (view.scheme) await page.context().addCookies([{
114
+ name: t$2,
115
+ value: view.scheme,
116
+ url: origin
117
+ }]);
118
+ return page;
119
+ };
120
+ /**
121
+ * The theme a page was painted in: the class on `<html>` (a space's own choice, or its default), else the one the
122
+ * machine prefers — which is what a page with neither follows.
123
+ */
124
+ var paintedScheme = (page, machine) => page.evaluate((prefers) => {
125
+ const { classList } = document.documentElement;
126
+ if (classList.contains("dark")) return "dark";
127
+ return classList.contains("light") ? "light" : prefers;
128
+ }, machine);
129
+ /** The page as a visitor sees it: the dev tools' own chrome — the badge, the panel — left out of whatever is looked at. */
130
+ var withoutDevTools = (page) => page.addStyleTag({ content: t$3 });
86
131
  //#endregion
87
132
  //#region src/commands/terminal.ts
88
133
  /**
@@ -1494,7 +1539,7 @@ var scripts = ({ mode, source }) => ({
1494
1539
  /**
1495
1540
  * Watched by PATH, not wholesale.
1496
1541
  *
1497
- * The server compiles the project's plugins into `.sdk-plugins/` and then IMPORTS what it built, so a
1542
+ * The server compiles the project's plugins into `tmp/.sdk-plugins/` and then IMPORTS what it built, so a
1498
1543
  * bare `--watch` sees its own output land, restarts, compiles again, and never stops. A local space is not
1499
1544
  * among them: `main.ts` re-authors it on save in a process of its own and the open pages load again
1500
1545
  * (`reloadPages`), so only the server's own code and the plugins restart it.
@@ -1591,10 +1636,12 @@ var tsconfig$1 = ({ mode }) => `${JSON.stringify({
1591
1636
  * writes `.yarn/install-state.gz` into the project, which is a cache and belongs in no repository.
1592
1637
  */
1593
1638
  var YARN_IGNORES$1 = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
1594
- /** Where the page server writes the plugin bundles it builds. A build output, and rebuilt whenever it is missing. */
1595
- var SERVER_IGNORES = ".sdk-plugins\n.plitzi\n";
1596
- var gitignore$1 = ({ mode, packageManager }) => `node_modules\ndist\n.env\nvisual/.results\nvisual/screenshots\n${mode === "server" ? SERVER_IGNORES : ""}${packageManager === "yarn" ? YARN_IGNORES$1 : ""}`;
1597
- var startLine = ({ mode, packageManager, source }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080 — or, when something else holds 8080, the next free port, which it prints and writes to \`.plitzi/dev-server.json\` (set \`PORT\` to choose one). \`${runCommand(packageManager, "start:dev")}\` restarts on a save to the server's code${source === "local" ? "; a save to the space reloads the open page" : ""}. In production, \`${runCommand(packageManager, "build")}\` once and run \`${runCommand(packageManager, "start:prod")}\` with \`NODE_ENV=production\`: the compiled server, with no TypeScript in the process.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
1639
+ /**
1640
+ * `tmp/` is everything the project writes for itself (`./paths`); `.plitzi/` is what the CLI records about it, and is
1641
+ * committed — a clone without it could not pull, push or upgrade.
1642
+ */
1643
+ var gitignore$1 = ({ packageManager }) => `node_modules\ndist\n.env\ntmp\n${packageManager === "yarn" ? YARN_IGNORES$1 : ""}`;
1644
+ var startLine = ({ mode, packageManager, source }) => mode === "server" ? `\`${runCommand(packageManager, "start")}\` serves pages on http://127.0.0.1:8080 — or, when something else holds 8080, the next free port, which it prints and writes to \`${DEV_SERVER_FILE}\` (set \`PORT\` to choose one). \`${runCommand(packageManager, "start:dev")}\` restarts on a save to the server's code${source === "local" ? "; a save to the space reloads the open page" : ""}. In production, \`${runCommand(packageManager, "build")}\` once and run \`${runCommand(packageManager, "start:prod")}\` with \`NODE_ENV=production\`: the compiled server, with no TypeScript in the process.` : `\`${runCommand(packageManager, "start")}\` runs Vite on http://127.0.0.1:5173, with hot module replacement.`;
1598
1645
  var spaceSection = (answers) => {
1599
1646
  if (answers.source === "cloud") return `## Where the space comes from
1600
1647
 
@@ -1613,9 +1660,9 @@ from what is written there, so authoring it twice writes byte-identical document
1613
1660
 
1614
1661
  Nothing is fetched and nothing is signed in to: there is no account, no key and no network in the picture.
1615
1662
 
1616
- \`${runCommand(answers.packageManager, "author")}\` writes the documents out as \`space/offline-data.json\`, for the moment you want the space
1617
- somewhere else — imported into Plitzi, handed to another server, or checked into a repository with no TypeScript
1618
- in it. Nothing here reads that file; the declaration is the source.`;
1663
+ \`${runCommand(answers.packageManager, "author")}\` authors it and says what it found — warnings, suggestions, what a newer SDK changed. It
1664
+ writes nothing: the declaration is the source, and \`npx plitzi push\` puts it on a space in Plitzi when you want it
1665
+ there.`;
1619
1666
  };
1620
1667
  var readme$1 = (answers) => `# ${answers.name}
1621
1668
 
@@ -1631,6 +1678,16 @@ ${startLine(answers)}
1631
1678
 
1632
1679
  ${spaceSection(answers)}
1633
1680
 
1681
+ ## Folders that are not the source
1682
+
1683
+ - \`public/\` is served to anyone who asks, as it is: every file in it is on the internet once the project is deployed.
1684
+ Data and pictures for every visitor go there — never a secret, a key, a private document or what only some visitors
1685
+ may read.
1686
+ - \`tmp/\` is what the project writes for itself while it runs — the plugins it builds, the port it took, test
1687
+ output. Ignored by git, and rebuilt whenever it is missing.
1688
+ - \`.plitzi/\` is what the CLI records about the project — the space it came from, its functions' working copy, the
1689
+ files \`create\` wrote — so \`plitzi pull\`, \`push\` and \`upgrade\` know where they stand. Commit it.
1690
+
1634
1691
  ## The skills
1635
1692
 
1636
1693
  \`.claude/skills/\` carries Plitzi's authoring skill, so an agent working in this repository knows how a space is
@@ -1656,10 +1713,10 @@ var agentsFile = (answers) => {
1656
1713
  `| ${run("visual")} | open the page in a browser and check it rendered |`
1657
1714
  ];
1658
1715
  const zeroWarnings = local ? `Zero warnings from ${run("author")}.` : "Zero warnings from authoring.";
1659
- const port = answers.mode === "server" ? `${run("start")} serves on 8080, or on the next free port when something else holds it — printed, and written to ${code(".plitzi/dev-server.json")}, where ${code("check")}, ${code("shot")} and ${code("visual")} read it. ${code("PORT")} chooses one.` : `${run("start")} runs Vite on 5173.`;
1716
+ const port = answers.mode === "server" ? `${run("start")} serves on 8080, or on the next free port when something else holds it — printed, and written to ${code(DEV_SERVER_FILE)}, where ${code("check")}, ${code("shot")} and ${code("visual")} read it. ${code("PORT")} chooses one.` : `${run("start")} runs Vite on 5173.`;
1660
1717
  const pictures = answers.mode === "server" ? `- **Pictures from other sites** are resized by this server once ${code("src/main.ts")} names their hosts — ${code("images: { domains }")} in ${code("createServer")}, a list of hosts like ${code("images.example.com")} — and ${code("sharp")} is installed: an ${code("image")} then offers a ${code("srcset")} (give it ${code("sizes")}, and ${code("width")}/${code("height")} so nothing jumps).\n` : "";
1661
- const serverData = answers.mode === "server" ? `; one whose ${code("runtime")} is ${code("server")} is read by the server, and the page arrives with it` : "";
1662
- const generated = [...local ? [`- ${code("space/offline-data.json")} — what ${run("author")} writes out; the space is ${code("src/space.ts")}.`] : [], ...answers.mode === "server" ? [`- ${code(".sdk-plugins/")} and ${code(".plitzi/")} — built and written by the server.`] : []];
1718
+ const serverData = answers.mode === "server" ? `; one whose ${code("runtime")} is ${code("server")} is read by the server, and the page arrives with it — bound the same way (${code("products.data.items")}), on a page or a layout, never inside a component` : "";
1719
+ const generated = [`- ${code(`tmp/`)} — what the project writes for itself while it runs: the plugins it built, the port it took${local ? ", the space as last authored" : ""}, test output. Never committed, rebuilt when missing.`, `- ${code(".plitzi/")} — what the CLI records about the project: where it came from, what it wrote. Committed; the CLI's to change.`];
1663
1720
  return `# ${answers.name} — notes for agents
1664
1721
 
1665
1722
  A Plitzi space, rendered ${answers.mode === "server" ? "by the server in this project (SSR + RSC)" : "in the browser by a Vite app"}.
@@ -1675,7 +1732,8 @@ ${commands.join("\n")}
1675
1732
 
1676
1733
  - **Port.** ${port}
1677
1734
  - **Data with no backend** goes in ${code("public/data/*.json")}, served as it is and read by an ${code("apiContainer")} whose ${code("query")} is ${code("/data/products.json")}${serverData}.
1678
- ${pictures}- **Check a page in text first:** ${code(`${runCommand(answers.packageManager, "check")} -- / --width 1440,390`)} says whether every element is on screen, nothing overflows and the console is clean — a picture only when it says something is wrong: ${code(`${runCommand(answers.packageManager, "shot")} -- / --width 390`)} (add ${code("--scheme dark")}; ${code("--frames 4")} to see what moves; ${code("--compare <url>")} against another site, by section). ${run("visual")} runs the checks as tests.
1735
+ - **${code("public/")} is on the internet.** Every file in it is served to anyone who asks for it, as it is, the moment the project is deployed — no sign-in, no check. Never put in it a secret, a key, a ${code(".env")}, a private document, a database dump, or data only some visitors may read: that goes through a server action or a provider that checks who is asking.
1736
+ ${pictures}- **Check a page in text first:** ${code(`${runCommand(answers.packageManager, "check")} -- / --width 1440,390`)} says whether every element is on screen, nothing overflows and the console is clean — a picture only when it says something is wrong: ${code(`${runCommand(answers.packageManager, "shot")} -- / --width 390`)} (add ${code("--scheme dark")}; ${code("--frames 4")} to see what moves; ${code("--compare <url>")} against another site: by section, and each text measured). ${run("visual")} runs the checks as tests.
1679
1737
  - **What the page holds, in text:** ${code(`${runCommand(answers.packageManager, "check")} -- /products --state --element <id>`)} adds its state, every source by name and one element (what it reads, its own state, whether it is on screen); every check already lists the flows that failed. Read it instead of guessing from classes in the DOM.
1680
1738
 
1681
1739
  ## Do not read
@@ -1693,12 +1751,22 @@ layouts, data, templates and flows. The types of ${code("@plitzi/sdk-authoring")
1693
1751
  For a component of your own — a plugin — or anything about packing or uploading one, read
1694
1752
  ${code(".claude/skills/plitzi-cli/SKILL.md")} first: ${code("plitzi add plugin")} writes it in the shape everything reads.
1695
1753
 
1754
+ ## Keep the project clean
1755
+
1756
+ What you leave behind is the next reader's problem — the user's, or the next agent's. Before calling a change done:
1757
+
1758
+ - **Nothing unused.** Delete what you made and no longer use — a file, a page, a component, a class, a token, a data file, an import, a plugin folder. No commented-out code, no ${code("console.log")} left from debugging, no copy of a file kept "just in case": git keeps the history.
1759
+ - **Scratch goes in ${code(`tmp/`)}, or nowhere.** A one-off script, a dump, a picture to look at — never at the root or beside the source, where it reads as part of the project.
1760
+ - **One of everything.** A look used twice is a class; a value used twice is a token; a block used twice is a component, and rows of data are one list. Change it where it is defined, and rename everywhere when you rename.
1761
+ - **Files a reader can find.** One part per file, named after what it is, in the folder of its kind — the shape ${code("src/space.ts")} already has. Do not start a parallel layout of your own.
1762
+ - **Leave it passing.** ${local ? `${run("author")} with zero warnings, ` : ""}${run("typecheck")}, ${run("lint")} and ${run("format")} clean, and the page checked (${run("check")}).
1763
+
1696
1764
  ## The rules that go wrong most
1697
1765
 
1698
1766
  - Never write schema/style JSON by hand; author it. A refusal names the fix — fix the declaration; ${code("npx plitzi fix --write")} writes the ones with a single reading.
1699
1767
  - After the ${code("@plitzi/*")} packages move, ${code("npx plitzi upgrade")}: what this project's CLI files, scripts, skills and renamed names should now be — ${code("--write")} makes it, a file you changed comes as a diff.
1700
1768
  - ${zeroWarnings}
1701
- - Chrome shared by pages is a layout; a look used twice is a class; a repeated block is a function or a ${code("map")}.
1769
+ - Chrome shared by pages is a layout; a look used twice is a class; a block placed again with other content is a component, and rows of data are one ${code("list")} (a short menu may be a ${code("map")} in code).
1702
1770
  - Ids are one namespace for the whole space: name what is referred to; a helper that runs more than once builds inside ${code("scope()")}.
1703
1771
  - A file per part — the tokens, the layout, each component, each page — short enough to read whole; ${code("src/space.ts")} assembles them. ${code("npx plitzi create <dir> --template catalog")} is a complete example of the shape.
1704
1772
  - Rebuilding a page the user owns: ${code("npx plitzi import <url>")} writes its tokens, outline and lists as a start — then split it into parts and write the content. It reads only a site whose domain the user verified on one of their spaces.
@@ -1765,14 +1833,8 @@ var prettierrc = () => `${JSON.stringify({
1765
1833
  arrowParens: "avoid",
1766
1834
  endOfLine: "lf"
1767
1835
  }, null, 2)}\n`;
1768
- /** What a project writes that is nobody's to format or lint: its builds, its authored documents, its test output. */
1769
- var PROJECT_OUTPUTS = [
1770
- "dist",
1771
- "space",
1772
- ".sdk-plugins",
1773
- "visual/.results",
1774
- "visual/screenshots"
1775
- ];
1836
+ /** What a project writes that is nobody's to format or lint: its builds, and everything it writes for itself. */
1837
+ var PROJECT_OUTPUTS = ["dist", "tmp"];
1776
1838
  /**
1777
1839
  * What is the agents' and not the project's: the skills copied in, whose recipes are TypeScript no program of the
1778
1840
  * project includes — linted with types, they would stop the lint before its first file.
@@ -1781,7 +1843,7 @@ var AGENT_FILES = ".claude";
1781
1843
  var prettierignore = (outputs) => `${[
1782
1844
  "node_modules",
1783
1845
  AGENT_FILES,
1784
- ...outputs.filter((output) => output !== ".sdk-plugins")
1846
+ ...outputs
1785
1847
  ].join("\n")}\n`;
1786
1848
  /**
1787
1849
  * Flat config, and only what earns its place.
@@ -1892,9 +1954,9 @@ const PORT = process.env.PORT
1892
1954
  : await freePort(8080, HOST);`;
1893
1955
  /** Where the server says it is: the port it took, for `npm run shot` and the visual tests to find. */
1894
1956
  var LISTEN_SNIPPET = `server.listen(PORT, HOST);
1895
- mkdirSync(path.join(PROJECT_ROOT, '.plitzi'), { recursive: true });
1957
+ mkdirSync(path.join(PROJECT_ROOT, 'tmp'), { recursive: true });
1896
1958
  writeFileSync(
1897
- path.join(PROJECT_ROOT, '.plitzi/dev-server.json'),
1959
+ path.join(PROJECT_ROOT, '${DEV_SERVER_FILE}'),
1898
1960
  \`\${JSON.stringify({ name: SERVER_NAME, port: PORT, url: \`http://127.0.0.1:\${PORT}\` }, null, 2)}\\n\`
1899
1961
  );
1900
1962
  console.log(\`pages on http://127.0.0.1:\${PORT}/\`);`;
@@ -1912,6 +1974,7 @@ import {
1912
1974
  } from '@plitzi/sdk-server';
1913
1975
 
1914
1976
  import { authorSpace } from '@plitzi/sdk-authoring';
1977
+ import { publicData } from '@plitzi/sdk-authoring/node';
1915
1978
 
1916
1979
  import { declarations } from './plugins/declarations.ts';
1917
1980
  import { space } from './space.ts';
@@ -1926,7 +1989,11 @@ ${PORT_SNIPPET}
1926
1989
  * here for the same reason: this restart is the output somebody editing the space is actually watching.
1927
1990
  */
1928
1991
  // \`declarations\`: what the project's plugins fire, answer and read, so the space's use of them is checked too.
1929
- const { schema, style, warnings } = authorSpace(space, { plugins: declarations });
1992
+ // \`data\`: the files of \`public/\` a provider reads, so a binding onto a path one of them does not have is warned.
1993
+ const { schema, style, warnings } = authorSpace(space, {
1994
+ plugins: declarations,
1995
+ data: publicData(new URL('../public/', import.meta.url))
1996
+ });
1930
1997
  const offlineData = { schema, style };
1931
1998
 
1932
1999
  for (const warning of warnings) {
@@ -1936,12 +2003,13 @@ for (const warning of warnings) {
1936
2003
  ${PLUGINS}
1937
2004
 
1938
2005
  /**
1939
- * While developing, the documents are served from the file \`npm run author\` writes, which the server reads again
1940
- * whenever it changes — so a save is re-authored and shown without restarting anything (see \`watchSpace\` below). A
1941
- * deployment keeps them in memory, as authored at boot.
2006
+ * While developing, the documents are served from \`${DEV_SPACE_FILE}\`, which the server reads again whenever it changes —
2007
+ * so a save is re-authored into it and shown without restarting anything (see \`watchSpace\` below). A by-product, in
2008
+ * \`tmp/\` with everything else the project writes for itself: the space is \`src/space.ts\`. A deployment keeps the
2009
+ * documents in memory, as authored at boot.
1942
2010
  */
1943
2011
  const DEVELOPING = process.env.NODE_ENV !== 'production';
1944
- const OFFLINE_DATA = path.join(PROJECT_ROOT, 'space/offline-data.json');
2012
+ const OFFLINE_DATA = path.join(PROJECT_ROOT, '${DEV_SPACE_FILE}');
1945
2013
  if (DEVELOPING) {
1946
2014
  mkdirSync(path.dirname(OFFLINE_DATA), { recursive: true });
1947
2015
  writeFileSync(OFFLINE_DATA, \`\${JSON.stringify(offlineData, null, 2)}\\n\`);
@@ -1961,7 +2029,7 @@ const functions = await loadFunctions(new URL('../functions/', import.meta.url))
1961
2029
  * page server makes. A real deployment swaps this for adapters onto its own database, or for
1962
2030
  * \`createCloudAdapters\` to read the live space out of Plitzi — the server never learns the difference.
1963
2031
  */
1964
- // What \`/health\` answers with, and \`.plitzi/dev-server.json\` records: how a tool knows it reached THIS project.
2032
+ // What \`/health\` answers with, and \`${DEV_SERVER_FILE}\` records: how a tool knows it reached THIS project.
1965
2033
  const SERVER_NAME = schema.definition.permanentUrl;
1966
2034
  const server = createServer({
1967
2035
  port: PORT,
@@ -2010,7 +2078,7 @@ const watchSpace = (): void => {
2010
2078
  }
2011
2079
 
2012
2080
  authoring = true;
2013
- const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'src/author.ts')], {
2081
+ const child = spawn(process.execPath, [path.join(PROJECT_ROOT, 'src/author.ts'), '--out', OFFLINE_DATA], {
2014
2082
  cwd: PROJECT_ROOT,
2015
2083
  stdio: 'inherit'
2016
2084
  });
@@ -2089,7 +2157,7 @@ const functions = await loadFunctions(new URL('../functions/', import.meta.url))
2089
2157
  * on every request; a published environment with no \`revision\` serves the latest and releases itself; with a
2090
2158
  * \`revision\` it serves exactly that version, for a deployment that rolls forward on its own schedule.
2091
2159
  */
2092
- // What \`/health\` answers with, and \`.plitzi/dev-server.json\` records: how a tool knows it reached THIS project.
2160
+ // What \`/health\` answers with, and \`${DEV_SERVER_FILE}\` records: how a tool knows it reached THIS project.
2093
2161
  const SERVER_NAME = ${JSON.stringify(name)};
2094
2162
  const server = createServer({
2095
2163
  port: PORT,
@@ -2183,9 +2251,9 @@ var skillVersion = (skill) => /^version: (.+)$/m.exec(skill)?.[1]?.trim();
2183
2251
  * sit beside it and are opened when the task needs them. Copying \`SKILL.md\` alone left every one of those links
2184
2252
  * pointing at nothing.
2185
2253
  */
2186
- var filesUnder$1 = (root, folder = root) => readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
2254
+ var filesUnder$2 = (root, folder = root) => readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
2187
2255
  const path = join(folder, entry.name);
2188
- if (entry.isDirectory()) return filesUnder$1(root, path);
2256
+ if (entry.isDirectory()) return filesUnder$2(root, path);
2189
2257
  return entry.isFile() ? [[relative(root, path).split(sep).join("/"), readFileSync(path, "utf-8")]] : [];
2190
2258
  });
2191
2259
  /**
@@ -2199,7 +2267,7 @@ var skillFiles = (names = SKILL_NAMES, root) => {
2199
2267
  const entry = resolveFrom(resolvers, `${SKILLS[name]}/skills/${name}/SKILL.md`);
2200
2268
  if (!entry) continue;
2201
2269
  const version = versionOf(resolvers, SKILLS[name]);
2202
- for (const [path, content] of filesUnder$1(dirname(entry))) files[`.claude/skills/${name}/${path}`] = path === "SKILL.md" && version ? stampVersion(content, version) : content;
2270
+ for (const [path, content] of filesUnder$2(dirname(entry))) files[`.claude/skills/${name}/${path}`] = path === "SKILL.md" && version ? stampVersion(content, version) : content;
2203
2271
  }
2204
2272
  return files;
2205
2273
  };
@@ -2217,22 +2285,25 @@ var skillFiles = (names = SKILL_NAMES, root) => {
2217
2285
  * signing up gives you cannot come apart.
2218
2286
  */
2219
2287
  /**
2220
- * Writes the declaration out as documents.
2288
+ * Authors the declaration and says what it found: the check an agent and a person run after every change.
2221
2289
  *
2222
- * Nothing in the project reads it — the server and the browser both author at boot — so it exists for the moment
2223
- * the space has to go somewhere else: imported into Plitzi, handed to another server, or checked into a
2224
- * repository with no TypeScript in it.
2290
+ * It writes nothing — the space is \`src/space.ts\`, and the server and the browser both author it at boot. Only the
2291
+ * server asks for the documents, while developing: \`--out <file>\` writes them where it re-reads them on a save.
2225
2292
  */
2226
2293
  var authorScript = () => `import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2227
2294
  import { createRequire } from 'node:module';
2295
+ import path from 'node:path';
2228
2296
 
2229
2297
  import { authorSpace, planFixes, refusalOf, SpaceRefusedError } from '@plitzi/sdk-authoring';
2298
+ import { publicData } from '@plitzi/sdk-authoring/node';
2230
2299
 
2231
2300
  import { declarations } from './plugins/declarations.ts';
2232
2301
  import { space } from './space.ts';
2233
2302
 
2234
2303
  // \`--json\` answers in one object, for a tool or an agent; otherwise one line on success, the problems on failure.
2235
2304
  const json = process.argv.includes('--json');
2305
+ // \`--out <file>\`: where to write the documents — the server's, while developing. Left out, nothing is written.
2306
+ const out = process.argv.includes('--out') ? process.argv[process.argv.indexOf('--out') + 1] : undefined;
2236
2307
 
2237
2308
  /** How many of the warnings and suggestions said have one fix \`plitzi fix\` can write in this source — and the line that says so. */
2238
2309
  const fixableHint = (): string | undefined => {
@@ -2282,9 +2353,15 @@ const outdated = (): { skill?: string; files?: string; sdk: string } | undefined
2282
2353
  };
2283
2354
 
2284
2355
  try {
2285
- const { schema, style, warnings, suggestions } = authorSpace(space, { plugins: declarations });
2286
- mkdirSync('space', { recursive: true });
2287
- writeFileSync('space/offline-data.json', \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
2356
+ // \`data\`: the files of \`public/\` a provider reads, so a binding onto a path one of them does not have is said here.
2357
+ const { schema, style, warnings, suggestions } = authorSpace(space, {
2358
+ plugins: declarations,
2359
+ data: publicData(new URL('../public/', import.meta.url))
2360
+ });
2361
+ if (out) {
2362
+ mkdirSync(path.dirname(out), { recursive: true });
2363
+ writeFileSync(out, \`\${JSON.stringify({ schema, style }, null, 2)}\\n\`);
2364
+ }
2288
2365
 
2289
2366
  const behind = outdated();
2290
2367
  if (json) {
@@ -2319,7 +2396,7 @@ try {
2319
2396
  }
2320
2397
 
2321
2398
  console.log(
2322
- \`ok · \${schema.pages.length} pages · \${warnings.length} warnings · \${suggestions.length} suggestions · space/offline-data.json\`
2399
+ \`ok · \${schema.pages.length} pages · \${warnings.length} warnings · \${suggestions.length} suggestions\`
2323
2400
  );
2324
2401
  }
2325
2402
  } catch (error) {
@@ -2418,7 +2495,7 @@ var spaceFiles = (answers) => {
2418
2495
  var RECORDED = `/** What \`npm start\` wrote down when it took a port: the port, and the name its \`/health\` answers with. */
2419
2496
  const recorded = (): { port?: number; name?: string } => {
2420
2497
  try {
2421
- const value: unknown = JSON.parse(readFileSync('.plitzi/dev-server.json', 'utf8'));
2498
+ const value: unknown = JSON.parse(readFileSync('${DEV_SERVER_FILE}', 'utf8'));
2422
2499
  if (typeof value !== 'object' || value === null) {
2423
2500
  return {};
2424
2501
  }
@@ -2442,7 +2519,7 @@ const PORT = Number(process.env.PORT ?? recorded().port ?? ${mode === "server" ?
2442
2519
 
2443
2520
  export default defineConfig({
2444
2521
  testDir: './visual',
2445
- outputDir: './visual/.results',
2522
+ outputDir: './${VISUAL_OUTPUT}',
2446
2523
  use: { baseURL: \`http://127.0.0.1:\${PORT}\` },
2447
2524
  // Playwright starts the project itself, so \`${runCommand(packageManager, "visual")}\` is one command from a
2448
2525
  // cold checkout.
@@ -2794,7 +2871,7 @@ var tsconfig = () => `{
2794
2871
  `;
2795
2872
  /** Yarn's own recommendation: with the `node-modules` linker it writes a cache into `.yarn/` that no repository wants. */
2796
2873
  var YARN_IGNORES = "\n.yarn/*\n!.yarn/patches\n!.yarn/plugins\n!.yarn/releases\n!.yarn/versions\n";
2797
- var gitignore = ({ packageManager, inProject }) => `node_modules\ndist\n*.zip\nvisual/.results\n${packageManager === "yarn" && !inProject ? YARN_IGNORES : ""}`;
2874
+ var gitignore = ({ packageManager, inProject }) => `node_modules\ndist\n*.zip\ntmp\n${packageManager === "yarn" && !inProject ? YARN_IGNORES : ""}`;
2798
2875
  var packageFiles = (names, answers) => ({
2799
2876
  "package.json": packageJson(names, answers),
2800
2877
  "tsconfig.json": tsconfig(),
@@ -2933,7 +3010,7 @@ var previewFiles = ([main, ...others]) => ({
2933
3010
  //#endregion
2934
3011
  //#region ../../packages/sdk-shared/dist/helpers/scroll.mjs
2935
3012
  var e = ["smooth", "auto"];
2936
- var t = [
3013
+ var t$1 = [
2937
3014
  "start",
2938
3015
  "center",
2939
3016
  "end",
@@ -3049,13 +3126,13 @@ var a$1 = {
3049
3126
  type: "select",
3050
3127
  description: "Where the element lands, top to bottom.",
3051
3128
  default: "start",
3052
- options: [...t]
3129
+ options: [...t$1]
3053
3130
  },
3054
3131
  inline: {
3055
3132
  type: "select",
3056
3133
  description: "Where it lands, side to side.",
3057
3134
  default: "nearest",
3058
- options: [...t]
3135
+ options: [...t$1]
3059
3136
  },
3060
3137
  behavior: r$2
3061
3138
  }
@@ -3779,7 +3856,7 @@ const PORT = 5173;
3779
3856
 
3780
3857
  export default defineConfig({
3781
3858
  testDir: './visual',
3782
- outputDir: './visual/.results',
3859
+ outputDir: './${VISUAL_OUTPUT}',
3783
3860
  use: { baseURL: \`http://127.0.0.1:\${PORT}\` },
3784
3861
  // Playwright starts the preview itself, so \`${runCommand(packageManager, "visual")}\` is one command from a cold checkout.
3785
3862
  webServer: {
@@ -3845,7 +3922,7 @@ var visualFiles = (elements, answers) => ({
3845
3922
  //#endregion
3846
3923
  //#region src/scaffold/pluginPackage/index.ts
3847
3924
  /** What the build writes, and what is nobody's to format or lint. */
3848
- var OUTPUTS = ["dist", "visual/.results"];
3925
+ var OUTPUTS = ["dist", "tmp"];
3849
3926
  /**
3850
3927
  * Every file of a plugin package: the element, the build that publishes it, a space to preview it in, and what checks
3851
3928
  * it — a project of its own, with nothing about it shared with whatever repository it sits in except the folder.
@@ -3946,7 +4023,7 @@ var LOCKFILES = [
3946
4023
  ["pnpm-lock.yaml", "pnpm"],
3947
4024
  ["package-lock.json", "npm"]
3948
4025
  ];
3949
- var exists = async (file) => {
4026
+ var exists$1 = async (file) => {
3950
4027
  try {
3951
4028
  await fs.access(file);
3952
4029
  return true;
@@ -3977,7 +4054,7 @@ var workspaceGlobs = async (dir, packageJson) => {
3977
4054
  var nearestPackage = async (from) => {
3978
4055
  let dir = from;
3979
4056
  for (;;) {
3980
- if (await exists(path.join(dir, "package.json"))) return dir;
4057
+ if (await exists$1(path.join(dir, "package.json"))) return dir;
3981
4058
  const parent = path.dirname(dir);
3982
4059
  if (parent === dir) return;
3983
4060
  dir = parent;
@@ -3996,7 +4073,7 @@ var nearestWorkspace = async (from) => {
3996
4073
  }
3997
4074
  };
3998
4075
  var lockfileManager = async (dirs) => {
3999
- for (const dir of dirs) for (const [file, manager] of LOCKFILES) if (await exists(path.join(dir, file))) return manager;
4076
+ for (const dir of dirs) for (const [file, manager] of LOCKFILES) if (await exists$1(path.join(dir, file))) return manager;
4000
4077
  };
4001
4078
  var readText$1 = async (file) => {
4002
4079
  try {
@@ -4034,7 +4111,7 @@ var projectDeclared = async (root) => {
4034
4111
  * in a project written since that was how, by a list written in the file before then.
4035
4112
  */
4036
4113
  var plitziProject = async (root, packageJson) => {
4037
- if (packageJson.peerDependencies?.["@plitzi/plitzi-sdk"] && await exists(path.join(root, "src/elements.ts"))) return {
4114
+ if (packageJson.peerDependencies?.["@plitzi/plitzi-sdk"] && await exists$1(path.join(root, "src/elements.ts"))) return {
4038
4115
  kind: "plugin",
4039
4116
  components: await packageComponents(root)
4040
4117
  };
@@ -4048,7 +4125,7 @@ var plitziProject = async (root, packageJson) => {
4048
4125
  return {
4049
4126
  kind: "project",
4050
4127
  mode: server ? "server" : "client",
4051
- source: await exists(path.join(root, "src/space.ts")) ? "local" : "cloud",
4128
+ source: await exists$1(path.join(root, "src/space.ts")) ? "local" : "cloud",
4052
4129
  discovers: server ? main.includes("readdirSync(PLUGINS_DIR") : main.includes("import.meta.glob<{ default: RenderPlugins[string]"),
4053
4130
  declared: await projectDeclared(root)
4054
4131
  };
@@ -4354,12 +4431,16 @@ var loadProjectSpace = async (root) => {
4354
4431
  };
4355
4432
  //#endregion
4356
4433
  //#region src/commands/check.ts
4357
- /** The space the project declares, authored — the handles say what each page owes. Only for one written here. */
4358
- var projectHandles = async (root) => {
4434
+ /** The space the project declares, authored. Only for one written here. */
4435
+ var projectSpace = async (root) => {
4359
4436
  const project = await loadProjectSpace(root);
4360
4437
  if ("problem" in project) return { problem: `${project.problem} There is nothing to check the page against.` };
4361
4438
  try {
4362
- return authorSpace(project.space, { plugins: project.plugins }).handles;
4439
+ const { handles, schema } = authorSpace(project.space, { plugins: project.plugins });
4440
+ return {
4441
+ handles,
4442
+ schema
4443
+ };
4363
4444
  } catch (error) {
4364
4445
  return { problem: `The space does not author — npm run author says why: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
4365
4446
  }
@@ -4372,14 +4453,48 @@ var pageFor = (handles, pathname) => {
4372
4453
  return segments.length === wanted.length && segments.every((segment, index) => segment.startsWith(":") || /^\{\{.*\}\}$/.test(segment) || segment === wanted[index]);
4373
4454
  })?.id;
4374
4455
  };
4375
- var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) => {
4376
- const page = await browser.newPage({
4377
- viewport: {
4378
- width,
4379
- height: 900
4380
- },
4381
- colorScheme: scheme,
4382
- reducedMotion: "no-preference"
4456
+ /** The elements a piece of HTML carries, by the id each is marked with. */
4457
+ var markedIn = (html) => new Set([...html.matchAll(/data-plitzi-el="([^"]+)"/g)].map(([, id]) => id));
4458
+ /** The `runtime: 'server'` provider an element is inside — or is — if any: what the server's HTML owes. */
4459
+ var serverProviderOf = (schema, id) => {
4460
+ const seen = /* @__PURE__ */ new Set();
4461
+ let current = schema.flat[id];
4462
+ while (current && !seen.has(current.id)) {
4463
+ seen.add(current.id);
4464
+ if (current.definition.type === "apiContainer" && current.definition.runtime === "server") return current.id;
4465
+ const parent = current.definition.parentId;
4466
+ current = parent ? schema.flat[parent] : void 0;
4467
+ }
4468
+ };
4469
+ /**
4470
+ * What the page has once hydrated and the server's HTML did not: inside a server provider, an issue per provider — its
4471
+ * answer was meant to be in the page as it arrived; anywhere else, what the browser renders on its own, said apart.
4472
+ */
4473
+ var lateElements = (schema, served, hydrated, width) => {
4474
+ const late = [...new Set(hydrated)].filter((id) => !served.has(id));
4475
+ const byProvider = /* @__PURE__ */ new Map();
4476
+ const browserOnly = [];
4477
+ for (const id of late) {
4478
+ const provider = schema ? serverProviderOf(schema, id) : void 0;
4479
+ if (provider) byProvider.set(provider, [...byProvider.get(provider) ?? [], id]);
4480
+ else browserOnly.push(id);
4481
+ }
4482
+ return {
4483
+ issues: [...byProvider].map(([provider, ids]) => ({
4484
+ code: "not-server-rendered",
4485
+ message: `apiContainer "${provider}" is resolved on the server, and ${String(ids.length)} of what it holds was not in the HTML the server sent (${ids.slice(0, 6).join(", ")}${ids.length > 6 ? ", …" : ""}) — it arrived after the page, so the first paint and a crawler miss it`,
4486
+ elementId: provider,
4487
+ width
4488
+ })),
4489
+ browserOnly
4490
+ };
4491
+ };
4492
+ var checkAt = async (browser, origin, pathname, width, scheme, authored, asked, ssr) => {
4493
+ const handles = authored?.handles;
4494
+ const page = await openProjectPage(browser, origin, {
4495
+ width,
4496
+ height: 900,
4497
+ ...scheme ? { scheme } : {}
4383
4498
  });
4384
4499
  const consoleErrors = [];
4385
4500
  const failedRequests = [];
@@ -4406,12 +4521,20 @@ var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) =
4406
4521
  hiddenAtWidth: [],
4407
4522
  consoleErrors,
4408
4523
  failedRequests,
4409
- devTools: false
4524
+ devTools: false,
4525
+ lists: {}
4410
4526
  };
4411
4527
  }
4528
+ await withoutDevTools(page);
4412
4529
  const pageId = handles ? pageFor(handles, pathname) : void 0;
4413
4530
  const report = handles && pageId ? await inspectPage(page, handles, { page: pageId }) : await inspectDocument(page);
4414
- const devTools = await readDevTools(page, asked);
4531
+ const devTools = await readDevTools(page, {
4532
+ ...asked,
4533
+ state: true
4534
+ });
4535
+ const data = authored && pageId && devTools.sources ? dataIssues(authored.schema, pageId, devTools.sources) : void 0;
4536
+ const served = ssr ? markedIn(await (await fetch(`${origin}${pathname}`)).text()) : void 0;
4537
+ const late = served ? lateElements(authored?.schema, served, await page.evaluate((selector) => Array.from(document.querySelectorAll(selector), (element) => element.getAttribute("data-plitzi-el") ?? ""), "[data-plitzi-el]"), width) : void 0;
4415
4538
  const noPage = handles && !pageId ? [{
4416
4539
  code: "no-page",
4417
4540
  message: `no page of the space answers at ${pathname}`,
@@ -4429,6 +4552,11 @@ var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) =
4429
4552
  ...issue,
4430
4553
  width
4431
4554
  })),
4555
+ ...(data?.issues ?? []).map((issue) => ({
4556
+ ...issue,
4557
+ width
4558
+ })),
4559
+ ...late?.issues ?? [],
4432
4560
  ...failedFlows
4433
4561
  ];
4434
4562
  return {
@@ -4442,8 +4570,10 @@ var checkAt = async (browser, origin, pathname, width, scheme, handles, asked) =
4442
4570
  consoleErrors,
4443
4571
  failedRequests,
4444
4572
  devTools: devTools.available,
4445
- ...devTools.state === void 0 ? {} : { state: devTools.state },
4446
- ...devTools.sources === void 0 ? {} : { sources: devTools.sources },
4573
+ lists: data?.lists ?? {},
4574
+ ...late ? { browserOnly: late.browserOnly } : {},
4575
+ ...!asked.state || devTools.state === void 0 ? {} : { state: devTools.state },
4576
+ ...!asked.state || devTools.sources === void 0 ? {} : { sources: devTools.sources },
4447
4577
  ...devTools.element === void 0 ? {} : { element: devTools.element }
4448
4578
  };
4449
4579
  };
@@ -4467,9 +4597,22 @@ var shapeOf = (value) => {
4467
4597
  const text = value === void 0 ? "undefined" : JSON.stringify(value);
4468
4598
  return text.length > 60 ? `${text.slice(0, 57)}…` : text;
4469
4599
  };
4600
+ /** Each list's rows in a few words — `plan-list 3 rows` — so an empty one is seen, not inferred from a box. */
4601
+ var listsText = (lists) => {
4602
+ const entries = Object.entries(lists);
4603
+ if (entries.length === 0) return [];
4604
+ const rows = entries.map(([id, count]) => `${id} ${count === null ? "reads nothing" : `${String(count)} row${count === 1 ? "" : "s"}`}`);
4605
+ return [chalk.dim(` · lists: ${rows.join(", ")}`)];
4606
+ };
4607
+ /** With `--ssr`, what only the browser renders — not a problem outside a server provider, and said so it is seen. */
4608
+ var browserOnlyText = (report) => report.browserOnly && report.browserOnly.length > 0 ? [chalk.dim(` · rendered only in the browser, outside any server provider: ${report.browserOnly.slice(0, 10).join(", ")}${report.browserOnly.length > 10 ? `, … (${String(report.browserOnly.length)})` : ""}`)] : [];
4470
4609
  var reportText$1 = (report, asked) => {
4471
4610
  const head = `${report.path} at ${String(report.width)} px`;
4472
- const held = heldText(report, asked);
4611
+ const held = [
4612
+ ...listsText(report.lists),
4613
+ ...browserOnlyText(report),
4614
+ ...heldText(report, asked)
4615
+ ];
4473
4616
  const atWidth = report.hiddenAtWidth.length > 0 ? chalk.dim(` · hidden at this width by a breakpoint, on purpose: ${report.hiddenAtWidth.join(", ")}`) : void 0;
4474
4617
  if (report.ok) return [
4475
4618
  chalk.green(`✓ ${head} — ${report.checked > 0 ? `${String(report.checked)} elements on screen, ` : ""}nothing wrong`),
@@ -4493,9 +4636,9 @@ var check = async (route, options) => {
4493
4636
  fail(where.problem);
4494
4637
  return;
4495
4638
  }
4496
- const handles = plitzi?.source === "local" ? await projectHandles(project.root) : void 0;
4497
- if (handles && "problem" in handles) {
4498
- fail(handles.problem);
4639
+ const authored = plitzi?.source === "local" ? await projectSpace(project.root) : void 0;
4640
+ if (authored && "problem" in authored) {
4641
+ fail(authored.problem);
4499
4642
  return;
4500
4643
  }
4501
4644
  const browser = await launchBrowser(project.root);
@@ -4505,13 +4648,13 @@ var check = async (route, options) => {
4505
4648
  }
4506
4649
  try {
4507
4650
  const widths = options.width ?? [1440, 390];
4508
- const scheme = options.scheme ?? "light";
4651
+ const { scheme } = options;
4509
4652
  const asked = {
4510
4653
  state: Boolean(options.state),
4511
4654
  ...options.element ? { element: options.element } : {}
4512
4655
  };
4513
4656
  const reports = [];
4514
- for (const width of widths) reports.push(await checkAt(browser, where.origin, route ?? "/", width, scheme, handles, asked));
4657
+ for (const width of widths) reports.push(await checkAt(browser, where.origin, route ?? "/", width, scheme, authored, asked, Boolean(options.ssr)));
4515
4658
  console.log(options.json ? JSON.stringify(reports) : reports.map((report) => reportText$1(report, asked)).join("\n"));
4516
4659
  if (reports.some((report) => !report.ok)) process.exitCode = 1;
4517
4660
  } finally {
@@ -4570,6 +4713,15 @@ var writeFunctionsState = async (root, state) => {
4570
4713
  };
4571
4714
  /** The files that differ between two copies — added, removed or changed — sorted. */
4572
4715
  var changedFiles = (a, b) => [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].filter((file) => a[file] !== b[file]).sort();
4716
+ /**
4717
+ * What `functions/` holds that the space does not: nothing at all (`none`, no `functions/index.ts`), something changed
4718
+ * since it was pulled from this space or never pulled (`changed`), or exactly what was pulled (`unchanged`).
4719
+ */
4720
+ var functionsChange = async (root, spaceId) => {
4721
+ const [local, state] = await Promise.all([readLocal(root), readState(root)]);
4722
+ if (!Object.hasOwn(local, "index.ts")) return "none";
4723
+ return state?.space === spaceId && !changedFiles(local, state.files).length ? "unchanged" : "changed";
4724
+ };
4573
4725
  var taskList = (draft) => draft.manifest?.tasks.length ? draft.manifest.tasks.map((task) => `${task.namespace}.${task.action}`).join(", ") : "no tasks";
4574
4726
  var readDraft = async (connection, spaceId) => {
4575
4727
  const answered = await authorizedRequest(connection, `/spaces/${spaceId}/functions`);
@@ -4622,30 +4774,29 @@ var printProblems = (problems) => {
4622
4774
  });
4623
4775
  process.exitCode = 1;
4624
4776
  };
4625
- var pushFunctions = async (options) => {
4626
- const root = await rootOf();
4627
- const connection = root && await connectToSpace(options, "to push to");
4628
- if (!root || !connection || !connection.space) return;
4777
+ /**
4778
+ * `functions/` saved as the draft of the space the connection works in: refused when the space's copy moved on since the
4779
+ * pull, and — for a project that never pulled them — when the space already has functions of its own. `force` replaces
4780
+ * whatever the space holds now, which is what `plitzi push --force` asks of every part.
4781
+ */
4782
+ var pushFunctionsOf = async (root, connection, space, { force = false } = {}) => {
4629
4783
  const [local, state] = await Promise.all([readLocal(root), readState(root)]);
4630
4784
  if (!Object.hasOwn(local, "index.ts")) {
4631
4785
  fail("There is no functions/index.ts here: it is where a space’s functions start. Pull them, or write it.");
4632
- return;
4786
+ return "failed";
4633
4787
  }
4634
- let base = state?.space === connection.space.id ? state.version : void 0;
4788
+ if (state?.space === space.id && !changedFiles(local, state.files).length) return "unchanged";
4789
+ let base = state?.space === space.id && !force ? state.version : void 0;
4635
4790
  if (base === void 0) {
4636
- const draft = await readDraft(connection, connection.space.id);
4637
- if (!draft) return;
4638
- if (Object.keys(draft.files).length) {
4639
- fail(`${connection.space.name} already has functions. Pull them first: plitzi functions pull.`);
4640
- return;
4791
+ const draft = await readDraft(connection, space.id);
4792
+ if (!draft) return "failed";
4793
+ if (Object.keys(draft.files).length && !force) {
4794
+ fail(`${space.name} already has functions. Pull them first: plitzi functions pull.`);
4795
+ return "failed";
4641
4796
  }
4642
4797
  base = draft.version;
4643
4798
  }
4644
- if (state?.space === connection.space.id && !changedFiles(local, state.files).length) {
4645
- console.log("Nothing to push: functions/ is what was pulled.");
4646
- return;
4647
- }
4648
- const answered = await authorizedRequest(connection, `/spaces/${connection.space.id}/functions`, {
4799
+ const answered = await authorizedRequest(connection, `/spaces/${space.id}/functions`, {
4649
4800
  method: "PUT",
4650
4801
  headers: { "Content-Type": "application/json" },
4651
4802
  body: JSON.stringify({
@@ -4655,27 +4806,36 @@ var pushFunctions = async (options) => {
4655
4806
  });
4656
4807
  if (!answered.ok) {
4657
4808
  fail(answered.error);
4658
- return;
4809
+ return "failed";
4659
4810
  }
4660
4811
  const { reply } = answered.value;
4661
4812
  if (reply.status === 422 && reply.data.problems) {
4662
4813
  printProblems(reply.data.problems);
4663
- return;
4814
+ return "failed";
4664
4815
  }
4665
4816
  if (reply.status === 409) {
4666
- fail(`${connection.space.name}’s functions changed since your pull — in the builder, or from another copy. Keep your changes aside, pull, and apply them again.`);
4667
- return;
4817
+ fail(`${space.name}’s functions changed since your pull — in the builder, or from another copy. Keep your changes aside, pull, and apply them again.`);
4818
+ return "failed";
4668
4819
  }
4669
4820
  if (reply.status !== 200 || !reply.data.version) {
4670
4821
  fail(reply.data.refusal?.error ?? reply.data.error ?? `The functions were not saved (${reply.status}).`);
4671
- return;
4822
+ return "failed";
4672
4823
  }
4673
4824
  await writeFunctionsState(root, {
4674
- space: connection.space.id,
4825
+ space: space.id,
4675
4826
  version: reply.data.version,
4676
4827
  files: local
4677
4828
  });
4678
- console.log(chalk.green(`Pushed to ${connection.space.name}’s draft — ${taskList({ manifest: reply.data.manifest ?? null })}.`) + chalk.dim(" The live site runs them once the space is published."));
4829
+ console.log(chalk.green(`Functions pushed to ${space.name}’s draft — ${taskList({ manifest: reply.data.manifest ?? null })}.`));
4830
+ return "pushed";
4831
+ };
4832
+ var pushFunctions = async (options) => {
4833
+ const root = await rootOf();
4834
+ const connection = root && await connectToSpace(options, "to push to");
4835
+ if (!root || !connection || !connection.space) return;
4836
+ const outcome = await pushFunctionsOf(root, connection, connection.space);
4837
+ if (outcome === "unchanged") console.log("Nothing to push: functions/ is what was pulled.");
4838
+ else if (outcome === "pushed") console.log(chalk.dim("The live site runs them once the space is published."));
4679
4839
  };
4680
4840
  /** What one run of a task did: its logs, then its value — or why it failed, as a failure of the command. */
4681
4841
  var printRun = (task, report) => {
@@ -4974,7 +5134,7 @@ var fromSpaceMain = (options) => {
4974
5134
  import path from 'node:path';
4975
5135
 
4976
5136
  import { closeOnSignals, consoleLogger, ${local ? "createJsonAdapters" : "createCloudAdapters"}, createServer, loadFunctions } from '@plitzi/sdk-server';
4977
- ${hasRuntime ? importLine(`{ ${runtimeImports} }`, "@plitzi/sdk-server/runtime") : ""}${local ? `\n${importLine("{ authorSpace }", "@plitzi/sdk-authoring")}` : ""}
5137
+ ${hasRuntime ? importLine(`{ ${runtimeImports} }`, "@plitzi/sdk-server/runtime") : ""}${local ? `\n${importLine("{ authorSpace }", "@plitzi/sdk-authoring")}${importLine("{ publicData }", "@plitzi/sdk-authoring/node")}` : ""}
4978
5138
  ${importLine("{ lookups }", "./actions.ts")}${local ? importLine("{ declarations }", "./plugins/declarations.ts") : ""}${runtimeEntry ? importLine("spaceRuntime", runtimeEntry) : ""}${local ? importLine("{ space }", "./space.ts") : ""}
4979
5139
  /**
4980
5140
  * The project's settings — the key its actions sign with, the variables the space was given on Plitzi — kept in \`.env\`,
@@ -4996,7 +5156,10 @@ ${builtPlugins ? BUILT_PLUGINS : ""}${local ? `
4996
5156
  * The space, held in this project: \`src/space/\`, authored at boot — saving a page and letting \`--watch\` restart is
4997
5157
  * the whole edit loop, and the warnings are printed where somebody editing it is watching.
4998
5158
  */
4999
- const { schema, style, warnings } = authorSpace(space, { plugins: declarations${builtPlugins ? ", pluginTypes: builtTypes" : ""} });
5159
+ const { schema, style, warnings } = authorSpace(space, {
5160
+ plugins: declarations,${builtPlugins ? "\n pluginTypes: builtTypes," : ""}
5161
+ data: publicData(new URL('../public/', import.meta.url))
5162
+ });
5000
5163
 
5001
5164
  for (const warning of warnings) {
5002
5165
  console.warn(\`[author] \${warning.message}\`);
@@ -5051,6 +5214,22 @@ var placeOf = (paths) => {
5051
5214
  const prefix = paths.length > 0 && paths.every((path) => path.startsWith("src/")) ? "" : "src/";
5052
5215
  return (path) => `${prefix}${path}`;
5053
5216
  };
5217
+ /**
5218
+ * Where the project keeps what the space's runtime and plugins are built from: the module the runtime starts at, and the
5219
+ * files each plugin's elements start at — the paths `create --from` writes them at, which `plitzi push` packs again.
5220
+ */
5221
+ var projectEntries = ({ source }) => {
5222
+ const place = placeOf(Object.keys(source.files));
5223
+ const runtime = source.runtime?.entries[0];
5224
+ return {
5225
+ runtime: runtime ? place(runtime) : null,
5226
+ plugins: source.plugins.map(({ type, entries }) => ({
5227
+ type,
5228
+ entries: entries.map(place)
5229
+ })),
5230
+ files: Object.keys(source.files).map(place)
5231
+ };
5232
+ };
5054
5233
  /** The import `src/main.ts` names the runtime's module by. */
5055
5234
  var fromMain = (path) => `./${path.replace(/^src\//, "")}`;
5056
5235
  /** What the space was given on Plitzi, by name, as `.env.example` lists it — and `.env`, with values where they are known. */
@@ -5212,7 +5391,7 @@ var digestsOf = async (root, file, format) => {
5212
5391
  var readOrigin = async (root) => {
5213
5392
  try {
5214
5393
  const value = JSON.parse(await fs.readFile(path.join(root, ORIGIN_FILE), "utf-8"));
5215
- if (!e$1(value) || value.format !== 1 || typeof value.api !== "string" || !e$1(value.space) || typeof value.space.id !== "number" || typeof value.space.name !== "string" || typeof value.space.permanentUrl !== "string" || value.source !== "local" && value.source !== "cloud" || !e$1(value.version) || typeof value.version.environment !== "string" || value.version.revision !== void 0 && typeof value.version.revision !== "number" || !isStrings(value.files) || !isStrings(value.downloads) || !isStrings(value.dependencies)) return;
5394
+ if (!e$1(value) || value.format !== 1 || typeof value.api !== "string" || !e$1(value.space) || typeof value.space.id !== "number" || typeof value.space.name !== "string" || typeof value.space.permanentUrl !== "string" || value.source !== "local" && value.source !== "cloud" || !e$1(value.version) || typeof value.version.environment !== "string" || value.version.revision !== void 0 && typeof value.version.revision !== "number" || value.draft !== void 0 && typeof value.draft !== "string" || !isStrings(value.files) || !isStrings(value.downloads) || !isStrings(value.dependencies)) return;
5216
5395
  return {
5217
5396
  format: 1,
5218
5397
  api: value.api,
@@ -5226,6 +5405,7 @@ var readOrigin = async (root) => {
5226
5405
  environment: value.version.environment,
5227
5406
  ...typeof value.version.revision === "number" ? { revision: value.version.revision } : {}
5228
5407
  },
5408
+ ...typeof value.draft === "string" ? { draft: value.draft } : {},
5229
5409
  files: value.files,
5230
5410
  downloads: value.downloads,
5231
5411
  dependencies: value.dependencies
@@ -5250,6 +5430,17 @@ var digestsOnDisk = async (root, files, format) => {
5250
5430
  const digests = await Promise.all(files.map(async (file) => [file, (await digestsOf(root, file, format))?.formatted]));
5251
5431
  return Object.fromEntries(digests.filter((entry) => entry[1] !== void 0));
5252
5432
  };
5433
+ /**
5434
+ * The files a space gives a project — its text as the project's formatter writes it, its binaries as they are — by
5435
+ * path: what a pull compares with what is on disk, and what a push records as given once the space holds the project.
5436
+ * The files of its CDN are not among them: they are fetched, and only when the space names another address for one.
5437
+ */
5438
+ var givenFiles = async (project, format) => {
5439
+ const given = /* @__PURE__ */ new Map();
5440
+ for (const [file, text] of Object.entries(project.files)) given.set(file, Buffer.from(await format(file, text)));
5441
+ Object.entries(project.binaries).forEach(([file, base64]) => given.set(file, Buffer.from(base64, "base64")));
5442
+ return given;
5443
+ };
5253
5444
  //#endregion
5254
5445
  //#region src/commands/createFrom.ts
5255
5446
  /**
@@ -5264,30 +5455,49 @@ var versionLabel = ({ environment, revision }) => {
5264
5455
  return revision ? `${environment} r${String(revision)}` : `${environment} (latest)`;
5265
5456
  };
5266
5457
  /**
5267
- * A version of the space named — its id or its permanent URL — put together as a project, or why not, said and
5268
- * `undefined`. `name` is what it is called in what is printed: `from`, unless a pull names it by its id.
5458
+ * A version of the space named — its id or its permanent URL — put together as a project, asked for on a connection that
5459
+ * is signed in: the export, or why not in words. `name` is what the space is called in them.
5269
5460
  */
5270
- var fetchExport = async (api, from, { source, version, name = from }) => {
5271
- const connection = await signedIn(api, `to take ${from} out of Plitzi`);
5272
- if (!connection) return;
5273
- console.log(`\nTaking ${chalk.bold(name)} (${versionLabel(version)}) out of ${api}…`);
5461
+ var requestExport = async (connection, from, { source, version, name = from }) => {
5274
5462
  const query = new URLSearchParams({
5275
5463
  source,
5276
5464
  environment: version.environment,
5277
5465
  ...version.revision ? { revision: String(version.revision) } : {}
5278
5466
  });
5279
5467
  const answered = await authorizedRequest(connection, `/spaces/${encodeURIComponent(from)}/export?${query.toString()}`);
5280
- if (!answered.ok) {
5281
- fail(answered.error);
5282
- return;
5283
- }
5468
+ if (!answered.ok) return answered;
5284
5469
  const { status, data } = answered.value.reply;
5285
- if (status === 200 && isExport(data)) return data;
5470
+ if (status === 200 && isExport(data)) return {
5471
+ ok: true,
5472
+ value: data
5473
+ };
5286
5474
  const said = e$1(data) && typeof data.error === "string" ? data.error : void 0;
5287
- fail({
5288
- 403: `You may not take ${from} out: it takes being able to change it — its owner, an administrator or a writer.`,
5289
- 404: said ?? `There is no space ${from} you can reach.`
5290
- }[status] ?? said ?? `The platform would not hand ${from} over (${String(status)}).`);
5475
+ return {
5476
+ ok: false,
5477
+ error: {
5478
+ 403: `You may not take ${name} out: it takes being able to change it — its owner, an administrator or a writer.`,
5479
+ 404: said ?? `There is no space ${name} you can reach.`
5480
+ }[status] ?? said ?? `The platform would not hand ${name} over (${String(status)}).`
5481
+ };
5482
+ };
5483
+ /**
5484
+ * A version of the space named put together as a project — signing in first when there is no session — or why not,
5485
+ * said and `undefined`. `name` is what it is called in what is printed: `from`, unless a pull names it by its id.
5486
+ */
5487
+ var fetchExport = async (api, from, { source, version, name = from }) => {
5488
+ const connection = await signedIn(api, `to take ${from} out of Plitzi`);
5489
+ if (!connection) return;
5490
+ console.log(`\nTaking ${chalk.bold(name)} (${versionLabel(version)}) out of ${api}…`);
5491
+ const exported = await requestExport(connection, from, {
5492
+ source,
5493
+ version,
5494
+ name
5495
+ });
5496
+ if (!exported.ok) {
5497
+ fail(exported.error);
5498
+ return;
5499
+ }
5500
+ return exported.value;
5291
5501
  };
5292
5502
  /** A file of the space's CDN, fetched. */
5293
5503
  var download = async (url) => {
@@ -5335,6 +5545,7 @@ var recordOrigin = async (root, { api, exported, source, pinned, project }) => {
5335
5545
  environment: exported.version.environment,
5336
5546
  ...pinned ? { revision: exported.version.revision } : {}
5337
5547
  },
5548
+ ...exported.draft ? { draft: exported.draft } : {},
5338
5549
  files,
5339
5550
  downloads: Object.fromEntries(project.downloads.filter(({ to }) => Object.hasOwn(files, to)).map(({ url, to }) => [to, url])),
5340
5551
  dependencies: project.dependencies
@@ -5902,7 +6113,7 @@ var explainCommand = (name, options) => {
5902
6113
  }
5903
6114
  const explanations = explain(name);
5904
6115
  if (explanations.length === 0) {
5905
- fail(`"${name}" is no element, step, trigger, code or transformer. See what there is: --list ${kinds}.`);
6116
+ fail(`"${name}" is no element, step, trigger, code, transformer or helper. See what there is: --list ${kinds}.`);
5906
6117
  return;
5907
6118
  }
5908
6119
  console.log(options.json ? JSON.stringify(explanations) : explanations.map(explanationText).join("\n\n"));
@@ -6540,18 +6751,18 @@ var measure = async (browser, url, width, scheme, read = []) => {
6540
6751
  };
6541
6752
  };
6542
6753
  /** Every file under a folder, by its path inside it — none when there is no such folder. */
6543
- var filesUnder = async (folder) => {
6754
+ var filesUnder$1 = async (folder) => {
6544
6755
  return (await readdir(folder, {
6545
6756
  recursive: true,
6546
6757
  withFileTypes: true
6547
6758
  }).catch(() => [])).filter((entry) => entry.isFile()).map((entry) => path.relative(folder, path.join(entry.parentPath, entry.name)));
6548
6759
  };
6549
- var count = (amount, one, many = `${one}s`) => `${String(amount)} ${amount === 1 ? one : many}`;
6760
+ var count$1 = (amount, one, many = `${one}s`) => `${String(amount)} ${amount === 1 ? one : many}`;
6550
6761
  var summaryText = (summary, out, stale) => [
6551
6762
  chalk.green(`Imported ${summary.url} into ${out}`),
6552
- ` tokens ${count(summary.colours, "colour")} (${summary.dark ? "light and dark" : "no dark scheme: dark repeats light"}), ${count(summary.shadows, "shadow")}, ${count(summary.radii, "radius", "radii")}${summary.fonts.length > 0 ? `, fonts ${summary.fonts.join(", ")}` : ""}`,
6553
- ` outline ${count(summary.blocks, "block")} at ${summary.widths.join(", ")} px`,
6554
- ...summary.lists.length > 0 ? [` lists ${summary.lists.map((list) => `${list.file} (${count(list.rows, "row")})`).join(", ")}`] : [],
6763
+ ` tokens ${count$1(summary.colours, "colour")} (${summary.dark ? "light and dark" : "no dark scheme: dark repeats light"}), ${count$1(summary.shadows, "shadow")}, ${count$1(summary.radii, "radius", "radii")}${summary.fonts.length > 0 ? `, fonts ${summary.fonts.join(", ")}` : ""}`,
6764
+ ` outline ${count$1(summary.blocks, "block")} at ${summary.widths.join(", ")} px`,
6765
+ ...summary.lists.length > 0 ? [` lists ${summary.lists.map((list) => `${list.file} (${count$1(list.rows, "row")})`).join(", ")}`] : [],
6555
6766
  ` pictures ${String(summary.pictures)} in assets.json`,
6556
6767
  ...stale.length > 0 ? [chalk.yellow(` earlier ${stale.join(", ")} ${stale.length === 1 ? "was" : "were"} there before and not written this time: delete what is stale`)] : [],
6557
6768
  ` next ${path.join(out, "IMPORT.md")} says what was not carried over`
@@ -6576,7 +6787,7 @@ var importPage = async (address, options) => {
6576
6787
  fail(`${out} is outside the project: the import is written inside it.`);
6577
6788
  return;
6578
6789
  }
6579
- const existing = await filesUnder(target);
6790
+ const existing = await filesUnder$1(target);
6580
6791
  if (existing.length > 0 && !options.force) {
6581
6792
  fail(`${out} already has files: choose another --out, or --force to write over them.`);
6582
6793
  return;
@@ -6674,16 +6885,16 @@ var a = [
6674
6885
  pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/
6675
6886
  }
6676
6887
  ];
6677
- var o = (e) => {
6888
+ var o$1 = (e) => {
6678
6889
  let t = e.split("/");
6679
6890
  if (!e || e.startsWith("/") || /^[a-zA-Z]:/.test(e) || e.includes("\\")) return `"${e}" is not a path relative to the project, with forward slashes`;
6680
6891
  if (t.some((e) => e === "" || e === "." || e === "..")) return `"${e}" climbs out of the project, or has an empty part`;
6681
6892
  if (t.some((e) => e === "node_modules" || e === ".git")) return `"${e}" is in node_modules or .git: a package is a dependency, not a file of the project`;
6682
6893
  if (i.some((t) => t.test(e))) return `"${e}" is a file of credentials: they stay where the project runs, never in what Plitzi keeps`;
6683
6894
  };
6684
- var s = (e) => a.find(({ pattern: t }) => t.test(e))?.what;
6685
- var c = (e) => typeof e == "string" && r.some((t) => t === e);
6686
- var l = (e) => typeof e == "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e);
6895
+ var s$1 = (e) => a.find(({ pattern: t }) => t.test(e))?.what;
6896
+ var c$1 = (e) => typeof e == "string" && r.some((t) => t === e);
6897
+ var l$1 = (e) => typeof e == "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e);
6687
6898
  var u = (e) => Array.isArray(e) && e.length > 0 && e.every((e) => typeof e == "string");
6688
6899
  var d = (t) => e$1(t) && Object.values(t).every((e) => typeof e == "string");
6689
6900
  var f = (e) => Math.floor(e.length * 3 / 4) - (e.endsWith("==") ? 2 : +!!e.endsWith("="));
@@ -6693,7 +6904,7 @@ var p = (t, i) => {
6693
6904
  problems: ["Not a source snapshot of format 1"]
6694
6905
  };
6695
6906
  let { kind: a, name: p, entries: m, files: h, dependencies: g } = t, _ = [];
6696
- if (c(a) || _.push(`Its kind is one of ${r.join(", ")}`), l(p) || _.push("Its name is a plugin type or \"runtime\": letters, digits, dashes and underscores"), !d(h) || !d(g)) return {
6907
+ if (c$1(a) || _.push(`Its kind is one of ${r.join(", ")}`), l$1(p) || _.push("Its name is a plugin type or \"runtime\": letters, digits, dashes and underscores"), !d(h) || !d(g)) return {
6697
6908
  ok: !1,
6698
6909
  problems: [..._, "Its files and dependencies are maps of text"]
6699
6910
  };
@@ -6702,7 +6913,7 @@ var p = (t, i) => {
6702
6913
  v.length > n.files && _.push(`It holds ${String(v.length)} files; a snapshot holds at most ${String(n.files)}`);
6703
6914
  let y = 0;
6704
6915
  return v.forEach((e) => {
6705
- let t = o(e);
6916
+ let t = o$1(e);
6706
6917
  if (t) {
6707
6918
  _.push(t);
6708
6919
  return;
@@ -6712,9 +6923,9 @@ var p = (t, i) => {
6712
6923
  _.push(`"${e}" is ${(r / 1024 / 1024).toFixed(1)} MB; a file of source is at most 4 MB`);
6713
6924
  return;
6714
6925
  }
6715
- let a = s(i(h[e]));
6926
+ let a = s$1(i(h[e]));
6716
6927
  a && _.push(`"${e}" holds ${a}: take it out of the code and give it to the project as a variable`);
6717
- }), y > n.totalBytes && _.push(`It holds ${(y / 1024 / 1024).toFixed(1)} MB of files; a snapshot holds at most 24 MB`), _.length > 0 || !c(a) || !l(p) || !u(m) ? {
6928
+ }), y > n.totalBytes && _.push(`It holds ${(y / 1024 / 1024).toFixed(1)} MB of files; a snapshot holds at most 24 MB`), _.length > 0 || !c$1(a) || !l$1(p) || !u(m) ? {
6718
6929
  ok: !1,
6719
6930
  problems: _
6720
6931
  } : {
@@ -7332,9 +7543,7 @@ var pull = async (options) => {
7332
7543
  if (!exported) return;
7333
7544
  const next = projectFromSpace(exported, origin.source);
7334
7545
  const format = await projectFormatter(root);
7335
- const given = /* @__PURE__ */ new Map();
7336
- for (const [file, text] of Object.entries(next.files)) given.set(file, Buffer.from(await format(file, text)));
7337
- Object.entries(next.binaries).forEach(([file, base64]) => given.set(file, Buffer.from(base64, "base64")));
7546
+ const given = await givenFiles(next, format);
7338
7547
  const missing = [];
7339
7548
  const untouched = /* @__PURE__ */ new Set();
7340
7549
  for (const { url, to } of next.downloads) {
@@ -7381,10 +7590,12 @@ var pull = async (options) => {
7381
7590
  if (dependencies.changed.length > 0) await fs.writeFile(packageFile, `${JSON.stringify(dependencies.manifest, null, 2)}\n`);
7382
7591
  const files = Object.fromEntries([...[...given].map(([file, bytes]) => [file, digest(bytes)]), ...[...untouched].flatMap((file) => origin.files[file] ? [[file, origin.files[file]]] : [])]);
7383
7592
  await writeOrigin(root, {
7384
- ...origin,
7593
+ format: origin.format,
7385
7594
  api,
7595
+ source: origin.source,
7386
7596
  space: exported.space,
7387
7597
  version: asked.version,
7598
+ ...exported.draft ? { draft: exported.draft } : {},
7388
7599
  files,
7389
7600
  downloads: Object.fromEntries(next.downloads.flatMap(({ url, to }) => {
7390
7601
  if (given.has(to)) return [[to, url]];
@@ -7418,6 +7629,202 @@ var pull = async (options) => {
7418
7629
  console.log("");
7419
7630
  };
7420
7631
  //#endregion
7632
+ //#region src/commands/askChecks.ts
7633
+ /** What a key does to the list: a new state, or the end of the question — taken or given up. */
7634
+ var checksAfter = (state, key) => {
7635
+ const count = state.checked.length;
7636
+ if (key.name === "escape" || key.ctrl && key.name === "c") return "cancel";
7637
+ if (key.name === "return" || key.name === "enter") return "done";
7638
+ if (key.name === "up" || key.name === "k") return {
7639
+ ...state,
7640
+ cursor: (state.cursor - 1 + count) % count
7641
+ };
7642
+ if (key.name === "down" || key.name === "j") return {
7643
+ ...state,
7644
+ cursor: (state.cursor + 1) % count
7645
+ };
7646
+ if (key.name === "space") return {
7647
+ ...state,
7648
+ checked: state.checked.map((on, index) => index === state.cursor ? !on : on)
7649
+ };
7650
+ if (key.name === "a") {
7651
+ const all = state.checked.every(Boolean);
7652
+ return {
7653
+ ...state,
7654
+ checked: state.checked.map(() => !all)
7655
+ };
7656
+ }
7657
+ return state;
7658
+ };
7659
+ /** The list as it is drawn: the question, a line per option, and the keys. */
7660
+ var renderChecks = (question, options, state) => [
7661
+ chalk.bold(question),
7662
+ ...options.map(({ label, hint }, index) => {
7663
+ return `${index === state.cursor ? chalk.cyan("›") : " "} ${state.checked[index] ? chalk.green("[x]") : "[ ]"} ${label}${hint ? chalk.dim(` ${hint}`) : ""}`;
7664
+ }),
7665
+ chalk.dim(" ↑/↓ move · space ticks · a all · enter takes · esc cancels")
7666
+ ];
7667
+ /** What is ticked once the person takes the list — `undefined` when they give it up. */
7668
+ var askChecks = async (question, options) => {
7669
+ const { stdin, stdout } = process;
7670
+ let state = {
7671
+ cursor: 0,
7672
+ checked: options.map(({ checked }) => checked)
7673
+ };
7674
+ let drawn = 0;
7675
+ const draw = () => {
7676
+ const lines = renderChecks(question, options, state);
7677
+ stdout.write(`${drawn ? `\x1b[${String(drawn)}A\x1b[0J` : ""}${lines.join("\n")}\n`);
7678
+ drawn = lines.length;
7679
+ };
7680
+ emitKeypressEvents(stdin);
7681
+ const wasRaw = stdin.isRaw;
7682
+ stdin.setRawMode(true);
7683
+ stdin.resume();
7684
+ stdout.write("\x1B[?25l");
7685
+ draw();
7686
+ return new Promise((resolve) => {
7687
+ const onKey = (_text, key) => {
7688
+ const next = checksAfter(state, key ?? {});
7689
+ if (next !== "done" && next !== "cancel") {
7690
+ state = next;
7691
+ draw();
7692
+ return;
7693
+ }
7694
+ stdin.off("keypress", onKey);
7695
+ stdin.setRawMode(wasRaw);
7696
+ stdin.pause();
7697
+ stdout.write("\x1B[?25h");
7698
+ resolve(next === "done" ? options.filter((_option, index) => state.checked[index]).map(({ value }) => value) : void 0);
7699
+ };
7700
+ stdin.on("keypress", onKey);
7701
+ });
7702
+ };
7703
+ //#endregion
7704
+ //#region src/commands/pushSpace.ts
7705
+ /** The JSON documents of a folder, by file: none when there is no such folder. */
7706
+ var readJsonFolder = async (dir) => {
7707
+ let files;
7708
+ try {
7709
+ files = (await fs.readdir(dir)).filter((file) => file.endsWith(".json")).sort();
7710
+ } catch {
7711
+ return;
7712
+ }
7713
+ return Promise.all(files.map(async (file) => ({
7714
+ file,
7715
+ value: JSON.parse(await fs.readFile(path.join(dir, file), "utf-8"))
7716
+ })));
7717
+ };
7718
+ /** The project's server actions, as its `src/actions.ts` serves them — `undefined` when it has no such module. */
7719
+ var projectActions = async (root) => {
7720
+ const file = path.join(root, "src/actions.ts");
7721
+ try {
7722
+ await fs.access(file);
7723
+ } catch {
7724
+ return;
7725
+ }
7726
+ const loaded = await import(pathToFileURL(file).href);
7727
+ const lookups = e$1(loaded) && e$1(loaded.lookups) ? loaded.lookups : void 0;
7728
+ if (!lookups || typeof lookups.listActions !== "function") return { problem: "src/actions.ts exports no `lookups` with a listActions: it is where the project’s actions are." };
7729
+ const listed = await lookups.listActions();
7730
+ if (!Array.isArray(listed)) return { problem: "src/actions.ts: lookups.listActions() answers no list." };
7731
+ return listed.flatMap((entry) => e$1(entry) && typeof entry.id === "string" && e$1(entry.document) ? [{
7732
+ identifier: entry.id,
7733
+ name: typeof entry.document.name === "string" ? entry.document.name : entry.id,
7734
+ document: entry.document
7735
+ }] : []);
7736
+ };
7737
+ /** The connectors in `src/connectors/`, one manifest a file — `undefined` when there is no such folder. */
7738
+ var projectConnectors = async (root) => {
7739
+ const read = await readJsonFolder(path.join(root, "src/connectors"));
7740
+ if (!read) return;
7741
+ const broken = read.find(({ value }) => !e$1(value) || typeof value.id !== "string" || !e$1(value.manifest));
7742
+ return broken ? { problem: `src/connectors/${broken.file} is not a connector: { "id", "name", "manifest" }.` } : read.flatMap(({ value }) => e$1(value) && typeof value.id === "string" && e$1(value.manifest) ? [{
7743
+ identifier: value.id,
7744
+ name: typeof value.name === "string" ? value.name : value.id,
7745
+ manifest: value.manifest
7746
+ }] : []);
7747
+ };
7748
+ /** The element types of plugins the project runs as they were built (`vendor/plugins/<type>/`): authored as known. */
7749
+ var builtPluginTypes = async (root) => {
7750
+ try {
7751
+ return (await fs.readdir(path.join(root, "vendor/plugins"), { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
7752
+ } catch {
7753
+ return [];
7754
+ }
7755
+ };
7756
+ var count = (entries, noun) => entries ? `${String(entries.length)} ${noun}${entries.length === 1 ? "" : "s"}` : `the space’s ${noun}s as they are`;
7757
+ /**
7758
+ * The project's space sent as the draft of the space the connection works in: refused when the draft moved on since
7759
+ * `base` (the draft the project last had), or — with no base — when the space holds work of its own; `force` takes it
7760
+ * anyway.
7761
+ */
7762
+ var pushSpaceOf = async (root, connection, space, { base, force }) => {
7763
+ const project = await loadProjectSpace(root);
7764
+ if ("problem" in project) {
7765
+ fail(project.problem);
7766
+ return { outcome: "failed" };
7767
+ }
7768
+ let documents;
7769
+ try {
7770
+ const { schema, style } = authorSpace(project.space, {
7771
+ plugins: project.plugins,
7772
+ pluginTypes: await builtPluginTypes(root)
7773
+ });
7774
+ documents = {
7775
+ schema,
7776
+ style
7777
+ };
7778
+ } catch (error) {
7779
+ fail(`The space does not author, so nothing was pushed — npm run author says why:\n${String(error instanceof Error ? error.message : error)}`);
7780
+ return { outcome: "failed" };
7781
+ }
7782
+ const [actions, connectors] = await Promise.all([projectActions(root), projectConnectors(root)]);
7783
+ for (const read of [actions, connectors]) if (read && "problem" in read) {
7784
+ fail(read.problem);
7785
+ return { outcome: "failed" };
7786
+ }
7787
+ const sent = {
7788
+ format: 1,
7789
+ documents,
7790
+ ...Array.isArray(actions) ? { actions } : {},
7791
+ ...Array.isArray(connectors) ? { connectors } : {},
7792
+ base,
7793
+ force
7794
+ };
7795
+ const answered = await authorizedRequest(connection, `/spaces/${String(space.id)}/import`, {
7796
+ method: "PUT",
7797
+ headers: { "Content-Type": "application/json" },
7798
+ body: JSON.stringify(sent)
7799
+ });
7800
+ if (!answered.ok) {
7801
+ fail(answered.error);
7802
+ return { outcome: "failed" };
7803
+ }
7804
+ const { status, data } = answered.value.reply;
7805
+ if (status === 409) {
7806
+ fail(data.refusal?.code === "DRAFT_NOT_EMPTY" ? `${space.name} already holds work of its own, and this project never had it: pushing would replace it.\nTake it out first (plitzi create --from ${space.permanentUrl}), or pass --force to replace it with this project.` : `${space.name}’s draft changed since this project last had it — in the builder, or from another copy.\nPull first (plitzi pull) and push again, or pass --force to replace it with this project.`);
7807
+ return { outcome: "failed" };
7808
+ }
7809
+ if (status === 422 && data.problems) {
7810
+ fail(`${space.name} would not take the space as it is:${data.problems.map((problem) => `\n ${problem}`).join("")}`);
7811
+ return { outcome: "failed" };
7812
+ }
7813
+ if (status !== 200 || !data.draft) {
7814
+ fail(data.error ?? `The space was not pushed (${String(status)}).`);
7815
+ return { outcome: "failed" };
7816
+ }
7817
+ if (!data.changed) return {
7818
+ outcome: "unchanged",
7819
+ draft: data.draft
7820
+ };
7821
+ console.log(chalk.green(`The space is ${space.name}’s draft now — its pages and styles, ${count(sent.actions, "action")}, ${count(sent.connectors, "connector")}.`) + chalk.dim(" Any builder open on it shows it now."));
7822
+ return {
7823
+ outcome: "pushed",
7824
+ draft: data.draft
7825
+ };
7826
+ };
7827
+ //#endregion
7421
7828
  //#region src/commands/keepSource.ts
7422
7829
  /**
7423
7830
  * A packed source snapshot sent beside what was just uploaded or pushed, and kept on the space (docs/en/projects-from-spaces.md): what lets
@@ -7445,7 +7852,6 @@ var keepSource = async (connection, spaceId, bytes) => {
7445
7852
  };
7446
7853
  //#endregion
7447
7854
  //#region src/commands/runtime.ts
7448
- var DEFAULT_ENTRY = path.join("src", "runtime.ts");
7449
7855
  var isPacker = (value) => e$1(value) && typeof value.packRuntime === "function";
7450
7856
  /** This project's own packer — read, since it is the project's. */
7451
7857
  var projectPacker = async (root) => {
@@ -7456,42 +7862,42 @@ var projectPacker = async (root) => {
7456
7862
  } catch {}
7457
7863
  fail("plitzi runtime packs it with this project’s own @plitzi/sdk-server:\n npm install @plitzi/sdk-server");
7458
7864
  };
7459
- /** Packs the project's runtime module and keeps it as the space's draft runtime — which a publish takes live. */
7460
- var pushRuntime = async (options) => {
7461
- const root = (await projectHere("whose runtime to push"))?.root;
7462
- const connection = root && await connectToSpace(options, "to push to");
7463
- if (!root || !connection || !connection.space) return;
7464
- const entry = path.resolve(root, options.entry ?? DEFAULT_ENTRY);
7865
+ var DEFAULT_RUNTIME_ENTRY = path.join("src", "runtime.ts");
7866
+ /**
7867
+ * The project's runtime module packed and kept as the draft runtime of the space the connection works in — which a
7868
+ * publish takes live — with the source it was packed from beside it.
7869
+ */
7870
+ var pushRuntimeOf = async (root, connection, space, entry) => {
7465
7871
  try {
7466
7872
  await fs.access(entry);
7467
7873
  } catch {
7468
7874
  fail(`There is no ${path.relative(root, entry)}: the module whose default export is defineRuntime({ start }).`);
7469
- return;
7875
+ return "failed";
7470
7876
  }
7471
7877
  const packer = await projectPacker(root);
7472
- if (!packer) return;
7878
+ if (!packer) return "failed";
7473
7879
  let bytes;
7474
7880
  try {
7475
7881
  bytes = await packer.packRuntime(entry);
7476
7882
  } catch (error) {
7477
7883
  fail(`It does not pack: ${error instanceof Error ? error.message : String(error)}`);
7478
- return;
7884
+ return "failed";
7479
7885
  }
7480
- const answered = await authorizedRequest(connection, `/spaces/${String(connection.space.id)}/runtime`, {
7886
+ const answered = await authorizedRequest(connection, `/spaces/${String(space.id)}/runtime`, {
7481
7887
  method: "PUT",
7482
7888
  headers: { "content-type": "application/octet-stream" },
7483
7889
  body: new Uint8Array(bytes)
7484
7890
  });
7485
7891
  if (!answered.ok) {
7486
7892
  fail(answered.error);
7487
- return;
7893
+ return "failed";
7488
7894
  }
7489
7895
  const { reply } = answered.value;
7490
7896
  if (reply.status !== 200 || !reply.data.digest) {
7491
7897
  fail(reply.data.error ?? `The runtime was not kept (${String(reply.status)}).`);
7492
- return;
7898
+ return "failed";
7493
7899
  }
7494
- console.log(chalk.green(`${connection.space.name}’s draft runtime is ${reply.data.digest.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KB).`) + chalk.dim(" It starts in a moment; publish the space to take it live."));
7900
+ console.log(chalk.green(`${space.name}’s draft runtime is ${reply.data.digest.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KB).`) + chalk.dim(" It starts in a moment; publish the space to take it live."));
7495
7901
  try {
7496
7902
  const source = await packSource({
7497
7903
  root,
@@ -7499,11 +7905,19 @@ var pushRuntime = async (options) => {
7499
7905
  name: "runtime",
7500
7906
  entries: [entry]
7501
7907
  });
7502
- await keepSource(answered.value.connection, connection.space.id, source.bytes);
7908
+ await keepSource(answered.value.connection, space.id, source.bytes);
7503
7909
  } catch (error) {
7504
7910
  console.log(chalk.yellow(" Its source is not kept, so a project taken from the space gets it built only:"));
7505
7911
  console.log(chalk.yellow(` ${error instanceof Error ? error.message : String(error)}`));
7506
7912
  }
7913
+ return "pushed";
7914
+ };
7915
+ /** Packs the project's runtime module and keeps it as the space's draft runtime — which a publish takes live. */
7916
+ var pushRuntime = async (options) => {
7917
+ const root = (await projectHere("whose runtime to push"))?.root;
7918
+ const connection = root && await connectToSpace(options, "to push to");
7919
+ if (!root || !connection || !connection.space) return;
7920
+ await pushRuntimeOf(root, connection, connection.space, path.resolve(root, options.entry ?? DEFAULT_RUNTIME_ENTRY));
7507
7921
  };
7508
7922
  var readRuntime = async (connection, spaceId) => {
7509
7923
  const answered = await authorizedRequest(connection, `/spaces/${String(spaceId)}/runtime`);
@@ -7644,55 +8058,840 @@ var unsetRuntimeVariable = async (name, options) => {
7644
8058
  console.log(chalk.green(`${name} removed — the runtime starts again without it.`));
7645
8059
  };
7646
8060
  //#endregion
7647
- //#region src/commands/shot.ts
7648
- /** A region moves when more than this share of it changed between two frames: below is anti-aliasing and carets. */
7649
- var MOVING_PERCENT = .5;
7650
- /** A selector, or an element's name — what `data-plitzi-el` carries. */
7651
- var selectorOf = (target) => /^[#.[]/.test(target) ? target : `[data-plitzi-el="${target}"]`;
7652
- var pictureOf = async (browser, url, view) => {
7653
- const page = await browser.newPage({
7654
- viewport: {
7655
- width: view.width,
7656
- height: view.height
7657
- },
7658
- colorScheme: view.scheme,
7659
- reducedMotion: view.reducedMotion ? "reduce" : "no-preference"
7660
- });
7661
- if (!await page.goto(url, { waitUntil: "networkidle" }).catch(() => null)) return { problem: `Nothing answers at ${url}.` };
7662
- if (view.waitFor) {
7663
- if (!await page.waitForSelector(selectorOf(view.waitFor), { timeout: 1e4 }).catch(() => null)) return { problem: `${view.waitFor} did not appear on ${url} within 10 s.` };
7664
- }
7665
- for (const target of [view.scrollTo, view.clip]) if (target && !await page.waitForSelector(selectorOf(target), { timeout: 1e4 }).catch(() => null)) return { problem: `${target} is not on ${url}.` };
7666
- if (view.scrollTo) await page.locator(selectorOf(view.scrollTo)).first().scrollIntoViewIfNeeded();
7667
- return {
7668
- page,
7669
- png: await capture(page, framing(view))
7670
- };
7671
- };
7672
- var framing = (view) => {
7673
- if (view.clip) return {
7674
- kind: "element",
7675
- selector: selectorOf(view.clip)
8061
+ //#region src/commands/uploadPlugin.ts
8062
+ /** The zips `plitzi pack plugin` leaves: at a plugin package's root, or under `dist/plugins` of any other project. */
8063
+ var packedZips = async (root) => {
8064
+ const inFolder = async (dir) => {
8065
+ try {
8066
+ return (await fs.readdir(dir)).filter((file) => file.endsWith(".zip")).map((file) => path.join(dir, file));
8067
+ } catch {
8068
+ return [];
8069
+ }
7676
8070
  };
7677
- return view.viewport || view.scrollTo ? { kind: "viewport" } : { kind: "page" };
7678
- };
7679
- var capture = (page, how) => {
7680
- if (how.kind === "element") return page.locator(how.selector).first().screenshot();
7681
- return page.screenshot({ fullPage: how.kind === "page" });
7682
- };
7683
- var regionsText = (regions) => regions.map((region) => `${region.name} ${String(region.changed)}%`).join(", ");
7684
- /** Compared frame to frame: the most each region changed between two that follow each other. */
7685
- var framesMoved = (diffs) => {
7686
- const most = /* @__PURE__ */ new Map();
7687
- for (const diff of diffs) for (const region of diff.regions) most.set(region.name, Math.max(most.get(region.name) ?? 0, region.changed));
7688
- return [...most].map(([name, changed]) => ({
7689
- name,
7690
- changed
7691
- }));
8071
+ const zips = [...await inFolder(root), ...await inFolder(path.join(root, "dist/plugins"))];
8072
+ const modified = await Promise.all(zips.map(async (zip) => (await fs.stat(zip)).mtimeMs));
8073
+ return zips.map((zip, index) => ({
8074
+ zip,
8075
+ at: modified[index]
8076
+ })).sort((a, b) => b.at - a.at).map(({ zip }) => zip);
7692
8077
  };
7693
- var shot = async (route, options) => {
7694
- const project = await projectHere("whose page to picture");
7695
- if (!project) return;
8078
+ /** The zip named, or the one packed here — asked for at a terminal when there are several. */
8079
+ var chooseZip = async (given) => {
8080
+ if (given) return path.resolve(given);
8081
+ const project = await findProject(process.cwd());
8082
+ const zips = project ? await packedZips(project.root) : [];
8083
+ if (zips.length === 0) {
8084
+ fail("No packed plugin here. Build one with plitzi pack plugin, or name it: plitzi upload plugin <file.zip>.");
8085
+ return;
8086
+ }
8087
+ if (zips.length === 1) return zips[0];
8088
+ const choices = zips.map((zip) => path.relative(process.cwd(), zip));
8089
+ if (!atTerminal()) {
8090
+ refuseWithoutTerminal("upload", [{
8091
+ flag: "<zip> (the argument)",
8092
+ choices,
8093
+ question: "Which packed plugin is to be uploaded?"
8094
+ }], "plitzi upload plugin stopped before uploading anything: which plugin goes to the space is a choice");
8095
+ return;
8096
+ }
8097
+ const rl = readline.createInterface({
8098
+ input: process.stdin,
8099
+ output: process.stdout
8100
+ });
8101
+ try {
8102
+ return await askPick(rl, "Which packed plugin? The newest is first.", zips.map((zip, index) => ({
8103
+ label: choices[index],
8104
+ value: zip
8105
+ })));
8106
+ } finally {
8107
+ rl.close();
8108
+ }
8109
+ };
8110
+ /**
8111
+ * The manifest a zip carries at its root, read here before anything is sent: a zip that is not a plugin is refused by
8112
+ * the platform too, but only after it has travelled.
8113
+ */
8114
+ var manifestOf = (zip) => {
8115
+ try {
8116
+ const entry = Object.values(unzipSync(zip, { filter: (file) => file.name === "plugin-manifest.json" })).at(0);
8117
+ const parsed = entry ? JSON.parse(new TextDecoder().decode(entry)) : {};
8118
+ return typeof parsed.root === "string" && parsed.root ? {
8119
+ root: parsed.root,
8120
+ version: typeof parsed.version === "string" ? parsed.version : ""
8121
+ } : void 0;
8122
+ } catch {
8123
+ return;
8124
+ }
8125
+ };
8126
+ /**
8127
+ * The public bucket a plugin goes in — the one `--cdn` and `--bucket` name, the only one there is, or the one picked.
8128
+ * A private bucket keeps server code and has no public address, so it is never one.
8129
+ */
8130
+ var chooseTarget = async (cdns, { cdn: givenCdn, bucket: givenBucket }, spaceName) => {
8131
+ if (givenCdn && !cdns.some((cdn) => cdn.identifier === givenCdn)) {
8132
+ fail(`${spaceName} has no CDN "${givenCdn}". Its CDNs: ${cdns.map((cdn) => cdn.identifier).join(", ")}.`);
8133
+ return;
8134
+ }
8135
+ const candidates = cdns.filter((cdn) => !givenCdn || cdn.identifier === givenCdn).flatMap((cdn) => cdn.buckets.map((bucket) => ({
8136
+ cdn,
8137
+ bucket
8138
+ })));
8139
+ if (givenBucket) {
8140
+ const named = candidates.find(({ bucket }) => bucket.identifier === givenBucket);
8141
+ if (!named) {
8142
+ fail(`${spaceName} has no bucket "${givenBucket}"${givenCdn ? ` in the CDN "${givenCdn}"` : ""}.`);
8143
+ return;
8144
+ }
8145
+ if (named.bucket.visibility === "private") {
8146
+ fail(`"${givenBucket}" is a private bucket: it has no public address, so no page could load a plugin from it.`);
8147
+ return;
8148
+ }
8149
+ return named;
8150
+ }
8151
+ const targets = candidates.filter(({ bucket }) => bucket.visibility === "public");
8152
+ if (targets.length === 0) {
8153
+ fail(`${spaceName} has no public bucket to put a plugin in. Add one to a CDN in the builder, under Resources.`);
8154
+ return;
8155
+ }
8156
+ if (targets.length === 1) return targets[0];
8157
+ if (!atTerminal()) {
8158
+ refuseWithoutTerminal("upload", [{
8159
+ flag: "--bucket",
8160
+ choices: targets.map(({ bucket }) => bucket.identifier),
8161
+ question: `Which of ${spaceName}'s public buckets does the plugin go in? (narrow with --cdn)`
8162
+ }], "plitzi upload plugin stopped before uploading anything: which bucket serves the plugin is a choice");
8163
+ return;
8164
+ }
8165
+ const rl = readline.createInterface({
8166
+ input: process.stdin,
8167
+ output: process.stdout
8168
+ });
8169
+ try {
8170
+ return await askPick(rl, `Which of ${spaceName}'s public buckets does the plugin go in?`, targets.map((target) => ({
8171
+ label: `${target.cdn.name} — ${target.bucket.name} ${chalk.dim(`${target.bucket.identifier} · ${target.bucket.domain}`)}`,
8172
+ value: target
8173
+ })));
8174
+ } finally {
8175
+ rl.close();
8176
+ }
8177
+ };
8178
+ /** The space's CDNs and their buckets, asked for — or why not, in words. */
8179
+ var listCdns = async (connection, space) => {
8180
+ const listed = await authorizedRequest(connection, `/spaces/${space.id}/cdns`);
8181
+ if (!listed.ok) return listed;
8182
+ const { reply } = listed.value;
8183
+ if (reply.status !== 200) return {
8184
+ ok: false,
8185
+ error: reply.data.error ?? `Could not list ${space.name}'s CDNs (${reply.status}).`
8186
+ };
8187
+ return {
8188
+ ok: true,
8189
+ value: {
8190
+ connection: listed.value.connection,
8191
+ cdns: reply.data.cdns ?? []
8192
+ }
8193
+ };
8194
+ };
8195
+ /**
8196
+ * A packed plugin uploaded to a bucket of the space and installed there — and its source kept beside it, when it was
8197
+ * packed with one.
8198
+ */
8199
+ var uploadZip = async (connection, space, { zip, filename, target, source }) => {
8200
+ const manifest = manifestOf(zip);
8201
+ if (!manifest) {
8202
+ fail(`${filename} is not a plugin: it has no plugin-manifest.json at its root. Build it with plitzi pack plugin.`);
8203
+ return "failed";
8204
+ }
8205
+ const { cdn, bucket } = target;
8206
+ const label = `${manifest.root}${manifest.version ? ` ${manifest.version}` : ""}`;
8207
+ console.log(`\nUploading ${chalk.bold(label)} to ${chalk.bold(space.name)}, in ${cdn.name} — ${bucket.name}…`);
8208
+ const query = new URLSearchParams({
8209
+ filename,
8210
+ bucket: bucket.identifier
8211
+ });
8212
+ const uploaded = await authorizedRequest(connection, `/spaces/${space.id}/cdns/${encodeURIComponent(cdn.identifier)}/plugins?${query}`, {
8213
+ method: "POST",
8214
+ headers: { "Content-Type": "application/zip" },
8215
+ body: new Uint8Array(zip)
8216
+ });
8217
+ if (!uploaded.ok) {
8218
+ fail(uploaded.error);
8219
+ return "failed";
8220
+ }
8221
+ const { status, data } = uploaded.value.reply;
8222
+ if (status === 413) {
8223
+ fail(`${filename} is larger than ${connection.api} accepts.`);
8224
+ return "failed";
8225
+ }
8226
+ if (status !== 201) {
8227
+ fail(data.error ?? `The upload was refused (${status}).`);
8228
+ return "failed";
8229
+ }
8230
+ const installed = data.installed === "updated" ? `now loads ${label}` : `loads ${label} from now on`;
8231
+ console.log(chalk.green(`${space.name} ${installed}.`));
8232
+ if (data.resource?.path) console.log(chalk.dim(` ${data.resource.path}`));
8233
+ console.log(chalk.dim(" Any builder open on the space loads it now."));
8234
+ if (source) await keepSource(uploaded.value.connection, space.id, source);
8235
+ return "pushed";
8236
+ };
8237
+ var uploadPluginCommand = async (zipGiven, options) => {
8238
+ const api = await apiFor(options);
8239
+ const zipPath = api ? await chooseZip(zipGiven) : void 0;
8240
+ if (!api || !zipPath) return;
8241
+ let zip;
8242
+ try {
8243
+ zip = await fs.readFile(zipPath);
8244
+ } catch {
8245
+ fail(`Could not read ${zipPath}.`);
8246
+ return;
8247
+ }
8248
+ if (!manifestOf(zip)) {
8249
+ fail(`${path.basename(zipPath)} is not a plugin: it has no plugin-manifest.json at its root. Build it with plitzi pack plugin.`);
8250
+ return;
8251
+ }
8252
+ const connection = await connectionWithSpace(api, "to upload to");
8253
+ if (!connection?.space) return;
8254
+ const { space } = connection;
8255
+ const listed = await listCdns(connection, space);
8256
+ if (!listed.ok) {
8257
+ fail(listed.error);
8258
+ return;
8259
+ }
8260
+ const target = await chooseTarget(listed.value.cdns, options, space.name);
8261
+ if (!target) return;
8262
+ const source = await fs.readFile(sourceFileOf(zipPath)).catch(() => void 0);
8263
+ await uploadZip(listed.value.connection, space, {
8264
+ zip,
8265
+ filename: path.basename(zipPath),
8266
+ target,
8267
+ ...source ? { source } : {}
8268
+ });
8269
+ console.log("");
8270
+ };
8271
+ //#endregion
8272
+ //#region src/commands/push.ts
8273
+ /**
8274
+ * `plitzi push`: the project put back on the space it works with — the way back of `plitzi pull`
8275
+ * (docs/en/projects-from-spaces.md). Whatever changed since the project last had the space, or the parts named:
8276
+ *
8277
+ * - `plugins` — each plugin whose source changed, packed and uploaded to the space's CDN, as `plitzi upload plugin`;
8278
+ * - `functions` — `functions/`, as `plitzi functions push`;
8279
+ * - `runtime` — the runtime module, packed, as `plitzi runtime push`;
8280
+ * - `space` — the pages, styles, actions and connectors, as the space's draft.
8281
+ *
8282
+ * In that order: the pages name the plugins' elements and the actions name the functions' tasks, so what is named goes
8283
+ * up before what names it. A part that fails stops the push there.
8284
+ *
8285
+ * Always to the space the CLI is connected to, which must be the one the project came from; always the draft, never a
8286
+ * published environment. A draft edited in the builder since the project last had it is never replaced unseen: the push
8287
+ * is refused, and `--force` replaces it.
8288
+ */
8289
+ var PUSH_PARTS = [
8290
+ "space",
8291
+ "functions",
8292
+ "runtime",
8293
+ "plugins"
8294
+ ];
8295
+ /** What goes up first: what the rest names. */
8296
+ var ORDER = [
8297
+ "plugins",
8298
+ "functions",
8299
+ "runtime",
8300
+ "space"
8301
+ ];
8302
+ var isPart = (value) => PUSH_PARTS.some((part) => part === value);
8303
+ var exists = async (file) => {
8304
+ try {
8305
+ await fs.access(file);
8306
+ return true;
8307
+ } catch {
8308
+ return false;
8309
+ }
8310
+ };
8311
+ /** Every file under a folder of the project, by its path in the project: none when there is no such folder. */
8312
+ var filesUnder = async (root, folder) => {
8313
+ try {
8314
+ return (await fs.readdir(path.join(root, folder), {
8315
+ recursive: true,
8316
+ withFileTypes: true
8317
+ })).filter((entry) => entry.isFile()).map((entry) => path.relative(root, path.join(entry.parentPath, entry.name)));
8318
+ } catch {
8319
+ return [];
8320
+ }
8321
+ };
8322
+ /**
8323
+ * Whether any of the files changed since the project last had the space: one not recorded, or not as it was recorded —
8324
+ * formatting aside. Everything has, for a project that never had it.
8325
+ */
8326
+ var changedSince = async (root, origin, files, format) => {
8327
+ if (!origin) return true;
8328
+ for (const file of files) {
8329
+ const was = origin.files[file];
8330
+ const here = await digestsOf(root, file, format);
8331
+ if (!was || !here || here.raw !== was && here.formatted !== was) return true;
8332
+ }
8333
+ return false;
8334
+ };
8335
+ /** The project's files a build of the runtime or of a plugin is made of — none when that cannot be told. */
8336
+ var closureOf = async (root, kind, name, entries) => {
8337
+ try {
8338
+ return Object.keys((await packSource({
8339
+ root,
8340
+ kind,
8341
+ name,
8342
+ entries
8343
+ })).snapshot.files);
8344
+ } catch {
8345
+ return;
8346
+ }
8347
+ };
8348
+ /** The files the space part is made of, as `create --from` lays them out: the pages, the actions, the connectors. */
8349
+ var SPACE_FILES = ["src/space.ts", "src/actions.ts"];
8350
+ var SPACE_FOLDERS = [
8351
+ "src/space/",
8352
+ "src/actions/",
8353
+ "src/connectors/"
8354
+ ];
8355
+ var isSpaceFile = (file) => SPACE_FILES.includes(file) || SPACE_FOLDERS.some((folder) => file.startsWith(folder));
8356
+ /** A plugin's type as the project's folder spells it: `src/plugins/SeatPicker` is `seatPicker`. */
8357
+ var typeOfFolder = (folder) => {
8358
+ const name = path.basename(folder);
8359
+ return `${name.charAt(0).toLowerCase()}${name.slice(1)}`;
8360
+ };
8361
+ /**
8362
+ * What the project can send, and whether each changed since the project last had the space. The plugins are grouped as
8363
+ * the space keeps them — a plugin can be several elements — and an element folder the space has no plugin of is a
8364
+ * plugin of its own.
8365
+ */
8366
+ var survey = async (root, { origin, source, before, spaceId, format }) => {
8367
+ const found = [];
8368
+ if (source === "local" && await exists(path.join(root, "src/space.ts"))) {
8369
+ const files = [...await Promise.all(SPACE_FILES.map(async (file) => await exists(path.join(root, file)) ? [file] : [])), ...await Promise.all(SPACE_FOLDERS.map((folder) => filesUnder(root, folder)))].flat();
8370
+ const gone = Object.keys(origin?.files ?? {}).some((file) => isSpaceFile(file) && !files.includes(file));
8371
+ found.push({
8372
+ part: "space",
8373
+ label: "space — its pages, styles, actions and connectors",
8374
+ changed: gone || await changedSince(root, origin, files.filter((file) => isSpaceFile(file)), format)
8375
+ });
8376
+ }
8377
+ const functions = await functionsChange(root, spaceId);
8378
+ if (functions !== "none") found.push({
8379
+ part: "functions",
8380
+ label: "functions — functions/",
8381
+ changed: functions === "changed"
8382
+ });
8383
+ const entries = before && projectEntries(before);
8384
+ const candidates = [...entries?.runtime ? [entries.runtime] : [], DEFAULT_RUNTIME_ENTRY];
8385
+ const present = await Promise.all(candidates.map((entry) => exists(path.join(root, entry))));
8386
+ const runtimeEntry = candidates.find((_entry, index) => present[index]);
8387
+ if (runtimeEntry) {
8388
+ const entry = path.join(root, runtimeEntry);
8389
+ const files = await closureOf(root, "runtime", "runtime", [entry]);
8390
+ found.push({
8391
+ part: "runtime",
8392
+ label: `runtime — ${runtimeEntry}`,
8393
+ changed: !files || await changedSince(root, origin, files, format),
8394
+ entry,
8395
+ files: files ?? []
8396
+ });
8397
+ }
8398
+ const folders = await elementFolders({ root });
8399
+ const grouped = (entries?.plugins ?? []).map(({ type, entries: starts }) => ({
8400
+ type,
8401
+ folders: starts.map((start) => path.join(root, path.dirname(start)))
8402
+ })).filter((group) => group.folders.every((folder) => folders.includes(folder)));
8403
+ const alone = folders.filter((folder) => !grouped.some((group) => group.folders.includes(folder))).map((folder) => ({
8404
+ type: typeOfFolder(folder),
8405
+ folders: [folder]
8406
+ }));
8407
+ for (const { type, folders: group } of [...grouped, ...alone]) {
8408
+ const files = await closureOf(root, "plugin", type, group.map((folder) => path.join(folder, "index.ts")));
8409
+ found.push({
8410
+ part: "plugins",
8411
+ label: `plugin ${type} — ${group.map((folder) => path.relative(root, folder)).join(", ")}`,
8412
+ changed: !files || await changedSince(root, origin, files, format),
8413
+ folders: group,
8414
+ files: files ?? []
8415
+ });
8416
+ }
8417
+ return found;
8418
+ };
8419
+ /**
8420
+ * What to send: the parts named; at a terminal, what the person ticks — what changed, ticked to begin with; with nobody
8421
+ * there, what changed. `undefined` when there is nothing to send, said why.
8422
+ */
8423
+ var choose = async (found, asked, spaceName) => {
8424
+ if (asked.length > 0) {
8425
+ const unknown = asked.filter((part) => !isPart(part));
8426
+ if (unknown.length > 0) {
8427
+ fail(`plitzi push sends ${PUSH_PARTS.join(", ")} — not ${unknown.join(", ")}.`);
8428
+ return;
8429
+ }
8430
+ const missing = asked.filter((part) => !found.some((item) => item.part === part));
8431
+ if (missing.length > 0) {
8432
+ fail(`This project has no ${missing.join(", ")} to push.`);
8433
+ return;
8434
+ }
8435
+ return found.filter((item) => asked.includes(item.part));
8436
+ }
8437
+ if (found.length === 0) {
8438
+ fail("This project has nothing to push: no src/space.ts, functions/, runtime or plugin.");
8439
+ return;
8440
+ }
8441
+ if (!atTerminal()) return found.filter((item) => item.changed);
8442
+ const chosen = await askChecks(`What goes to ${spaceName}? Ticked: what changed since the project last had it.`, found.map((item) => ({
8443
+ label: item.label,
8444
+ value: item,
8445
+ checked: item.changed,
8446
+ hint: item.changed ? "changed" : "unchanged"
8447
+ })));
8448
+ if (!chosen) console.log("Nothing was pushed.");
8449
+ return chosen;
8450
+ };
8451
+ /** A plugin packed as `plitzi pack plugin` packs it, and uploaded and installed as `plitzi upload plugin` does. */
8452
+ var pushPlugin = async (root, connection, space, { folders, target, version }) => {
8453
+ const base = fileNameOf(folders[0]);
8454
+ const zip = path.join(root, "dist/plugins", `${base}-${version}.zip`);
8455
+ let packed;
8456
+ try {
8457
+ packed = await packPlugin({
8458
+ root,
8459
+ source: {
8460
+ kind: "elements",
8461
+ folders
8462
+ },
8463
+ base,
8464
+ version,
8465
+ outDir: path.join(root, "dist/plugins", base),
8466
+ zip
8467
+ });
8468
+ } catch (error) {
8469
+ if (!(error instanceof PackError)) throw error;
8470
+ fail(error.message);
8471
+ return "failed";
8472
+ }
8473
+ if ("problem" in packed.source) {
8474
+ console.log(chalk.yellow(` Its source is not kept, so the space can be taken out with ${base} built only:`));
8475
+ console.log(chalk.yellow(` ${packed.source.problem}`));
8476
+ }
8477
+ return uploadZip(connection, space, {
8478
+ zip: await fs.readFile(zip),
8479
+ filename: path.basename(zip),
8480
+ target,
8481
+ ..."file" in packed.source ? { source: await fs.readFile(packed.source.file) } : {}
8482
+ });
8483
+ };
8484
+ /**
8485
+ * `.plitzi/space.json` as the space now holds the project, so `plitzi pull` compares with it from here on: the files of
8486
+ * the parts sent recorded as the space gives them back, the rest as they were — a page changed in the builder and not
8487
+ * pushed over is still the builder's change to the next pull. A project that never had the space records it whole.
8488
+ */
8489
+ var recordPush = async (root, connection, space, { origin, source, found, sent, draft }) => {
8490
+ const exported = await requestExport(connection, String(space.id), {
8491
+ source,
8492
+ version: { environment: "main" },
8493
+ name: space.name
8494
+ });
8495
+ if (!exported.ok) {
8496
+ console.log(chalk.yellow(`Pushed, but .plitzi/space.json is not brought up to date — ${exported.error}\nThe next plitzi pull compares with what it recorded before.`));
8497
+ return;
8498
+ }
8499
+ const format = await projectFormatter(root);
8500
+ const next = projectFromSpace(exported.value, source);
8501
+ const given = await givenFiles(next, format);
8502
+ const parts = new Set(sent.map((item) => item.part));
8503
+ const codeOf = (items) => items.flatMap((item) => item.part === "runtime" || item.part === "plugins" ? item.files : []);
8504
+ const built = new Set(codeOf(sent));
8505
+ const code = /* @__PURE__ */ new Set([...codeOf(found), ...projectEntries(exported.value).files]);
8506
+ const downloads = new Set(Object.keys(origin?.downloads ?? {}));
8507
+ const isSent = (file) => {
8508
+ if (!origin || built.has(file)) return true;
8509
+ if (file.startsWith("functions/")) return parts.has("functions");
8510
+ return !code.has(file) && !file.startsWith("vendor/") && parts.has("space");
8511
+ };
8512
+ const files = Object.fromEntries(Object.entries(origin?.files ?? {}).filter(([file]) => downloads.has(file) || !isSent(file) || given.has(file)));
8513
+ given.forEach((bytes, file) => {
8514
+ if (isSent(file)) files[file] = digest(bytes);
8515
+ });
8516
+ const recorded = draft ?? origin?.draft;
8517
+ await writeOrigin(root, {
8518
+ format: 1,
8519
+ api: connection.api,
8520
+ space: exported.value.space,
8521
+ source,
8522
+ version: { environment: "main" },
8523
+ ...recorded ? { draft: recorded } : {},
8524
+ files,
8525
+ downloads: origin?.downloads ?? {},
8526
+ dependencies: !origin || parts.has("runtime") || parts.has("plugins") ? next.dependencies : origin.dependencies
8527
+ });
8528
+ };
8529
+ var push = async (asked, options) => {
8530
+ const project = await findProject(process.cwd());
8531
+ if (!project) {
8532
+ fail("There is no package.json here or above: run plitzi push in the project to send.");
8533
+ return;
8534
+ }
8535
+ if (project.plitzi?.kind === "plugin") {
8536
+ fail("This is a plugin package: plitzi pack plugin and plitzi upload plugin put it on a space.");
8537
+ return;
8538
+ }
8539
+ const { root } = project;
8540
+ const origin = await readOrigin(root);
8541
+ if (origin && origin.version.environment !== "main") {
8542
+ fail(`This project follows ${versionLabel(origin.version)} of ${origin.space.name}, and a push writes the draft.\nFollow the draft first: plitzi pull --environment main.`);
8543
+ return;
8544
+ }
8545
+ const connection = await connectToSpace({
8546
+ ...options,
8547
+ api: options.api ?? origin?.api
8548
+ }, "to push to");
8549
+ const space = connection?.space;
8550
+ if (!connection || !space) return;
8551
+ if (origin && origin.space.id !== space.id) {
8552
+ fail(`This project is ${origin.space.name}’s, and the CLI works in ${space.name}. Switch to it with plitzi space, and push again.`);
8553
+ return;
8554
+ }
8555
+ const source = origin?.source ?? (project.plitzi?.kind === "project" ? project.plitzi.source : "local");
8556
+ let before;
8557
+ if (origin) {
8558
+ const exported = await requestExport(connection, String(space.id), {
8559
+ source: "cloud",
8560
+ version: { environment: "main" },
8561
+ name: space.name
8562
+ });
8563
+ if (!exported.ok) {
8564
+ fail(exported.error);
8565
+ return;
8566
+ }
8567
+ before = exported.value;
8568
+ }
8569
+ const format = await projectFormatter(root);
8570
+ const found = await survey(root, {
8571
+ origin,
8572
+ source,
8573
+ before,
8574
+ spaceId: space.id,
8575
+ format
8576
+ });
8577
+ const chosen = await choose(found, asked, space.name);
8578
+ if (!chosen) return;
8579
+ if (chosen.length === 0) {
8580
+ console.log(`Nothing to push: the project is what ${space.name} last gave it.`);
8581
+ return;
8582
+ }
8583
+ const force = options.force ?? false;
8584
+ const sent = [];
8585
+ const settled = [];
8586
+ const unchanged = [];
8587
+ let draft;
8588
+ let target;
8589
+ const version = (await readPackageJson(root))?.version ?? "0.0.0";
8590
+ for (const item of [...chosen].sort((a, b) => ORDER.indexOf(a.part) - ORDER.indexOf(b.part))) {
8591
+ let outcome;
8592
+ if (item.part === "plugins") {
8593
+ if (!target) {
8594
+ const listed = await listCdns(connection, space);
8595
+ if (!listed.ok) {
8596
+ fail(listed.error);
8597
+ return;
8598
+ }
8599
+ target = await chooseTarget(listed.value.cdns, options, space.name);
8600
+ if (!target) return;
8601
+ }
8602
+ outcome = await pushPlugin(root, connection, space, {
8603
+ folders: item.folders,
8604
+ target,
8605
+ version
8606
+ });
8607
+ } else if (item.part === "functions") outcome = await pushFunctionsOf(root, connection, space, { force });
8608
+ else if (item.part === "runtime") outcome = await pushRuntimeOf(root, connection, space, item.entry);
8609
+ else {
8610
+ const pushed = await pushSpaceOf(root, connection, space, {
8611
+ base: origin?.draft ?? null,
8612
+ force
8613
+ });
8614
+ outcome = pushed.outcome;
8615
+ draft = pushed.draft;
8616
+ }
8617
+ if (outcome === "failed") {
8618
+ if (sent.length > 0) {
8619
+ console.log(chalk.yellow(`\nPushed before it stopped: ${sent.map((done) => done.label).join("; ")}.`));
8620
+ await recordPush(root, connection, space, {
8621
+ origin,
8622
+ source,
8623
+ found,
8624
+ sent: settled,
8625
+ draft
8626
+ });
8627
+ }
8628
+ return;
8629
+ }
8630
+ if (outcome === "pushed") sent.push(item);
8631
+ else unchanged.push(item.label);
8632
+ if (outcome === "pushed" || item.part === "space") settled.push(item);
8633
+ }
8634
+ if (settled.length > 0) await recordPush(root, connection, space, {
8635
+ origin,
8636
+ source,
8637
+ found,
8638
+ sent: settled,
8639
+ draft
8640
+ });
8641
+ if (unchanged.length > 0) console.log(chalk.dim(`Already ${space.name}’s: ${unchanged.join("; ")}.`));
8642
+ if (sent.length > 0) console.log(chalk.green(`\nPushed to ${space.name}’s draft.`) + chalk.dim(" Publish it in the builder to take it live."));
8643
+ };
8644
+ //#endregion
8645
+ //#region ../../packages/sdk-shared/dist/schema/motion.mjs
8646
+ var t = [
8647
+ "fade",
8648
+ "fade-up",
8649
+ "fade-down",
8650
+ "slide-left",
8651
+ "slide-right",
8652
+ "slide-up",
8653
+ "scale",
8654
+ "zoom-in",
8655
+ "zoom-out",
8656
+ "pop",
8657
+ "bounce-in",
8658
+ "tilt",
8659
+ "spin-in"
8660
+ ];
8661
+ var o = {
8662
+ fade: [{
8663
+ at: 0,
8664
+ opacity: 0
8665
+ }],
8666
+ "fade-up": [{
8667
+ at: 0,
8668
+ opacity: 0,
8669
+ translate: "0 24px"
8670
+ }],
8671
+ "fade-down": [{
8672
+ at: 0,
8673
+ opacity: 0,
8674
+ translate: "0 -24px"
8675
+ }],
8676
+ "slide-left": [{
8677
+ at: 0,
8678
+ opacity: 0,
8679
+ translate: "32px 0"
8680
+ }],
8681
+ "slide-right": [{
8682
+ at: 0,
8683
+ opacity: 0,
8684
+ translate: "-32px 0"
8685
+ }],
8686
+ "slide-up": [{
8687
+ at: 0,
8688
+ opacity: 0,
8689
+ translate: "0 72px"
8690
+ }],
8691
+ scale: [{
8692
+ at: 0,
8693
+ opacity: 0,
8694
+ scale: "0.94"
8695
+ }],
8696
+ "zoom-in": [{
8697
+ at: 0,
8698
+ opacity: 0,
8699
+ scale: "0.6"
8700
+ }],
8701
+ "zoom-out": [{
8702
+ at: 0,
8703
+ opacity: 0,
8704
+ scale: "1.25"
8705
+ }],
8706
+ pop: [
8707
+ {
8708
+ at: 0,
8709
+ opacity: 0,
8710
+ scale: "0.5"
8711
+ },
8712
+ {
8713
+ at: 55,
8714
+ opacity: 1,
8715
+ scale: "1.08"
8716
+ },
8717
+ {
8718
+ at: 78,
8719
+ scale: "0.97"
8720
+ }
8721
+ ],
8722
+ "bounce-in": [
8723
+ {
8724
+ at: 0,
8725
+ opacity: 0,
8726
+ translate: "0 -64px"
8727
+ },
8728
+ {
8729
+ at: 55,
8730
+ opacity: 1,
8731
+ translate: "0 10px"
8732
+ },
8733
+ {
8734
+ at: 75,
8735
+ translate: "0 -5px"
8736
+ },
8737
+ {
8738
+ at: 90,
8739
+ translate: "0 2px"
8740
+ }
8741
+ ],
8742
+ tilt: [{
8743
+ at: 0,
8744
+ opacity: 0,
8745
+ translate: "0 32px",
8746
+ rotate: "-6deg"
8747
+ }],
8748
+ "spin-in": [{
8749
+ at: 0,
8750
+ opacity: 0,
8751
+ scale: "0.4",
8752
+ rotate: "-180deg"
8753
+ }]
8754
+ };
8755
+ Object.fromEntries(t.map((e) => {
8756
+ let [{ at: t, ...n }] = o[e];
8757
+ return [e, n];
8758
+ }));
8759
+ var c = (e, t) => {
8760
+ let n = t * 2 * Math.PI, r = Math.round(Math.sin(n) * e * 10) / 10, i = Math.round(-Math.cos(n) * e * 10) / 10;
8761
+ return `translate(${String(r)}px, ${String(i)}px)`;
8762
+ };
8763
+ Array.from({ length: 9 }, (e, t) => ({
8764
+ at: t * 100 / 8,
8765
+ transform: c(6, t / 8)
8766
+ }));
8767
+ var _ = `[data-motion-enter], [data-motion-stagger] > *, [data-motion-loop] { animation: none !important; }`;
8768
+ [
8769
+ "[data-motion-loop] { animation-play-state: running, running !important; }",
8770
+ "[data-motion-on=\"view\"][data-motion-enter], [data-motion-on=\"view\"][data-motion-stagger] > * { animation-play-state: running, running !important; }",
8771
+ "[data-motion-on=\"scroll\"][data-motion-enter], [data-motion-on=\"scroll\"][data-motion-stagger] > * { animation-timeline: auto, auto !important; }"
8772
+ ].join("\n");
8773
+ //#endregion
8774
+ //#region src/commands/shot.ts
8775
+ /** A region moves when more than this share of it changed between two frames: below is anti-aliasing and carets. */
8776
+ var MOVING_PERCENT = .5;
8777
+ /** How long a whole page waits for its pictures before it is taken anyway. */
8778
+ var IMAGES_BUDGET_MS = 8e3;
8779
+ /** How many texts that differ are written out; `--json` has every one. */
8780
+ var TEXTS_SHOWN = 12;
8781
+ /** A shift within this is where it was: a pixel or two is rounding. */
8782
+ var DRIFT_PX = 2;
8783
+ /** An arrival that waits for the scroll and has not had it: one waiting to be seen, or one tied to the scroll. */
8784
+ var WAITING_FOR_SCROLL = "[data-motion-on=\"view\"]:not([data-motion-seen]), [data-motion-on=\"scroll\"]";
8785
+ /** A selector, or an element's name — what `data-plitzi-el` carries. */
8786
+ var selectorOf = (target) => /^[#.[]/.test(target) ? target : `[data-plitzi-el="${target}"]`;
8787
+ var framing = (view) => {
8788
+ if (view.clip) return {
8789
+ kind: "element",
8790
+ selector: selectorOf(view.clip)
8791
+ };
8792
+ return view.viewport || view.scrollTo ? { kind: "viewport" } : { kind: "page" };
8793
+ };
8794
+ var capture = (page, how) => {
8795
+ if (how.kind === "element") return page.locator(how.selector).first().screenshot();
8796
+ return page.screenshot({ fullPage: how.kind === "page" });
8797
+ };
8798
+ /**
8799
+ * One page, pictured: the project's own (`origin` given — in the space's theme when one is asked for) or another site,
8800
+ * in the scheme it is told.
8801
+ */
8802
+ var pictureOf = async (browser, url, view, origin) => {
8803
+ const page = origin ? await openProjectPage(browser, origin, view) : await browser.newPage({
8804
+ viewport: {
8805
+ width: view.width,
8806
+ height: view.height
8807
+ },
8808
+ colorScheme: view.scheme ?? "light",
8809
+ reducedMotion: view.reducedMotion ? "reduce" : "no-preference"
8810
+ });
8811
+ if (!await page.goto(url, { waitUntil: "networkidle" }).catch(() => null)) return { problem: `Nothing answers at ${url}.` };
8812
+ if (view.waitFor) {
8813
+ if (!await page.waitForSelector(selectorOf(view.waitFor), { timeout: 1e4 }).catch(() => null)) return { problem: `${view.waitFor} did not appear on ${url} within 10 s.` };
8814
+ }
8815
+ for (const target of [view.scrollTo, view.clip]) if (target && !await page.waitForSelector(selectorOf(target), { timeout: 1e4 }).catch(() => null)) return { problem: `${target} is not on ${url}.` };
8816
+ if (view.scrollTo) await page.locator(selectorOf(view.scrollTo)).first().scrollIntoViewIfNeeded();
8817
+ await withoutDevTools(page);
8818
+ const settled = view.still ? await page.evaluate((selector) => document.querySelectorAll(selector).length, WAITING_FOR_SCROLL) : 0;
8819
+ const whole = framing(view).kind === "page";
8820
+ const pictures = whole ? await page.evaluate(loadImages, IMAGES_BUDGET_MS) : void 0;
8821
+ if (whole) await page.evaluate(unrollPage, void 0);
8822
+ if (view.still) await page.addStyleTag({ content: _ });
8823
+ return {
8824
+ page,
8825
+ png: await capture(page, framing(view)),
8826
+ scheme: await paintedScheme(page, view.scheme ?? "light"),
8827
+ settled,
8828
+ unloaded: pictures?.missing ?? 0
8829
+ };
8830
+ };
8831
+ var shiftText = (shift) => shift !== void 0 && Math.abs(shift) > DRIFT_PX ? ` (${shift > 0 ? "+" : ""}${String(shift)}px there)` : "";
8832
+ var regionsText = (regions) => regions.map((region) => `${region.name} ${String(region.changed)}%${shiftText(region.shift)}`).join(", ");
8833
+ /** Compared frame to frame: the most each region changed between two that follow each other. */
8834
+ var framesMoved = (diffs) => {
8835
+ const most = /* @__PURE__ */ new Map();
8836
+ for (const diff of diffs) for (const region of diff.regions) most.set(region.name, Math.max(most.get(region.name) ?? 0, region.changed));
8837
+ return [...most].map(([name, changed]) => ({
8838
+ name,
8839
+ changed
8840
+ }));
8841
+ };
8842
+ var differenceText = (entry) => ` ${entry.tag} ${JSON.stringify(entry.text.length > 48 ? `${entry.text.slice(0, 47)}…` : entry.text)} — ${entry.differences.join(" · ")}`;
8843
+ var someOf = (texts) => {
8844
+ return `${texts.slice(0, 3).map((text) => JSON.stringify(text.length > 32 ? `${text.slice(0, 31)}…` : text)).join(", ")}${texts.length > 3 ? ` (+${String(texts.length - 3)})` : ""}`;
8845
+ };
8846
+ /** What a comparison found, in the lines an agent reads before it opens a picture. */
8847
+ var compareText = (compare) => {
8848
+ const lines = [`${chalk.bold(`${String(compare.changed)}% differs`)} from ${compare.url}${compare.regions.length > 0 ? ` — ${regionsText(compare.regions)}` : ""}`];
8849
+ const longer = compare.heights.there - compare.heights.here;
8850
+ const drift = compare.driftFrom;
8851
+ const placed = [Math.abs(longer) > DRIFT_PX ? `the page there is ${String(Math.abs(longer))}px ${longer > 0 ? "longer" : "shorter"} (${String(compare.heights.here)} → ${String(compare.heights.there)})` : "", drift ? `from ${drift.name} on, it sits ${String(Math.abs(drift.shift))}px ${drift.shift > 0 ? "lower" : "higher"} there — each section is compared where it is` : ""].filter(Boolean);
8852
+ if (placed.length > 0) lines.push(placed.join("; "));
8853
+ const { texts } = compare;
8854
+ if (texts.differ.length > 0) lines.push(`texts: ${String(texts.same)} the same, ${String(texts.differ.length)} differ (here → there):`, ...texts.differ.slice(0, TEXTS_SHOWN).map(differenceText), ...texts.differ.length > TEXTS_SHOWN ? [chalk.dim(` …and ${String(texts.differ.length - TEXTS_SHOWN)} more: --json lists them`)] : []);
8855
+ else if (texts.same > 0) lines.push(`texts: the ${String(texts.same)} both pages have look the same`);
8856
+ if (texts.onlyHere.length > 0 || texts.onlyThere.length > 0) lines.push(chalk.dim([texts.onlyHere.length > 0 ? `only here: ${someOf(texts.onlyHere)}` : "", texts.onlyThere.length > 0 ? `only there: ${someOf(texts.onlyThere)}` : ""].filter(Boolean).join(" · ")));
8857
+ if (compare.unloaded > 0) lines.push(chalk.yellow(`${String(compare.unloaded)} picture(s) there had not loaded when it was taken: those parts compare against a hole`));
8858
+ lines.push(chalk.dim(`${compare.sideBySide}\n${compare.diff}`));
8859
+ return lines;
8860
+ };
8861
+ /** The other site beside this page: section by section where each one is, and the words both have, measured. */
8862
+ var compareWith = async (browser, taken, target, view, regions, base) => {
8863
+ const theirs = await pictureOf(browser, target, {
8864
+ ...view,
8865
+ scheme: taken.scheme
8866
+ });
8867
+ if ("problem" in theirs) return theirs;
8868
+ const diff = await comparePictures(taken.page, dataUrl(taken.png), dataUrl(theirs.png), {
8869
+ regions,
8870
+ align: true
8871
+ });
8872
+ await writeFile(`${base}-compare.png`, fromDataUrl(diff.sideBySide));
8873
+ await writeFile(`${base}-diff.png`, fromDataUrl(diff.diff));
8874
+ const rowShift = diff.rowShift ?? [];
8875
+ const shiftAt = (y) => rowShift[Math.min(Math.max(0, Math.round(y)), rowShift.length - 1)] ?? 0;
8876
+ const texts = compareTexts(await taken.page.evaluate(pageTexts, PAGE_TEXT_LIMIT), await theirs.page.evaluate(pageTexts, PAGE_TEXT_LIMIT), shiftAt);
8877
+ return {
8878
+ url: target,
8879
+ changed: diff.changed,
8880
+ heights: {
8881
+ here: diff.heights.a,
8882
+ there: diff.heights.b
8883
+ },
8884
+ ...diff.driftFrom ? { driftFrom: diff.driftFrom } : {},
8885
+ regions: diff.regions,
8886
+ texts,
8887
+ unloaded: theirs.unloaded,
8888
+ sideBySide: `${base}-compare.png`,
8889
+ diff: `${base}-diff.png`
8890
+ };
8891
+ };
8892
+ var shot = async (route, options) => {
8893
+ const project = await projectHere("whose page to picture");
8894
+ if (!project) return;
7696
8895
  const where = await projectOrigin(project.root, project.plitzi?.kind === "project" ? project.plitzi : void 0);
7697
8896
  if ("problem" in where) {
7698
8897
  fail(where.problem);
@@ -7713,49 +8912,42 @@ var shot = async (route, options) => {
7713
8912
  const view = {
7714
8913
  width: options.width ?? 1280,
7715
8914
  height: options.height ?? 800,
7716
- scheme: options.scheme ?? "light",
8915
+ ...options.scheme ? { scheme: options.scheme } : {},
7717
8916
  reducedMotion: options.reducedMotion === true,
7718
8917
  ...options.waitFor ? { waitFor: options.waitFor } : {},
7719
8918
  ...options.clip ? { clip: options.clip } : {},
7720
8919
  ...options.scrollTo ? { scrollTo: options.scrollTo } : {},
7721
- ...options.viewport ? { viewport: true } : {}
8920
+ ...options.viewport ? { viewport: true } : {},
8921
+ still: !options.clip && !options.scrollTo && !options.viewport && (options.frames ?? 0) <= 1
7722
8922
  };
7723
8923
  const how = framing(view);
7724
- const name = pathname === "/" ? "home" : pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
7725
- const out = path.resolve(project.root, options.out ?? `visual/.shots/${name}-${String(view.width)}-${view.scheme}.png`);
7726
- const base = out.replace(/\.png$/, "");
7727
- const taken = await pictureOf(browser, `${where.origin}${pathname}`, view);
8924
+ const taken = await pictureOf(browser, `${where.origin}${pathname}`, view, where.origin);
7728
8925
  if ("problem" in taken) {
7729
8926
  fail(`${taken.problem} Start the project first: npm start`);
7730
8927
  return;
7731
8928
  }
8929
+ const name = pathname === "/" ? "home" : pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
8930
+ const out = path.resolve(project.root, options.out ?? `tmp/shots/${name}-${String(view.width)}-${taken.scheme}.png`);
8931
+ const base = out.replace(/\.png$/, "");
7732
8932
  await mkdir(path.dirname(out), { recursive: true });
7733
8933
  await writeFile(out, taken.png);
7734
8934
  const regions = how.kind === "page" ? await taken.page.evaluate(pageRegions, void 0) : [];
7735
8935
  const report = {
7736
8936
  path: pathname,
7737
8937
  width: view.width,
7738
- scheme: view.scheme,
7739
- out
8938
+ scheme: taken.scheme,
8939
+ out,
8940
+ ...taken.settled > 0 ? { settled: taken.settled } : {},
8941
+ ...taken.unloaded > 0 ? { unloaded: taken.unloaded } : {}
7740
8942
  };
7741
8943
  if (options.compare) {
7742
8944
  const other = new URL(options.compare);
7743
- const target = other.pathname === "/" ? new URL(pathname, other).href : other.href;
7744
- const theirs = await pictureOf(browser, target, view);
7745
- if ("problem" in theirs) {
7746
- fail(theirs.problem);
8945
+ const compared = await compareWith(browser, taken, other.pathname === "/" ? new URL(pathname, other).href : other.href, view, regions, base);
8946
+ if ("problem" in compared) {
8947
+ fail(compared.problem);
7747
8948
  return;
7748
8949
  }
7749
- const diff = await comparePictures(taken.page, dataUrl(taken.png), dataUrl(theirs.png), regions);
7750
- await writeFile(`${base}-compare.png`, fromDataUrl(diff.sideBySide));
7751
- await writeFile(`${base}-diff.png`, fromDataUrl(diff.diff));
7752
- report.compare = {
7753
- url: target,
7754
- changed: diff.changed,
7755
- regions: diff.regions,
7756
- sideBySide: `${base}-compare.png`,
7757
- diff: `${base}-diff.png`
7758
- };
8950
+ report.compare = compared;
7759
8951
  }
7760
8952
  const frames = options.frames ?? 0;
7761
8953
  if (frames > 1) {
@@ -7766,7 +8958,7 @@ var shot = async (route, options) => {
7766
8958
  pictures.push(await capture(taken.page, how));
7767
8959
  }
7768
8960
  const diffs = [];
7769
- for (let frame = 1; frame < pictures.length; frame += 1) diffs.push(await comparePictures(taken.page, dataUrl(pictures[frame - 1]), dataUrl(pictures[frame]), regions));
8961
+ for (let frame = 1; frame < pictures.length; frame += 1) diffs.push(await comparePictures(taken.page, dataUrl(pictures[frame - 1]), dataUrl(pictures[frame]), { regions }));
7770
8962
  const whole = how.kind === "element" ? options.clip ?? "element" : "viewport";
7771
8963
  const moved = how.kind === "page" ? framesMoved(diffs) : [{
7772
8964
  name: whole,
@@ -7784,10 +8976,9 @@ var shot = async (route, options) => {
7784
8976
  return;
7785
8977
  }
7786
8978
  console.log(out);
7787
- if (report.compare) {
7788
- console.log(`${chalk.bold(`${String(report.compare.changed)}% differs`)} from ${report.compare.url}${report.compare.regions.length > 0 ? ` — ${regionsText(report.compare.regions)}` : ""}`);
7789
- console.log(chalk.dim(`${report.compare.sideBySide}\n${report.compare.diff}`));
7790
- }
8979
+ if (report.settled) console.log(chalk.dim(`${String(report.settled)} arrival${report.settled === 1 ? "" : "s"} waiting for the scroll shown as they end — --scroll-to <id> --viewport --frames 4 shows one arriving`));
8980
+ if (report.unloaded) console.log(chalk.yellow(`${String(report.unloaded)} picture(s) had not loaded when the page was taken`));
8981
+ if (report.compare) console.log(compareText(report.compare).join("\n"));
7791
8982
  if (report.frames) console.log(report.frames.moving.length > 0 ? `moving over ${String(report.frames.count)} frames: ${regionsText(report.frames.moving)}` : `nothing moved over ${String(report.frames.count)} frames, ${String(report.frames.every)} ms apart`);
7792
8983
  } finally {
7793
8984
  await browser.close();
@@ -8138,187 +9329,6 @@ var upgrade = async (parts, options) => {
8138
9329
  }
8139
9330
  };
8140
9331
  //#endregion
8141
- //#region src/commands/uploadPlugin.ts
8142
- /** The zips `plitzi pack plugin` leaves: at a plugin package's root, or under `dist/plugins` of any other project. */
8143
- var packedZips = async (root) => {
8144
- const inFolder = async (dir) => {
8145
- try {
8146
- return (await fs.readdir(dir)).filter((file) => file.endsWith(".zip")).map((file) => path.join(dir, file));
8147
- } catch {
8148
- return [];
8149
- }
8150
- };
8151
- const zips = [...await inFolder(root), ...await inFolder(path.join(root, "dist/plugins"))];
8152
- const modified = await Promise.all(zips.map(async (zip) => (await fs.stat(zip)).mtimeMs));
8153
- return zips.map((zip, index) => ({
8154
- zip,
8155
- at: modified[index]
8156
- })).sort((a, b) => b.at - a.at).map(({ zip }) => zip);
8157
- };
8158
- /** The zip named, or the one packed here — asked for at a terminal when there are several. */
8159
- var chooseZip = async (given) => {
8160
- if (given) return path.resolve(given);
8161
- const project = await findProject(process.cwd());
8162
- const zips = project ? await packedZips(project.root) : [];
8163
- if (zips.length === 0) {
8164
- fail("No packed plugin here. Build one with plitzi pack plugin, or name it: plitzi upload plugin <file.zip>.");
8165
- return;
8166
- }
8167
- if (zips.length === 1) return zips[0];
8168
- const choices = zips.map((zip) => path.relative(process.cwd(), zip));
8169
- if (!atTerminal()) {
8170
- refuseWithoutTerminal("upload", [{
8171
- flag: "<zip> (the argument)",
8172
- choices,
8173
- question: "Which packed plugin is to be uploaded?"
8174
- }], "plitzi upload plugin stopped before uploading anything: which plugin goes to the space is a choice");
8175
- return;
8176
- }
8177
- const rl = readline.createInterface({
8178
- input: process.stdin,
8179
- output: process.stdout
8180
- });
8181
- try {
8182
- return await askPick(rl, "Which packed plugin? The newest is first.", zips.map((zip, index) => ({
8183
- label: choices[index],
8184
- value: zip
8185
- })));
8186
- } finally {
8187
- rl.close();
8188
- }
8189
- };
8190
- /**
8191
- * The manifest a zip carries at its root, read here before anything is sent: a zip that is not a plugin is refused by
8192
- * the platform too, but only after it has travelled.
8193
- */
8194
- var manifestOf = (zip) => {
8195
- try {
8196
- const entry = Object.values(unzipSync(zip, { filter: (file) => file.name === "plugin-manifest.json" })).at(0);
8197
- const parsed = entry ? JSON.parse(new TextDecoder().decode(entry)) : {};
8198
- return typeof parsed.root === "string" && parsed.root ? {
8199
- root: parsed.root,
8200
- version: typeof parsed.version === "string" ? parsed.version : ""
8201
- } : void 0;
8202
- } catch {
8203
- return;
8204
- }
8205
- };
8206
- /**
8207
- * The public bucket a plugin goes in — the one `--cdn` and `--bucket` name, the only one there is, or the one picked.
8208
- * A private bucket keeps server code and has no public address, so it is never one.
8209
- */
8210
- var chooseTarget = async (cdns, { cdn: givenCdn, bucket: givenBucket }, spaceName) => {
8211
- if (givenCdn && !cdns.some((cdn) => cdn.identifier === givenCdn)) {
8212
- fail(`${spaceName} has no CDN "${givenCdn}". Its CDNs: ${cdns.map((cdn) => cdn.identifier).join(", ")}.`);
8213
- return;
8214
- }
8215
- const candidates = cdns.filter((cdn) => !givenCdn || cdn.identifier === givenCdn).flatMap((cdn) => cdn.buckets.map((bucket) => ({
8216
- cdn,
8217
- bucket
8218
- })));
8219
- if (givenBucket) {
8220
- const named = candidates.find(({ bucket }) => bucket.identifier === givenBucket);
8221
- if (!named) {
8222
- fail(`${spaceName} has no bucket "${givenBucket}"${givenCdn ? ` in the CDN "${givenCdn}"` : ""}.`);
8223
- return;
8224
- }
8225
- if (named.bucket.visibility === "private") {
8226
- fail(`"${givenBucket}" is a private bucket: it has no public address, so no page could load a plugin from it.`);
8227
- return;
8228
- }
8229
- return named;
8230
- }
8231
- const targets = candidates.filter(({ bucket }) => bucket.visibility === "public");
8232
- if (targets.length === 0) {
8233
- fail(`${spaceName} has no public bucket to put a plugin in. Add one to a CDN in the builder, under Resources.`);
8234
- return;
8235
- }
8236
- if (targets.length === 1) return targets[0];
8237
- if (!atTerminal()) {
8238
- refuseWithoutTerminal("upload", [{
8239
- flag: "--bucket",
8240
- choices: targets.map(({ bucket }) => bucket.identifier),
8241
- question: `Which of ${spaceName}'s public buckets does the plugin go in? (narrow with --cdn)`
8242
- }], "plitzi upload plugin stopped before uploading anything: which bucket serves the plugin is a choice");
8243
- return;
8244
- }
8245
- const rl = readline.createInterface({
8246
- input: process.stdin,
8247
- output: process.stdout
8248
- });
8249
- try {
8250
- return await askPick(rl, `Which of ${spaceName}'s public buckets does the plugin go in?`, targets.map((target) => ({
8251
- label: `${target.cdn.name} — ${target.bucket.name} ${chalk.dim(`${target.bucket.identifier} · ${target.bucket.domain}`)}`,
8252
- value: target
8253
- })));
8254
- } finally {
8255
- rl.close();
8256
- }
8257
- };
8258
- var uploadPluginCommand = async (zipGiven, options) => {
8259
- const api = await apiFor(options);
8260
- const zipPath = api ? await chooseZip(zipGiven) : void 0;
8261
- if (!api || !zipPath) return;
8262
- let zip;
8263
- try {
8264
- zip = await fs.readFile(zipPath);
8265
- } catch {
8266
- fail(`Could not read ${zipPath}.`);
8267
- return;
8268
- }
8269
- const manifest = manifestOf(zip);
8270
- if (!manifest) {
8271
- fail(`${path.basename(zipPath)} is not a plugin: it has no plugin-manifest.json at its root. Build it with plitzi pack plugin.`);
8272
- return;
8273
- }
8274
- const connection = await connectionWithSpace(api, "to upload to");
8275
- if (!connection?.space) return;
8276
- const { space } = connection;
8277
- const listed = await authorizedRequest(connection, `/spaces/${space.id}/cdns`);
8278
- if (!listed.ok) {
8279
- fail(listed.error);
8280
- return;
8281
- }
8282
- if (listed.value.reply.status !== 200) {
8283
- fail(listed.value.reply.data.error ?? `Could not list ${space.name}'s CDNs (${listed.value.reply.status}).`);
8284
- return;
8285
- }
8286
- const target = await chooseTarget(listed.value.reply.data.cdns ?? [], options, space.name);
8287
- if (!target) return;
8288
- const { cdn, bucket } = target;
8289
- const label = `${manifest.root}${manifest.version ? ` ${manifest.version}` : ""}`;
8290
- console.log(`\nUploading ${chalk.bold(label)} to ${chalk.bold(space.name)}, in ${cdn.name} — ${bucket.name}…`);
8291
- const query = new URLSearchParams({
8292
- filename: path.basename(zipPath),
8293
- bucket: bucket.identifier
8294
- });
8295
- const uploaded = await authorizedRequest(listed.value.connection, `/spaces/${space.id}/cdns/${encodeURIComponent(cdn.identifier)}/plugins?${query}`, {
8296
- method: "POST",
8297
- headers: { "Content-Type": "application/zip" },
8298
- body: new Uint8Array(zip)
8299
- });
8300
- if (!uploaded.ok) {
8301
- fail(uploaded.error);
8302
- return;
8303
- }
8304
- const { status, data } = uploaded.value.reply;
8305
- if (status === 413) {
8306
- fail(`${path.basename(zipPath)} is larger than ${api} accepts.`);
8307
- return;
8308
- }
8309
- if (status !== 201) {
8310
- fail(data.error ?? `The upload was refused (${status}).`);
8311
- return;
8312
- }
8313
- const installed = data.installed === "updated" ? `now loads ${label}` : `loads ${label} from now on`;
8314
- console.log(chalk.green(`\n${space.name} ${installed}.`));
8315
- if (data.resource?.path) console.log(chalk.dim(` ${data.resource.path}`));
8316
- console.log(chalk.dim(" Any builder open on the space loads it now."));
8317
- const source = await fs.readFile(sourceFileOf(zipPath)).catch(() => void 0);
8318
- if (source) await keepSource(uploaded.value.connection, space.id, source);
8319
- console.log("");
8320
- };
8321
- //#endregion
8322
9332
  //#region src/options.ts
8323
9333
  /**
8324
9334
  * What a flag's value has to be, read once where the command line is declared. A value that is not one is refused
@@ -8362,7 +9372,7 @@ var PROJECT_ONLY = [
8362
9372
  ];
8363
9373
  /** The widths a page is looked at, as one flag every command that opens a page shares. */
8364
9374
  var widthsOption = (description, fallback) => new Option("--width <px,px>", description).argParser(widths).default(fallback, fallback.join(","));
8365
- var schemeOption = () => new Option("--scheme <scheme>", "The colour scheme").choices(SCHEMES).default("light");
9375
+ var schemeOption = () => new Option("--scheme <scheme>", "The space’s theme, as a visitor’s toggle sets it. The space’s own default when left out").choices(SCHEMES);
8366
9376
  var API_OPTION = ["--api <url>", "The platform’s API. Defaults to PLITZI_API_URL, else the one signed in to, else https://api.plitzi.com."];
8367
9377
  program.command("create").argument("[directory]", "Where to write it. Defaults to the current directory, or asked for with --plugin.").description("Scaffold a project that renders a Plitzi space, or with --plugin a plugin package").addOption(new Option("-m, --mode <mode>", "server (SSR + RSC on a Node tier) or client (browser only)").choices(["server", "client"])).addOption(new Option("-s, --source <source>", "local (the space travels in the project) or cloud (read it from Plitzi)").choices(["local", "cloud"])).option("-k, --key <key>", "Cloud only: the space key (asked for when omitted)").option("-e, --environment <environment>", "Which version: main (the draft), or a published environment — what --from takes out, and what a cloud project serves", "main").option("--revision <n>", "A published environment’s revision, pinned rather than its latest (with --from or --source cloud)").addOption(new Option("-p, --package-manager <manager>", "The package manager the project is written for. Asked for when left out.").choices([...PACKAGE_MANAGERS])).option("--from <space>", "Make it from a space on Plitzi (its id or permanent URL): its pages, actions, functions, runtime, plugins and files, served by the project alone. Signs in as you; the space must be one you can change.").option(...API_OPTION).option("--plugin", "A plugin package instead: one element any space can load, with a preview and a build").option("--name <name>", "Plugin only: its package name (plitzi-plugin-seat-picker). Asked for when left out.").option("--title <title>", "Plugin only: what the builder calls the element").option("--description <description>", "Plugin only: what the element is for, in a sentence").option("--owner <owner>", "Plugin only: who publishes it").option("--elements <names>", "Plugin only: other elements the package holds, by name, separated by commas").addOption(new Option("-t, --template <template>", "What a space written here starts as: welcome (a tour, the default), blank (tokens, a layout, one empty page) or catalog (a shop: layout, card component, data, filtered list, a page per product)").choices([...CREATE_TEMPLATES])).option("--no-install", "Write the files without installing dependencies").option("-f, --force", "Write into a directory that is not empty").option("-y, --yes", "At a terminal: take the defaults for any choice not passed. Without one, every choice must be passed").action((directory, options) => {
8368
9378
  if (!options.plugin) return create(directory, options);
@@ -8371,6 +9381,7 @@ program.command("create").argument("[directory]", "Where to write it. Defaults t
8371
9381
  return createPlugin(directory, options);
8372
9382
  });
8373
9383
  program.command("pull").description("Bring a project made with create --from up to date with its space: what changed there is written, what changed here is kept").option("-f, --force", "Where a file changed both here and on the space, take the space’s copy").option("-e, --environment <environment>", "Follow another version from now on: main (the draft), or a published environment").option("--revision <n>", "Pin a published revision of it, or latest to follow its newest again").option(...API_OPTION).action((options) => pull(options));
9384
+ program.command("push").argument("[parts...]", `Only these: ${PUSH_PARTS.join(", ")}. None named: what changed — ticked at a terminal, to choose from`).description("Put the project back on the space it works with, as its draft: the way back of pull. Refused when the draft changed since").option("-f, --force", "Replace the space’s draft even when it changed since, or holds work this project never had").option("--cdn <identifier>", "Which of the space’s CDNs a plugin goes to, when it has several").option("--bucket <identifier>", "Which public bucket a plugin goes in, when there are several").option(...API_OPTION).action((parts, options) => push(parts, options));
8374
9385
  program.command("add").description("Add something to the project you are in").command("plugin").argument("[names...]", "What each element is called: seat-picker. Asked for when left out.").description("Add elements of your own to this project: each one its component, declaration and builder panel").option("-d, --dir <folder>", "The folder that holds the project’s components. Asked for, unless the project says.").option("--title <title>", "One element only: what the builder calls it").option("--description <description>", "One element only: what it is for, in a sentence").option("--prop <name:type=default>", "One element only: an attribute it takes — interval:number=5000, paused:boolean, label:string=Hi. Repeat it", collect, []).option("--trigger <name:fields>", "An event it fires and what a flow reads — onTick:count. Repeat it", collect, []).option("--callback <name>", "An action a flow can call on it — reset. Repeat it", collect, []).option("--headless", "Nothing to see: hidden on a page, a badge in the builder (a clock, a listener)").option("-f, --force", "Write into a folder that is not empty").action((names, options) => addPlugin(names, options));
8375
9386
  var pack = program.command("pack").description("Build something of this project into what the platform takes");
8376
9387
  pack.command("plugin").argument("[folders...]", "Element folders to pack, the main one first. In a plugin package, left out: all of them.").description("Build a plugin: one ES module, its plugin-manifest.json, and the zip the builder takes under Resources").option("-o, --out <folder>", "Where the build goes. Emptied first; it must be inside the project.").option("--no-zip", "Build without the zip").option("--plugin-version <version>", "The version the manifest carries. Defaults to the one in package.json.").option("--source-root <folder>", "The project its source is kept relative to, when the elements are a project of their own inside this one").action((folders, options) => packPluginCommand(folders, options));
@@ -8379,15 +9390,15 @@ program.command("login").description("Sign in, in your browser. The session is k
8379
9390
  program.command("logout").description("Sign out: the session is revoked on the platform and forgotten here").action(() => logout());
8380
9391
  program.command("whoami").description("Who the CLI is signed in as, and the space it works in").option("--json", "One object: { api, user, space }, for a tool or an agent").option(...API_OPTION).action((options) => whoami(options));
8381
9392
  program.command("space").description("Choose the space to work in, in your browser. One at a time: it replaces the one chosen before").option(...API_OPTION).action((options) => space(options));
8382
- program.command("check").argument("[path]", "The page: /about. The home page when left out").description("Whether a page of the running project is whole, in text: every element on screen, no broken image, no console error, no failed flow").addOption(widthsOption("The widths to check it at, separated by commas", [1440, 390])).addOption(schemeOption()).option("--state", "Also what the page holds: its state, and every source by name").option("--element <id>", "Also one element: what it reads, its own state, whether it is on screen").option("--json", "One object per width, for a tool or an agent").action((path, options) => check(path, options));
9393
+ program.command("check").argument("[path]", "The page: /about. The home page when left out").description("Whether a page of the running project is whole, in text: every element on screen, no broken image, no console error, no failed flow").addOption(widthsOption("The widths to check it at, separated by commas", [1440, 390])).addOption(schemeOption()).option("--state", "Also what the page holds: its state, and every source by name").option("--element <id>", "Also one element: what it reads, its own state, whether it is on screen").option("--ssr", "Also the HTML the server sent against the hydrated page: what a server provider sent late").option("--json", "One object per width, for a tool or an agent").action((path, options) => check(path, options));
8383
9394
  program.command("fix").description("What authoring would fix in the space, as edits to your source: shown as a diff; with --write written, formatted and checked").option("--write", "Write the edits — kept only when the space authors with each fix gone and no problem added").option("--json", "The plan as one object, for a tool or an agent").action((options) => fix(options));
8384
9395
  program.command("import").argument("<url>", "The page to start from: https://example.com/pricing").description("A page of a site you verified, as a place to start writing from: its tokens, the outline of its blocks per breakpoint, its lists as JSON — never its words").option("-o, --out <dir>", "Where it is written, inside the project", "src/imported").addOption(widthsOption("The widths it is measured at: the widest is desktop, then tablet and mobile", [
8385
9396
  1440,
8386
9397
  768,
8387
9398
  390
8388
9399
  ])).option("-f, --force", "Write over what --out already holds").option("--json", "One object, for a tool or an agent").option(...API_OPTION).action((url, options) => importPage(url, options));
8389
- program.command("shot").argument("[path]", "The page: /about. The home page when left out").description("A picture of one page of the running project — and, asked, how it differs from another or what moves").addOption(new Option("--width <px>", "The viewport width").argParser(width).default(1280)).addOption(new Option("--height <px>", "The viewport height (the picture is the whole page unless --viewport, --scroll-to or --clip)").argParser(positiveInteger).default(800)).addOption(schemeOption()).option("-o, --out <file>", "Where the picture goes. visual/.shots/<page>-<width>-<scheme>.png by default").option("--compare <url>", "Another site: the same page there, side by side, and how much differs by section").addOption(new Option("--frames <n>", "Pictures taken one after another: which sections move (a marquee, an autoplay)").argParser(positiveInteger)).addOption(new Option("--every <ms>", "How far apart --frames are taken").argParser(positiveInteger).default(500)).option("--wait-for <element>", "An element to wait for first: its name (data-plitzi-el) or a CSS selector").option("--clip <element>", "Only this element — its name or a CSS selector — scrolled to wherever it is").option("--scroll-to <element>", "The page scrolled until this element is in view; the picture is the screen there").option("--viewport", "Only what fits the viewport, not the whole page").option("--reduced-motion", "As a visitor who asked for less motion").option("--json", "One object, for a tool or an agent").action((path, options) => shot(path, options));
8390
- program.command("explain").argument("[name]", "An element, a step, a trigger, a problem’s code or a transformer: container, navigate, class-and-css").description("What a name means when authoring a space — what it takes, fires, reads and how it is written").option("--list <kind>", "Every name of a kind: elements, steps, triggers, codes, transformers").option("--json", "One object, for a tool or an agent").action((name, options) => explainCommand(name, options));
9400
+ program.command("shot").argument("[path]", "The page: /about. The home page when left out").description("A picture of one page of the running project — and, asked, how it differs from another or what moves").addOption(new Option("--width <px>", "The viewport width").argParser(width).default(1280)).addOption(new Option("--height <px>", "The viewport height (the picture is the whole page unless --viewport, --scroll-to or --clip)").argParser(positiveInteger).default(800)).addOption(schemeOption()).option("-o, --out <file>", "Where the picture goes. tmp/shots/<page>-<width>-<scheme>.png by default").option("--compare <url>", "Another site: the same page there, side by side, and how much differs by section").addOption(new Option("--frames <n>", "Pictures taken one after another: which sections move (a marquee, an autoplay)").argParser(positiveInteger)).addOption(new Option("--every <ms>", "How far apart --frames are taken").argParser(positiveInteger).default(500)).option("--wait-for <element>", "An element to wait for first: its name (data-plitzi-el) or a CSS selector").option("--clip <element>", "Only this element — its name or a CSS selector — scrolled to wherever it is").option("--scroll-to <element>", "The page scrolled until this element is in view; the picture is the screen there").option("--viewport", "Only what fits the viewport, not the whole page").option("--reduced-motion", "As a visitor who asked for less motion").option("--json", "One object, for a tool or an agent").action((path, options) => shot(path, options));
9401
+ program.command("explain").argument("[name]", "An element, a step, a trigger, a problem’s code, a transformer or a helper: container, navigate, class-and-css, bindTemplate, motion").description("What a name means when authoring a space — what it takes, fires, reads and how it is written").option("--list <kind>", "Every name of a kind: elements, steps, triggers, codes, transformers, helpers").option("--json", "One object, for a tool or an agent").action((name, options) => explainCommand(name, options));
8391
9402
  program.command("upgrade").alias("update").argument("[parts...]", `Only these: ${UPGRADE_PARTS.join(", ")} — every one when none is named`).description("Bring the project up to this CLI: its machinery files, package.json, the skills and renamed names — shown; with --write made").option("--write", "Make the changes: files nobody changed replaced, package.json merged and installed, names renamed").option("--take <files...>", "Replace these files of your own too (`all` for every one), after reading their diff").option("--no-install", "Write package.json and leave the install to you").option("--json", "One object, for a tool or an agent").action((parts, options) => upgrade(parts, options));
8392
9403
  program.command("skills").description("The Plitzi skills an agent reads in .claude/skills/").command("update").description("Bring them up to the Plitzi packages this project has installed, each replaced whole (`upgrade skills --write`)").action(() => upgrade(["skills"], { write: true }));
8393
9404
  program.command("data").description("The JSON a project serves (public/data), without reading it whole").command("describe").argument("<file>", "A JSON file: public/data/products.json").description("Its shape — every field, its type, whether every row has it — and one row of its longest list").option("--json", "One object: { shape, example }").action((file, options) => dataDescribe(file, options));