foss-design 0.9.14 → 0.9.16

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/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { spawnSync as spawnSync3 } from "child_process";
5
- import fs39 from "fs";
6
- import path38 from "path";
5
+ import fs40 from "fs";
6
+ import path39 from "path";
7
7
  import { parseArgs } from "util";
8
8
 
9
9
  // src/core/paths.ts
@@ -23,9 +23,9 @@ function slugify(stem) {
23
23
  return stem.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase() || "item";
24
24
  }
25
25
  function fileStem(file) {
26
- const base = path.basename(file, path.extname(file));
27
- if (base === "index") return path.basename(path.dirname(file));
28
- return base;
26
+ const base2 = path.basename(file, path.extname(file));
27
+ if (base2 === "index") return path.basename(path.dirname(file));
28
+ return base2;
29
29
  }
30
30
  function toPosix(p) {
31
31
  return p.split(path.sep).join("/");
@@ -115,6 +115,7 @@ var PKG = {
115
115
  root: PKG_ROOT,
116
116
  manifest: path2.join(PKG_ROOT, "package.json"),
117
117
  runtime: path2.join(PKG_ROOT, "dist", "runtime", "index.js"),
118
+ pageRuntime: path2.join(PKG_ROOT, "dist", "runtime", "page.js"),
118
119
  baseCss: path2.join(PKG_ROOT, "dist", "runtime", "base.css"),
119
120
  viewer: path2.join(PKG_ROOT, "dist", "viewer"),
120
121
  cli: path2.join(PKG_ROOT, "dist", "cli.js")
@@ -348,8 +349,8 @@ function routeError(pattern) {
348
349
  }
349
350
  return null;
350
351
  }
351
- function split(path39) {
352
- return path39.split("/").filter(Boolean);
352
+ function split(path40) {
353
+ return path40.split("/").filter(Boolean);
353
354
  }
354
355
  function compileRoute(pattern) {
355
356
  return {
@@ -368,41 +369,41 @@ function decode2(part) {
368
369
  return part;
369
370
  }
370
371
  }
371
- function scoreRoute(route2, path39) {
372
- const parts = split(path39.split(/[?#]/)[0] ?? "").map(decode2);
373
- const walk2 = (s, p) => {
372
+ function scoreRoute(route2, path40) {
373
+ const parts = split(path40.split(/[?#]/)[0] ?? "").map(decode2);
374
+ const walk3 = (s, p) => {
374
375
  if (s === route2.segments.length) return p === parts.length ? 0 : null;
375
376
  const segment = route2.segments[s];
376
377
  if (segment.kind === "splat") return 0;
377
378
  if (segment.kind === "static") {
378
379
  if (parts[p] !== segment.value) return null;
379
- const rest = walk2(s + 1, p + 1);
380
+ const rest = walk3(s + 1, p + 1);
380
381
  return rest === null ? null : rest + 4;
381
382
  }
382
383
  if (p < parts.length) {
383
- const rest = walk2(s + 1, p + 1);
384
+ const rest = walk3(s + 1, p + 1);
384
385
  if (rest !== null) return rest + 2;
385
386
  }
386
- return segment.optional ? walk2(s + 1, p) : null;
387
+ return segment.optional ? walk3(s + 1, p) : null;
387
388
  };
388
- return walk2(0, 0);
389
+ return walk3(0, 0);
389
390
  }
390
- function matchRoute(entries, path39) {
391
+ function matchRoute(entries, path40) {
391
392
  let best = null;
392
393
  for (const entry of entries) {
393
394
  for (const route2 of entry.routes) {
394
- const score = scoreRoute(route2, path39);
395
+ const score = scoreRoute(route2, path40);
395
396
  if (score !== null && (!best || score > best.score)) best = { score, value: entry.value };
396
397
  }
397
398
  }
398
399
  return best?.value ?? null;
399
400
  }
400
- function linkPath(href, base = "/") {
401
+ function linkPath(href, base2 = "/") {
401
402
  const raw = href.trim();
402
403
  if (!raw || raw.startsWith("#")) return null;
403
404
  if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith("//")) return null;
404
405
  const origin = "http://app.invalid";
405
- const url = new URL(raw, `${origin}${base.replace(/\/:[^/]+\??|\/\*$/g, "/x")}`);
406
+ const url = new URL(raw, `${origin}${base2.replace(/\/:[^/]+\??|\/\*$/g, "/x")}`);
406
407
  return url.pathname + url.search + url.hash;
407
408
  }
408
409
 
@@ -432,6 +433,11 @@ var DesignConfigSchema = z.strictObject({
432
433
  app: z.string().min(1).optional(),
433
434
  /** A folder served at the root of every screen, like Vite's `public`, relative to the project root. */
434
435
  public: z.string().min(1).optional(),
436
+ /**
437
+ * Plugins screens get from the app's own Vite config, by name (`vite-plugin-svgr`): the config is
438
+ * the app's code, which this only picks from.
439
+ */
440
+ vitePlugins: z.array(z.string().min(1)).optional(),
435
441
  port: z.number().int().min(1).max(65535).optional()
436
442
  });
437
443
  var SystemConfigSchema = z.strictObject({
@@ -518,13 +524,13 @@ var CanvasSchema = z.strictObject({
518
524
  pages: z.array(z.unknown()).min(1, "add at least one page")
519
525
  });
520
526
  var ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
521
- function formatPath(base, path39) {
522
- let out = base;
523
- for (const part of path39) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
527
+ function formatPath(base2, path40) {
528
+ let out = base2;
529
+ for (const part of path40) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
524
530
  return out;
525
531
  }
526
- function describeZodError(error, base = "") {
527
- return error.issues.map((issue) => ({ at: formatPath(base, issue.path), message: issue.message }));
532
+ function describeZodError(error, base2 = "") {
533
+ return error.issues.map((issue) => ({ at: formatPath(base2, issue.path), message: issue.message }));
528
534
  }
529
535
 
530
536
  // src/core/canvas.ts
@@ -538,8 +544,8 @@ var routesOf = (route2) => route2 === void 0 ? void 0 : Array.isArray(route2) ?
538
544
  function frameOf(input) {
539
545
  const custom = input.width !== void 0 || input.height !== void 0;
540
546
  const device = input.device ?? (custom ? void 0 : DEFAULT_DEVICE);
541
- const base = DEVICES[input.device ?? DEFAULT_DEVICE];
542
- return { width: input.width ?? base.width, height: input.height ?? base.height, device };
547
+ const base2 = DEVICES[input.device ?? DEFAULT_DEVICE];
548
+ return { width: input.width ?? base2.width, height: input.height ?? base2.height, device };
543
549
  }
544
550
  function loadCanvas(paths, canvasId, urls, snapshots) {
545
551
  const dir = path3.join(paths.canvases, canvasId);
@@ -778,11 +784,11 @@ function resolveCover(cover, dir, screens, paths, urls, report) {
778
784
  report("error", `cover "${cover}" does not exist`, "cover");
779
785
  return void 0;
780
786
  }
781
- const { size: size2, mtimeMs } = fs3.statSync(file);
782
- if (size2 > MAX_COVER_BYTES) {
787
+ const { size: size3, mtimeMs } = fs3.statSync(file);
788
+ if (size3 > MAX_COVER_BYTES) {
783
789
  report(
784
790
  "error",
785
- `cover "${cover}" is ${Math.ceil(size2 / 1024)} KB; the limit is ${MAX_COVER_BYTES / 1024} KB`,
791
+ `cover "${cover}" is ${Math.ceil(size3 / 1024)} KB; the limit is ${MAX_COVER_BYTES / 1024} KB`,
786
792
  "cover"
787
793
  );
788
794
  return void 0;
@@ -1128,17 +1134,17 @@ function collect(css2, from) {
1128
1134
  function readStylesheets(entry) {
1129
1135
  const seen = /* @__PURE__ */ new Set();
1130
1136
  const files = [];
1131
- const walk2 = (file) => {
1137
+ const walk3 = (file) => {
1132
1138
  if (seen.has(file) || !fs6.existsSync(file)) return;
1133
1139
  seen.add(file);
1134
1140
  const css2 = fs6.readFileSync(file, "utf8");
1135
1141
  const imports = [...css2.matchAll(/@import\s+(?:url\()?\s*["']([^"']+)["']/g)].map((m) => m[1]);
1136
1142
  for (const spec of imports) {
1137
- if (spec.startsWith("./") || spec.startsWith("../")) walk2(path6.resolve(path6.dirname(file), spec));
1143
+ if (spec.startsWith("./") || spec.startsWith("../")) walk3(path6.resolve(path6.dirname(file), spec));
1138
1144
  }
1139
1145
  files.push({ file, css: css2 });
1140
1146
  };
1141
- walk2(entry);
1147
+ walk3(entry);
1142
1148
  return files;
1143
1149
  }
1144
1150
  function closingParen(text2, start) {
@@ -1684,6 +1690,30 @@ var Entries = class {
1684
1690
  entryFile(source) {
1685
1691
  return path9.join(this.dir, source.canvas, `${source.id}.js`);
1686
1692
  }
1693
+ /**
1694
+ * The app's own folder for a stylesheet of the app (system.json `stylesheet` outside `.design`):
1695
+ * design.json `app`, else the package the stylesheet belongs to. In the app's build Tailwind finds
1696
+ * the app's classes from the app's root; here the root is `.design`, so it is named as a source.
1697
+ */
1698
+ appSourceDir() {
1699
+ const { paths } = this.project;
1700
+ const stylesheet = this.project.system()?.stylesheet;
1701
+ if (!stylesheet || isInside(paths.design, stylesheet)) return null;
1702
+ const app = this.project.appDir();
1703
+ if (app) return app;
1704
+ for (let dir = path9.dirname(stylesheet); isInside(paths.root, dir); dir = path9.dirname(dir)) {
1705
+ if (fs9.existsSync(path9.join(dir, "package.json"))) return dir;
1706
+ if (dir === paths.root) break;
1707
+ }
1708
+ return paths.root;
1709
+ }
1710
+ /** Every folder the system stylesheet has Tailwind scan: `.design`, the app's, design.json `sources`. */
1711
+ scannedDirs() {
1712
+ const { paths } = this.project;
1713
+ const extra = (this.project.config().config.sources ?? []).map((dir) => path9.resolve(paths.root, dir));
1714
+ const app = this.appSourceDir();
1715
+ return [paths.design, ...app ? [app] : [], ...extra];
1716
+ }
1687
1717
  /** Tailwind with the design tokens, or the stylesheet system.json names; scans every file in `.design`. */
1688
1718
  systemCss() {
1689
1719
  const { paths } = this.project;
@@ -1692,6 +1722,8 @@ var Entries = class {
1692
1722
  const lines = ["/* Generated by foss-design. Edit .design/system/tokens.css instead. */"];
1693
1723
  if (system?.stylesheet) {
1694
1724
  lines.push(`@import ${css(system.stylesheet)};`);
1725
+ const app = this.appSourceDir();
1726
+ if (app) lines.push(`@source ${css(app)};`);
1695
1727
  } else {
1696
1728
  const tailwind = resolveFrom(PKG.manifest, "tailwindcss/index.css");
1697
1729
  if (!tailwind) throw new Error("tailwindcss is missing from foss-design; reinstall the package");
@@ -1790,7 +1822,7 @@ function headTags(head) {
1790
1822
  public: head.public
1791
1823
  };
1792
1824
  const fallback = source.theme ?? head.theme;
1793
- const theme2 = `(function(){var q=new URLSearchParams(location.search).get('theme');var t=${JSON.stringify(source.theme ?? null)}||(q==='dark'||q==='light'?q:${JSON.stringify(fallback)});var r=document.documentElement;r.dataset.theme=t;r.classList.toggle('dark',t==='dark');r.style.colorScheme=t})()`;
1825
+ const theme2 = `(function(){var q=new URLSearchParams(location.search).get('theme');var t=${JSON.stringify(source.theme ?? null)}||(q==='dark'||q==='light'?q:${JSON.stringify(fallback)});var r=document.documentElement;r.dataset.theme=r.dataset.mode=r.dataset.colorScheme=t;r.classList.toggle('dark',t==='dark');r.classList.toggle('theme-dark',t==='dark');r.style.colorScheme=t})()`;
1794
1826
  const tags = [`<script>window.__DESIGN__=${scriptJson(config)};${theme2}</script>`, '<link rel="icon" href="data:,">'];
1795
1827
  for (const href of head.fonts) tags.push(`<link rel="stylesheet" href="${escapeHtml(href)}">`);
1796
1828
  if (head.systemCss) tags.push(`<link rel="stylesheet" href="${escapeHtml(head.systemCss)}">`);
@@ -1977,6 +2009,7 @@ import fs12 from "fs";
1977
2009
  import path12 from "path";
1978
2010
  import tailwindcss from "@tailwindcss/vite";
1979
2011
  import react from "@vitejs/plugin-react";
2012
+ import { loadConfigFromFile } from "vite";
1980
2013
 
1981
2014
  // src/server/css-guard.ts
1982
2015
  import fs11 from "fs";
@@ -2012,9 +2045,9 @@ function targets(params) {
2012
2045
  return [...out];
2013
2046
  }
2014
2047
  var isPathLike = (spec) => spec.startsWith(".") || path11.isAbsolute(spec);
2015
- function existing(base, exts) {
2048
+ function existing(base2, exts) {
2016
2049
  for (const ext of exts) {
2017
- const file = base + ext;
2050
+ const file = base2 + ext;
2018
2051
  try {
2019
2052
  if (fs11.statSync(file).isFile()) return fs11.realpathSync(file);
2020
2053
  } catch {
@@ -2057,8 +2090,8 @@ function designCodeGuard(design) {
2057
2090
  },
2058
2091
  (file) => file.endsWith(".css")
2059
2092
  );
2060
- const script = resolveWith({ ...config?.resolve }, (file) => !file.endsWith(".css"));
2061
- return { style, script };
2093
+ const script2 = resolveWith({ ...config?.resolve }, (file) => !file.endsWith(".css"));
2094
+ return { style, script: script2 };
2062
2095
  };
2063
2096
  const candidates = async (spec, importer, resolve, exts) => {
2064
2097
  const found = /* @__PURE__ */ new Set();
@@ -2076,13 +2109,13 @@ function designCodeGuard(design) {
2076
2109
  }
2077
2110
  return [...found];
2078
2111
  };
2079
- const check = async (environment, file, code, seen) => {
2080
- const { style, script } = resolvers(environment);
2112
+ const check2 = async (environment, file, code, seen) => {
2113
+ const { style, script: script2 } = resolvers(environment);
2081
2114
  for (const match of code.matchAll(LOADS_CODE)) {
2082
2115
  const directive = match[1] ? `@${match[1]}` : "use()";
2083
2116
  const specs = match[1] ? targets(match[2] ?? "") : [match[4] ?? ""];
2084
2117
  for (const spec of specs) {
2085
- for (const target of await candidates(spec, file, script, JS_EXT)) {
2118
+ for (const target of await candidates(spec, file, script2, JS_EXT)) {
2086
2119
  if (!pulled(target)) continue;
2087
2120
  throw new Error(
2088
2121
  `${shown(file)}: \`${match[0].trim()}\` loads ${shown(target)}, which is refused. ${directive} runs its code in Node on this machine, and code under .design can come from a teammate through \`design pull\`. Put the plugin in the app\u2019s own source (outside .design) or name an installed package.`
@@ -2102,7 +2135,7 @@ function designCodeGuard(design) {
2102
2135
  } catch {
2103
2136
  continue;
2104
2137
  }
2105
- await check(environment, target, text2, seen);
2138
+ await check2(environment, target, text2, seen);
2106
2139
  }
2107
2140
  }
2108
2141
  }
@@ -2116,7 +2149,7 @@ function designCodeGuard(design) {
2116
2149
  async transform(code, id2) {
2117
2150
  if (!isStylesheetId(id2)) return null;
2118
2151
  const file = path11.resolve(idFile(id2));
2119
- await check(this.environment, file, code, /* @__PURE__ */ new Set([file]));
2152
+ await check2(this.environment, file, code, /* @__PURE__ */ new Set([file]));
2120
2153
  return null;
2121
2154
  }
2122
2155
  };
@@ -2140,6 +2173,41 @@ function aliases(project) {
2140
2173
  }
2141
2174
  return list;
2142
2175
  }
2176
+ var INSTALL_FILES = [
2177
+ "package.json",
2178
+ "pnpm-lock.yaml",
2179
+ "package-lock.json",
2180
+ "npm-shrinkwrap.json",
2181
+ "yarn.lock",
2182
+ "bun.lock",
2183
+ "bun.lockb",
2184
+ ".pnp.cjs",
2185
+ "node_modules/.pnpm/lock.yaml",
2186
+ "node_modules/.modules.yaml",
2187
+ "node_modules/.package-lock.json",
2188
+ "node_modules/.yarn-state.yml",
2189
+ "node_modules/.yarn-integrity"
2190
+ ];
2191
+ function installStamp(paths, appDir) {
2192
+ const seen = /* @__PURE__ */ new Set();
2193
+ const parts = [];
2194
+ for (const start of appDir ? [paths.root, appDir] : [paths.root]) {
2195
+ for (let dir = path12.resolve(start); ; dir = path12.dirname(dir)) {
2196
+ for (const name of INSTALL_FILES) {
2197
+ const file = path12.join(dir, name);
2198
+ if (seen.has(file)) continue;
2199
+ seen.add(file);
2200
+ try {
2201
+ const stat = fs12.statSync(file);
2202
+ parts.push(`${file}:${stat.size}:${stat.mtimeMs}`);
2203
+ } catch {
2204
+ }
2205
+ }
2206
+ if (path12.dirname(dir) === dir) break;
2207
+ }
2208
+ }
2209
+ return createHash3("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
2210
+ }
2143
2211
  function dropStaleDepCache(paths, appDir = null) {
2144
2212
  const dir = path12.join(paths.cache, "vite");
2145
2213
  const stamp = path12.join(paths.cache, "vite-runtime");
@@ -2149,6 +2217,7 @@ function dropStaleDepCache(paths, appDir = null) {
2149
2217
  } catch {
2150
2218
  }
2151
2219
  if (appDir) runtime += ` ${appDir}`;
2220
+ runtime += ` ${installStamp(paths, appDir)}`;
2152
2221
  let seen = "";
2153
2222
  try {
2154
2223
  seen = fs12.readFileSync(stamp, "utf8");
@@ -2159,11 +2228,82 @@ function dropStaleDepCache(paths, appDir = null) {
2159
2228
  fs12.mkdirSync(paths.cache, { recursive: true });
2160
2229
  fs12.writeFileSync(stamp, runtime);
2161
2230
  }
2162
- function baseConfig(project) {
2231
+ var VITE_CONFIGS = [
2232
+ "vite.config.ts",
2233
+ "vite.config.mts",
2234
+ "vite.config.js",
2235
+ "vite.config.mjs",
2236
+ "vite.config.cts",
2237
+ "vite.config.cjs"
2238
+ ];
2239
+ var OWN_PLUGIN = /^(?:vite:react|@tailwindcss\/vite)/;
2240
+ function appViteConfig(project) {
2241
+ for (const dir of [project.appDir(), project.paths.root]) {
2242
+ if (!dir) continue;
2243
+ for (const name of VITE_CONFIGS) if (fs12.existsSync(path12.join(dir, name))) return path12.join(dir, name);
2244
+ }
2245
+ return null;
2246
+ }
2247
+ async function flatten(list, out = []) {
2248
+ for (const option of list ?? []) {
2249
+ const value = await option;
2250
+ if (Array.isArray(value)) await flatten(value, out);
2251
+ else if (value && typeof value === "object" && "name" in value) out.push(value);
2252
+ }
2253
+ return out;
2254
+ }
2255
+ async function appPlugins(project, command) {
2256
+ const wanted = project.config().config.vitePlugins ?? [];
2257
+ if (!wanted.length) return { plugins: [], problems: [] };
2258
+ const file = appViteConfig(project);
2259
+ if (!file)
2260
+ return {
2261
+ plugins: [],
2262
+ problems: [
2263
+ `design.json vitePlugins: no vite.config.* in ${project.appDir() ? "the app package or " : ""}the project root to take them from`
2264
+ ]
2265
+ };
2266
+ let all;
2267
+ try {
2268
+ const loaded = await loadConfigFromFile(
2269
+ { command, mode: command === "serve" ? "development" : "production", isSsrBuild: false, isPreview: false },
2270
+ file,
2271
+ path12.dirname(file),
2272
+ "silent"
2273
+ );
2274
+ all = await flatten(loaded?.config.plugins);
2275
+ } catch (error) {
2276
+ return {
2277
+ plugins: [],
2278
+ problems: [
2279
+ `design.json vitePlugins: ${path12.relative(project.paths.root, file)} did not load: ${error.message}`
2280
+ ]
2281
+ };
2282
+ }
2283
+ const plugins = [];
2284
+ const problems = [];
2285
+ for (const name of wanted) {
2286
+ if (OWN_PLUGIN.test(name)) {
2287
+ problems.push(`design.json vitePlugins: "${name}" is foss-design's own (React and Tailwind are always on)`);
2288
+ continue;
2289
+ }
2290
+ const found = all.filter((plugin) => plugin.name === name || plugin.name.startsWith(`${name}:`));
2291
+ if (found.length) plugins.push(...found);
2292
+ else
2293
+ problems.push(
2294
+ `design.json vitePlugins: no "${name}" in ${path12.relative(project.paths.root, file)}; it has ${all.map((plugin) => plugin.name).join(", ") || "no plugins"}`
2295
+ );
2296
+ }
2297
+ return { plugins, problems };
2298
+ }
2299
+ function baseConfig(project, extra = []) {
2163
2300
  const { paths } = project;
2164
2301
  return {
2165
2302
  configFile: false,
2166
- envDir: false,
2303
+ // The app's `.env`, `.env.local` and `.env.<mode>` files, as its own dev server and build read
2304
+ // them: screens get the same `import.meta.env.VITE_*` (a CDN host, a public API key). Only the
2305
+ // `VITE_` ones reach the browser, as in the app's build; a push bakes them into the cloud's copy.
2306
+ envDir: project.appDir() ?? paths.root,
2167
2307
  root: paths.design,
2168
2308
  cacheDir: path12.join(paths.cache, "vite"),
2169
2309
  // design.json `public`: served under the frames' base, so HTML and CSS paths such as `/logo.png` resolve.
@@ -2180,7 +2320,7 @@ function baseConfig(project) {
2180
2320
  css: { postcss: {} },
2181
2321
  // Pulled code runs in the browser only: the guard refuses stylesheets that would load code from
2182
2322
  // `.design` in Node (Tailwind's `@plugin` and `@config`), before Tailwind reads them.
2183
- plugins: [react(), designCodeGuard(paths.design), tailwindcss()]
2323
+ plugins: [react(), designCodeGuard(paths.design), ...extra, tailwindcss()]
2184
2324
  };
2185
2325
  }
2186
2326
 
@@ -2319,12 +2459,15 @@ async function buildSite(paths, out, options = {}) {
2319
2459
  fs13.mkdirSync(out, { recursive: true });
2320
2460
  const moduleMap = {};
2321
2461
  if (inputs.length) {
2322
- const base = baseConfig(project);
2462
+ const fromApp = await appPlugins(project, "build");
2463
+ for (const problem of fromApp.problems) process.stderr.write(`warning: ${problem}
2464
+ `);
2465
+ const base2 = baseConfig(project, fromApp.plugins);
2323
2466
  await build({
2324
- ...base,
2467
+ ...base2,
2325
2468
  plugins: [
2326
- ...base.plugins ?? [],
2327
- options.moduleMap ? {
2469
+ ...base2.plugins ?? [],
2470
+ {
2328
2471
  name: "design-module-map",
2329
2472
  generateBundle(_, bundle) {
2330
2473
  for (const [file, chunk2] of Object.entries(bundle)) {
@@ -2332,7 +2475,7 @@ async function buildSite(paths, out, options = {}) {
2332
2475
  moduleMap[file] = (chunk2.moduleIds ?? []).map((id2) => id2.split("?")[0]).filter((id2) => path13.isAbsolute(id2) && !id2.includes(`${path13.sep}node_modules${path13.sep}`)).map((id2) => toPosix(path13.relative(paths.root, id2))).filter((rel) => !rel.startsWith(".."));
2333
2476
  }
2334
2477
  }
2335
- } : null
2478
+ }
2336
2479
  ],
2337
2480
  root: staging,
2338
2481
  base: "./",
@@ -2418,7 +2561,8 @@ async function buildSite(paths, out, options = {}) {
2418
2561
  fs13.copyFileSync(path13.join(publicDir, rel), to);
2419
2562
  }
2420
2563
  }
2421
- return { out, canvases: canvasIds, frames: inputs.length, errors, absoluteAssets };
2564
+ const modules = [...new Set(Object.values(moduleMap).flat())].sort();
2565
+ return { out, canvases: canvasIds, frames: inputs.length, errors, absoluteAssets, modules };
2422
2566
  }
2423
2567
  function copyViewer(viewerDir, out) {
2424
2568
  copyTree(viewerDir, out, () => true);
@@ -2600,7 +2744,7 @@ function warnLink(rel) {
2600
2744
  process.stderr.write(`warning: .design/${rel} links outside .design; it is not synced
2601
2745
  `);
2602
2746
  }
2603
- function walk(dir, base, out) {
2747
+ function walk(dir, base2, out) {
2604
2748
  let entries;
2605
2749
  try {
2606
2750
  entries = fs15.readdirSync(dir, { withFileTypes: true });
@@ -2617,14 +2761,14 @@ function walk(dir, base, out) {
2617
2761
  isFile = false;
2618
2762
  try {
2619
2763
  const target = fs15.realpathSync(full);
2620
- const root = fs15.realpathSync(base);
2764
+ const root = fs15.realpathSync(base2);
2621
2765
  if (target.startsWith(root + path15.sep)) isFile = fs15.statSync(target).isFile();
2622
- else warnLink(toPosix(path15.relative(base, full)));
2766
+ else warnLink(toPosix(path15.relative(base2, full)));
2623
2767
  } catch {
2624
2768
  }
2625
2769
  }
2626
- if (isDir) walk(full, base, out);
2627
- else if (isFile) out[toPosix(path15.relative(base, full))] = hashFile(full);
2770
+ if (isDir) walk(full, base2, out);
2771
+ else if (isFile) out[toPosix(path15.relative(base2, full))] = hashFile(full);
2628
2772
  }
2629
2773
  }
2630
2774
  function unitManifest(paths, key) {
@@ -2759,6 +2903,7 @@ function parseProject(value, fallbackRole) {
2759
2903
  id: id2,
2760
2904
  name: text(data.name) ?? id2,
2761
2905
  role: text(data.role) ?? fallbackRole,
2906
+ kind: data.kind === "pages" ? "pages" : "design",
2762
2907
  archived: flag(data.archived, data.archivedAt, data.archived_at),
2763
2908
  banned: flag(data.banned, data.bannedAt, data.banned_at),
2764
2909
  icon: /[?&]v=([0-9a-f]{64})/.exec(text(data.icon) ?? "")?.[1] ?? null
@@ -2785,6 +2930,21 @@ function parseUnit(value) {
2785
2930
  })
2786
2931
  };
2787
2932
  }
2933
+ function parsePage(value) {
2934
+ const data = record(value);
2935
+ const slug2 = str(data.slug);
2936
+ if (!slug2) return null;
2937
+ return {
2938
+ slug: slug2,
2939
+ title: text(data.title) ?? slug2,
2940
+ headRev: num(data.headRev) ?? 0,
2941
+ bytes: num(data.bytes) ?? 0,
2942
+ updatedAt: str(data.updatedAt) ?? "",
2943
+ archived: flag(data.archivedAt),
2944
+ banned: flag(data.bannedAt),
2945
+ publicId: str(data.publicId) ?? null
2946
+ };
2947
+ }
2788
2948
  function parseRevision(value) {
2789
2949
  const data = record(value);
2790
2950
  const rev = num(data.rev);
@@ -2802,6 +2962,21 @@ function parseRevision(value) {
2802
2962
  rollbackAllowed: data.rollbackAllowed === true
2803
2963
  };
2804
2964
  }
2965
+ var NOT_CONNECTED = /* @__PURE__ */ new Set([
2966
+ "ETIMEDOUT",
2967
+ "ECONNREFUSED",
2968
+ "ENOTFOUND",
2969
+ "EAI_AGAIN",
2970
+ "ENETUNREACH",
2971
+ "EHOSTUNREACH",
2972
+ "ENETDOWN",
2973
+ "UND_ERR_CONNECT_TIMEOUT"
2974
+ ]);
2975
+ var CONNECT_RETRIES = 3;
2976
+ function networkCode(error) {
2977
+ const cause = error.cause;
2978
+ return cause?.code ?? cause?.errors?.find((inner) => inner.code)?.code;
2979
+ }
2805
2980
  async function withRetries(fn, retries = 3) {
2806
2981
  for (let attempt = 0; ; attempt++) {
2807
2982
  try {
@@ -2821,24 +2996,34 @@ var CloudClient = class {
2821
2996
  }
2822
2997
  host;
2823
2998
  token;
2824
- async send(method, path39, init) {
2999
+ async send(method, path40, init) {
2825
3000
  const headers = {
2826
3001
  "User-Agent": `foss-design/${packageVersion()} (${os.platform()})`,
2827
3002
  Accept: "application/json"
2828
3003
  };
2829
3004
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
2830
3005
  if (init.type) headers["Content-Type"] = init.type;
2831
- try {
2832
- return await fetch(`${this.host}/api${path39}`, {
2833
- method,
2834
- headers,
2835
- body: init.body,
2836
- signal: AbortSignal.timeout(init.timeout)
2837
- });
2838
- } catch (error) {
2839
- const cause = error.cause;
2840
- const reason = error.name === "TimeoutError" ? "timed out" : cause?.code ?? cause?.message ?? error.message;
2841
- throw new CloudError(`Could not reach ${this.host}: ${reason}`, 0, "network");
3006
+ for (let attempt = 0; ; attempt++) {
3007
+ try {
3008
+ return await fetch(`${this.host}/api${path40}`, {
3009
+ method,
3010
+ headers,
3011
+ body: init.body,
3012
+ signal: AbortSignal.timeout(init.timeout)
3013
+ });
3014
+ } catch (error) {
3015
+ const timedOut = error.name === "TimeoutError";
3016
+ const code = networkCode(error);
3017
+ const again = !timedOut && (NOT_CONNECTED.has(code ?? "") || method === "GET" || method === "HEAD");
3018
+ if (again && attempt < CONNECT_RETRIES) {
3019
+ await sleep(1e3 * 2 ** attempt);
3020
+ continue;
3021
+ }
3022
+ const cause = error.cause;
3023
+ const reason = timedOut ? "timed out" : code ?? cause?.message ?? error.message;
3024
+ const tries = attempt ? ` (tried ${attempt + 1} times)` : "";
3025
+ throw new CloudError(`Could not reach ${this.host}: ${reason}${tries}`, 0, "network");
3026
+ }
2842
3027
  }
2843
3028
  }
2844
3029
  async fail(response) {
@@ -2850,8 +3035,8 @@ var CloudClient = class {
2850
3035
  throw errorFor(this.host, response.status, body, response.headers.get("retry-after"));
2851
3036
  }
2852
3037
  /** JSON in, JSON out; any non-2xx answer throws a CloudError. */
2853
- async request(method, path39, body) {
2854
- const response = await this.send(method, path39, {
3038
+ async request(method, path40, body) {
3039
+ const response = await this.send(method, path40, {
2855
3040
  body: body === void 0 ? void 0 : JSON.stringify(body),
2856
3041
  type: body === void 0 ? void 0 : "application/json",
2857
3042
  timeout: JSON_TIMEOUT
@@ -2863,7 +3048,7 @@ var CloudClient = class {
2863
3048
  try {
2864
3049
  return JSON.parse(text2);
2865
3050
  } catch {
2866
- throw new CloudError(`${this.host} answered ${method} ${path39} with something other than JSON`, 0, "bad_response");
3051
+ throw new CloudError(`${this.host} answered ${method} ${path40} with something other than JSON`, 0, "bad_response");
2867
3052
  }
2868
3053
  }
2869
3054
  /** POST /device/code. */
@@ -2898,8 +3083,8 @@ var CloudClient = class {
2898
3083
  const list = (value, role) => (Array.isArray(value) ? value : []).map((item) => parseProject(item, role)).filter((item) => item !== null);
2899
3084
  return { owned: list(body.owned, "owner"), shared: list(body.shared, "member") };
2900
3085
  }
2901
- async createProject(name) {
2902
- const body = record(await this.request("POST", "/projects", { name }));
3086
+ async createProject(name, kind = "design") {
3087
+ const body = record(await this.request("POST", "/projects", kind === "design" ? { name } : { name, kind }));
2903
3088
  const project = parseProject(body.project ?? body, "owner");
2904
3089
  if (!project) throw new CloudError(`${this.host} did not return the new project`, 0, "bad_response");
2905
3090
  return project;
@@ -2981,6 +3166,21 @@ var CloudClient = class {
2981
3166
  );
2982
3167
  return { publicId: str(body.publicId) ?? null };
2983
3168
  }
3169
+ /** The pages of a pages project, last changed first. */
3170
+ async pages(projectId) {
3171
+ const body = record(await this.request("GET", `/projects/${encodeURIComponent(projectId)}/pages`));
3172
+ return (Array.isArray(body.pages) ? body.pages : []).map(parsePage).filter((page) => !!page);
3173
+ }
3174
+ /** Publish a page (its link stays across versions) or turn its link off (owner only). */
3175
+ async pageAction(projectId, slug2, action) {
3176
+ const body = record(
3177
+ await this.request(
3178
+ "POST",
3179
+ `/projects/${encodeURIComponent(projectId)}/pages/${encodeURIComponent(slug2)}/${action}`
3180
+ )
3181
+ );
3182
+ return { publicId: str(body.publicId) ?? null };
3183
+ }
2984
3184
  /** POST /blobs/missing, in batches: the hashes the cloud does not have yet. */
2985
3185
  async missing(projectId, hashes2) {
2986
3186
  const missing = [];
@@ -3054,8 +3254,8 @@ function cloudHost() {
3054
3254
  return normalizeHost(process.env.FOSS_DESIGN_CLOUD || DEFAULT_CLOUD);
3055
3255
  }
3056
3256
  function credentialsFile() {
3057
- const base = process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : process.env.XDG_CONFIG_HOME || path16.join(os2.homedir(), ".config");
3058
- return path16.join(base, "foss-design", "credentials.json");
3257
+ const base2 = process.platform === "win32" && process.env.APPDATA ? process.env.APPDATA : process.env.XDG_CONFIG_HOME || path16.join(os2.homedir(), ".config");
3258
+ return path16.join(base2, "foss-design", "credentials.json");
3059
3259
  }
3060
3260
  function readAll() {
3061
3261
  const file = credentialsFile();
@@ -3114,9 +3314,10 @@ function readLink(paths) {
3114
3314
  if (typeof data?.host !== "string" || typeof data.project !== "string")
3115
3315
  throw new CliError(`${file} has no host or project; run \`design link <project>\` again`);
3116
3316
  const units = {};
3117
- for (const [key, base] of Object.entries(data.units ?? {})) {
3118
- if (!isUnitKey(key) || typeof base?.rev !== "number") continue;
3119
- units[key] = { rev: base.rev, files: base.files && typeof base.files === "object" ? base.files : {} };
3317
+ for (const [key, base2] of Object.entries(data.units ?? {})) {
3318
+ if (!isUnitKey(key) || typeof base2?.rev !== "number") continue;
3319
+ units[key] = { rev: base2.rev, files: base2.files && typeof base2.files === "object" ? base2.files : {} };
3320
+ if (base2.deps && typeof base2.deps === "object") units[key].deps = base2.deps;
3120
3321
  }
3121
3322
  const conflicts = {};
3122
3323
  for (const [key, rev] of Object.entries(data.conflicts ?? {})) if (typeof rev === "number") conflicts[key] = rev;
@@ -3156,23 +3357,24 @@ function diffFiles(a, b) {
3156
3357
  for (const file of /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])) if (a[file] !== b[file]) out.push(file);
3157
3358
  return out.sort();
3158
3359
  }
3159
- function unitStatus(key, link, local, remote) {
3160
- const base = link.units[key];
3161
- const baseRev = base?.rev ?? 0;
3360
+ function unitStatus(key, link, local, remote, design) {
3361
+ const base2 = link.units[key];
3362
+ const baseRev = base2?.rev ?? 0;
3162
3363
  const localFiles = hashes(local ?? {});
3163
3364
  const remoteUnit = remote?.get(key);
3164
3365
  const remoteFiles = remoteUnit ? hashes(remoteUnit.manifest) : {};
3165
3366
  const deletedRemotely = !!remote && !remoteUnit && baseRev > 0;
3166
3367
  const ahead = remote ? remoteUnit ? remoteUnit.headRev > baseRev : baseRev > 0 : false;
3167
- let changed = diffFiles(localFiles, base?.files ?? {});
3368
+ let changed = diffFiles(localFiles, base2?.files ?? {});
3168
3369
  if (ahead) changed = changed.filter((file) => localFiles[file] !== remoteFiles[file]);
3169
- const remoteChanged = ahead ? diffFiles(remoteFiles, base?.files ?? {}) : [];
3370
+ const remoteChanged = ahead ? diffFiles(remoteFiles, base2?.files ?? {}) : [];
3371
+ const usesChanged = design ? changedDeps(design, base2?.deps) : [];
3170
3372
  const conflictRev = link.conflicts[key];
3171
3373
  let state;
3172
3374
  if (remoteUnit?.archived) state = "archived";
3173
3375
  else if (conflictRev !== void 0 || changed.length && remoteChanged.length && !deletedRemotely) state = "conflict";
3174
3376
  else if (ahead) state = "remote_ahead";
3175
- else if (changed.length) state = "local_changes";
3377
+ else if (changed.length || usesChanged.length) state = "local_changes";
3176
3378
  else state = "in_sync";
3177
3379
  return {
3178
3380
  key,
@@ -3181,12 +3383,25 @@ function unitStatus(key, link, local, remote) {
3181
3383
  headRev: remoteUnit ? remoteUnit.headRev : null,
3182
3384
  local: Object.keys(localFiles).length > 0,
3183
3385
  changed,
3386
+ usesChanged,
3184
3387
  ahead,
3185
3388
  remoteChanged,
3186
3389
  deletedRemotely,
3187
3390
  conflictRev
3188
3391
  };
3189
3392
  }
3393
+ function changedDeps(design, deps) {
3394
+ const out = [];
3395
+ for (const [rel, hash] of Object.entries(deps ?? {})) {
3396
+ let now = null;
3397
+ try {
3398
+ now = hashBuffer(fs17.readFileSync(path17.join(design, rel)));
3399
+ } catch {
3400
+ }
3401
+ if (now !== hash) out.push(rel);
3402
+ }
3403
+ return out.sort();
3404
+ }
3190
3405
  function allUnitKeys(link, local, remote) {
3191
3406
  const keys = /* @__PURE__ */ new Set([...local.keys(), ...Object.keys(link.units), ...Object.keys(link.conflicts)]);
3192
3407
  for (const key of remote?.keys() ?? []) keys.add(key);
@@ -3207,10 +3422,10 @@ function rate(bytes, started) {
3207
3422
  }
3208
3423
  var seconds = (started) => `${((Date.now() - started) / 1e3).toFixed(1)}s`;
3209
3424
  function webUrl(link, unit) {
3210
- const base = `${link.host.replace(/\/+$/, "")}/p/${encodeURIComponent(link.project)}`;
3211
- if (!unit) return base;
3212
- if (unit === SYSTEM_UNIT) return `${base}/system`;
3213
- return `${base}/c/${encodeURIComponent(unit.slice("canvas/".length))}`;
3425
+ const base2 = `${link.host.replace(/\/+$/, "")}/p/${encodeURIComponent(link.project)}`;
3426
+ if (!unit) return base2;
3427
+ if (unit === SYSTEM_UNIT) return `${base2}/system`;
3428
+ return `${base2}/c/${encodeURIComponent(unit.slice("canvas/".length))}`;
3214
3429
  }
3215
3430
  function newReport(command, link) {
3216
3431
  return { ok: true, command, host: link.host, project: link.project, url: webUrl(link), units: [], hints: [] };
@@ -3513,10 +3728,10 @@ var AUTO_VIEWPORT_HEIGHT = 900;
3513
3728
  function withTheme(url, theme2) {
3514
3729
  return `${url}${url.includes("?") ? "&" : "?"}theme=${theme2}`;
3515
3730
  }
3516
- function absolute(base, url) {
3517
- return /^https?:\/\//.test(url) ? url : `${base.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
3731
+ function absolute(base2, url) {
3732
+ return /^https?:\/\//.test(url) ? url : `${base2.replace(/\/$/, "")}${url.startsWith("/") ? "" : "/"}${url}`;
3518
3733
  }
3519
- function frameJob(base, canvas, page, item, theme2) {
3734
+ function frameJob(base2, canvas, page, item, theme2) {
3520
3735
  const auto = item.frame.height === "auto";
3521
3736
  const pinned = item.kind === "screen" ? item.theme : void 0;
3522
3737
  const effective = pinned ?? theme2;
@@ -3525,7 +3740,7 @@ function frameJob(base, canvas, page, item, theme2) {
3525
3740
  canvas,
3526
3741
  id: item.id,
3527
3742
  page,
3528
- url: item.kind === "url" ? item.url : withTheme(absolute(base, item.url), effective),
3743
+ url: item.kind === "url" ? item.url : withTheme(absolute(base2, item.url), effective),
3529
3744
  width: item.frame.width,
3530
3745
  height: auto ? AUTO_VIEWPORT_HEIGHT : item.frame.height,
3531
3746
  fullPage: auto,
@@ -3536,7 +3751,7 @@ function frameJob(base, canvas, page, item, theme2) {
3536
3751
  function isFrameItem(item) {
3537
3752
  return item.kind === "screen" || item.kind === "url";
3538
3753
  }
3539
- function canvasFrames(doc, base, theme2, filter = {}) {
3754
+ function canvasFrames(doc, base2, theme2, filter = {}) {
3540
3755
  const jobs = [];
3541
3756
  for (const page of doc.pages) {
3542
3757
  if (filter.page && page.id !== filter.page) continue;
@@ -3546,20 +3761,20 @@ function canvasFrames(doc, base, theme2, filter = {}) {
3546
3761
  if (filter.id && item.id !== filter.id) continue;
3547
3762
  if (item.kind === "url" && filter.includeUrls === false) continue;
3548
3763
  if (item.kind === "screen" && item.missing && filter.skipMissing !== false) continue;
3549
- jobs.push(frameJob(base, doc.id, page.id, item, theme2));
3764
+ jobs.push(frameJob(base2, doc.id, page.id, item, theme2));
3550
3765
  }
3551
3766
  }
3552
3767
  }
3553
3768
  return jobs;
3554
3769
  }
3555
- function systemFrames(doc, base, theme2) {
3770
+ function systemFrames(doc, base2, theme2) {
3556
3771
  const specimens = doc.components.map((component) => ({ id: component.id, url: component.url }));
3557
3772
  if (doc.typographyUrl) specimens.push({ id: "@typography", url: doc.typographyUrl });
3558
3773
  return specimens.map(({ id: id2, url }) => ({
3559
3774
  key: `@system/${id2}`,
3560
3775
  canvas: "@system",
3561
3776
  id: id2,
3562
- url: withTheme(absolute(base, url), theme2),
3777
+ url: withTheme(absolute(base2, url), theme2),
3563
3778
  width: 1200,
3564
3779
  height: 800,
3565
3780
  fullPage: true,
@@ -3788,7 +4003,7 @@ function keep(paths, key, rel, side, data) {
3788
4003
  fs23.mkdirSync(path22.dirname(file), { recursive: true });
3789
4004
  fs23.writeFileSync(file, data);
3790
4005
  }
3791
- function mergeUnit(paths, key, headRev, base, here, cloud2, blob) {
4006
+ function mergeUnit(paths, key, headRev, base2, here, cloud2, blob) {
3792
4007
  const files = { added: 0, changed: 0, removed: 0, merged: 0 };
3793
4008
  const conflicts = [];
3794
4009
  const stop = key === SYSTEM_UNIT ? paths.design : path22.dirname(unitRoot(paths, key));
@@ -3799,8 +4014,8 @@ function mergeUnit(paths, key, headRev, base, here, cloud2, blob) {
3799
4014
  fs23.rmSync(file, { force: true });
3800
4015
  pruneEmpty(path22.dirname(file), stop);
3801
4016
  };
3802
- for (const rel of [.../* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(here), ...Object.keys(cloud2)])].sort()) {
3803
- const b = base[rel];
4017
+ for (const rel of [.../* @__PURE__ */ new Set([...Object.keys(base2), ...Object.keys(here), ...Object.keys(cloud2)])].sort()) {
4018
+ const b = base2[rel];
3804
4019
  const h = here[rel]?.hash;
3805
4020
  const c = cloud2[rel]?.hash;
3806
4021
  if (h === c || c === b) continue;
@@ -3876,8 +4091,8 @@ function finishMerge(paths, link, key, accept = false) {
3876
4091
  // src/cloud/meta.ts
3877
4092
  import fs24 from "fs";
3878
4093
  import path23 from "path";
3879
- async function syncProjectMeta(paths, client, link) {
3880
- const remote = await client.project(link.project);
4094
+ async function syncProjectMeta(paths, client2, link) {
4095
+ const remote = await client2.project(link.project);
3881
4096
  if (!remote) return [];
3882
4097
  const lines = [];
3883
4098
  link.name = remote.name;
@@ -3888,17 +4103,17 @@ async function syncProjectMeta(paths, client, link) {
3888
4103
  link.icon = cloud2;
3889
4104
  return lines;
3890
4105
  }
3891
- const base = link.icon;
3892
- const localChanged = base === void 0 ? cloud2 === null : local !== base;
3893
- const cloudChanged = base === void 0 ? local === null : cloud2 !== base;
4106
+ const base2 = link.icon;
4107
+ const localChanged = base2 === void 0 ? cloud2 === null : local !== base2;
4108
+ const cloudChanged = base2 === void 0 ? local === null : cloud2 !== base2;
3894
4109
  if (localChanged && !cloudChanged) {
3895
4110
  try {
3896
4111
  if (file) {
3897
4112
  const ext = path23.extname(file).slice(1);
3898
- await client.setProjectIcon(link.project, { data: fs24.readFileSync(file), type: ICON_TYPES[ext] });
4113
+ await client2.setProjectIcon(link.project, { data: fs24.readFileSync(file), type: ICON_TYPES[ext] });
3899
4114
  lines.push("The project icon went up to the cloud.");
3900
4115
  } else {
3901
- await client.setProjectIcon(link.project, null);
4116
+ await client2.setProjectIcon(link.project, null);
3902
4117
  lines.push("The project icon was removed in the cloud too.");
3903
4118
  }
3904
4119
  link.icon = local;
@@ -3908,7 +4123,7 @@ async function syncProjectMeta(paths, client, link) {
3908
4123
  }
3909
4124
  return lines;
3910
4125
  }
3911
- const icon = cloud2 ? await client.projectIcon(link.project) : null;
4126
+ const icon = cloud2 ? await client2.projectIcon(link.project) : null;
3912
4127
  if (icon && !sniffIcon(icon.data))
3913
4128
  return [...lines, "The cloud sent a project icon that is no image; it was left out."];
3914
4129
  writeIcon(paths, icon?.data ?? null);
@@ -3947,14 +4162,14 @@ function applyUnit(paths, key, local, target, blobs) {
3947
4162
  return counts;
3948
4163
  }
3949
4164
  async function pull(ctx, options) {
3950
- const { paths, client } = ctx;
4165
+ const { paths, client: client2 } = ctx;
3951
4166
  const link = structuredClone(ctx.link);
3952
4167
  const report = newReport("pull", link);
3953
4168
  for (const line of await syncProjectMeta(ctx.paths, ctx.client, link)) report.hints.push(line);
3954
4169
  const selected = unique(options.units.map(unitArg));
3955
4170
  const theirs = unique(options.theirs.map(unitArg));
3956
4171
  const scan = scanLocal(paths);
3957
- const remote = await client.units(link.project);
4172
+ const remote = await client2.units(link.project);
3958
4173
  const local = (key) => scan.units.get(key)?.manifest ?? {};
3959
4174
  const keys = selected.length || theirs.length ? unique([...selected, ...theirs]) : allUnitKeys(link, /* @__PURE__ */ new Map(), remote);
3960
4175
  const clashes = caseClashes([...remote.keys(), ...scan.units.keys(), ...Object.keys(link.units)]);
@@ -4042,9 +4257,9 @@ async function pull(ctx, options) {
4042
4257
  checkManifest(plan.key, plan.remote.manifest);
4043
4258
  for (const entry of Object.values(plan.remote.manifest)) needed.add(entry.hash);
4044
4259
  if (plan.kind !== "merge") continue;
4045
- const base = link.units[plan.key]?.files ?? {};
4260
+ const base2 = link.units[plan.key]?.files ?? {};
4046
4261
  const here = local(plan.key);
4047
- for (const [rel, hash] of Object.entries(base)) {
4262
+ for (const [rel, hash] of Object.entries(base2)) {
4048
4263
  const h = here[rel]?.hash;
4049
4264
  const c = plan.remote.manifest[rel]?.hash;
4050
4265
  if (h && c && h !== hash && c !== hash && h !== c) bases.add(hash);
@@ -4066,7 +4281,7 @@ async function pull(ctx, options) {
4066
4281
  const line = () => `Downloading ${gotFiles}/${download.length} files \xB7 ${megabytes(gotBytes)}${rate(gotBytes, started)}`;
4067
4282
  bar.update(line());
4068
4283
  await mapLimit(download, TRANSFERS, async (hash) => {
4069
- const data = await client.getBlob(link.project, hash);
4284
+ const data = await client2.getBlob(link.project, hash);
4070
4285
  blobs.set(hash, data);
4071
4286
  gotFiles++;
4072
4287
  gotBytes += data.length;
@@ -4079,7 +4294,7 @@ async function pull(ctx, options) {
4079
4294
  TRANSFERS,
4080
4295
  async (hash) => {
4081
4296
  try {
4082
- blobs.set(hash, await client.getBlob(link.project, hash));
4297
+ blobs.set(hash, await client2.getBlob(link.project, hash));
4083
4298
  } catch {
4084
4299
  }
4085
4300
  }
@@ -4144,14 +4359,14 @@ function blockedReport(status, message) {
4144
4359
  };
4145
4360
  }
4146
4361
  async function push(ctx, options) {
4147
- const { paths, client, log } = ctx;
4362
+ const { paths, client: client2, log } = ctx;
4148
4363
  const link = structuredClone(ctx.link);
4149
4364
  const report = newReport("push", link);
4150
4365
  for (const line of await syncProjectMeta(ctx.paths, ctx.client, link)) report.hints.push(line);
4151
4366
  const selected = unique(options.units.map(unitArg));
4152
4367
  const resolved = unique(options.resolved.map(unitArg));
4153
4368
  const scan = scanLocal(paths);
4154
- const remote = await client.units(link.project);
4369
+ const remote = await client2.units(link.project);
4155
4370
  const clashes = caseClashes([...scan.units.keys(), ...remote.keys()]);
4156
4371
  if (clashes.size) {
4157
4372
  const pairs = [...clashes].filter(([a, b]) => a < b).map(([a, b]) => `${a} / ${b}`);
@@ -4175,7 +4390,7 @@ async function push(ctx, options) {
4175
4390
  link.units[key] = { rev: head, files: link.units[key]?.files ?? {} };
4176
4391
  delete link.conflicts[key];
4177
4392
  }
4178
- const status = (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote);
4393
+ const status = (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote, paths.design);
4179
4394
  for (const key of selected) {
4180
4395
  if (!scan.units.has(key) && !remote.has(key) && !link.units[key]) {
4181
4396
  const known = [...scan.units.keys()].join(", ") || "none";
@@ -4194,7 +4409,7 @@ async function push(ctx, options) {
4194
4409
  candidates = [...resolved];
4195
4410
  for (const key of scan.units.keys()) {
4196
4411
  const s = status(key);
4197
- if (s.changed.length || s.conflictRev !== void 0) candidates.push(key);
4412
+ if (s.changed.length || s.usesChanged.length || s.conflictRev !== void 0) candidates.push(key);
4198
4413
  }
4199
4414
  candidates = unique(candidates);
4200
4415
  }
@@ -4285,7 +4500,8 @@ async function push(ctx, options) {
4285
4500
  }
4286
4501
  const pushing = toPush.filter((key) => {
4287
4502
  const s = status(key);
4288
- if (systemPush || resolved.includes(key) || s.changed.length || !remote.has(key)) return true;
4503
+ if (systemPush || resolved.includes(key) || s.changed.length || s.usesChanged.length || !remote.has(key))
4504
+ return true;
4289
4505
  report.units.push({ unit: key, action: "unchanged", rev: s.baseRev, url: webUrl(link, key) });
4290
4506
  return false;
4291
4507
  });
@@ -4295,6 +4511,7 @@ async function push(ctx, options) {
4295
4511
  const project = new DesignProject(paths, STATIC_URLS);
4296
4512
  const systemRev = remote.get(SYSTEM_UNIT)?.headRev ?? 0;
4297
4513
  const units = [];
4514
+ const deps = /* @__PURE__ */ new Map();
4298
4515
  const files = /* @__PURE__ */ new Map();
4299
4516
  const addFiles = (manifest, dir) => {
4300
4517
  for (const [rel, entry] of Object.entries(manifest))
@@ -4315,6 +4532,12 @@ async function push(ctx, options) {
4315
4532
  );
4316
4533
  const source = scan.units.get(key).manifest;
4317
4534
  const build2 = buildManifest(unitBuildDir(paths, key));
4535
+ deps.set(key, borrowedFiles(paths, key, result.modules));
4536
+ const uses = status(key).usesChanged;
4537
+ if (uses.length && !status(key).changed.length)
4538
+ report.hints.push(
4539
+ `${key} is pushed for files it uses from outside its folder: ${uses.slice(0, 3).join(", ")}${uses.length > 3 ? ` and ${uses.length - 3} more` : ""}`
4540
+ );
4318
4541
  addFiles(source, paths.design);
4319
4542
  addFiles(build2, unitBuildDir(paths, key));
4320
4543
  units.push({
@@ -4326,7 +4549,7 @@ async function push(ctx, options) {
4326
4549
  systemRev: id2 === null ? null : systemRev
4327
4550
  });
4328
4551
  }
4329
- const missing = await client.missing(link.project, [...files.keys()]);
4552
+ const missing = await client2.missing(link.project, [...files.keys()]);
4330
4553
  if (missing.length) {
4331
4554
  const unknown = missing.find((hash) => !files.has(hash));
4332
4555
  if (unknown) throw new CliError(`The cloud asked for ${unknown.slice(0, 12)}, which is not part of this push`);
@@ -4341,7 +4564,7 @@ async function push(ctx, options) {
4341
4564
  const file = files.get(hash);
4342
4565
  const data = fs26.readFileSync(file);
4343
4566
  try {
4344
- await client.putBlob(link.project, hash, data);
4567
+ await client2.putBlob(link.project, hash, data);
4345
4568
  } catch (error) {
4346
4569
  if (error instanceof CloudError && error.status === 413)
4347
4570
  throw new CliError(`${path25.relative(process.cwd(), file)} (${megabytes(data.length)}): ${error.message}`);
@@ -4356,7 +4579,7 @@ async function push(ctx, options) {
4356
4579
  log(`Pushing ${plural(units.length, "unit")}\u2026`);
4357
4580
  let revs2;
4358
4581
  try {
4359
- revs2 = await client.push(link.project, units);
4582
+ revs2 = await client2.push(link.project, units);
4360
4583
  } catch (error) {
4361
4584
  if (!(error instanceof CloudError) || error.status !== 409 || error.error !== "conflict") throw error;
4362
4585
  report.ok = false;
@@ -4368,7 +4591,12 @@ async function push(ctx, options) {
4368
4591
  }
4369
4592
  for (const unit of units) {
4370
4593
  const rev = revs2.get(unit.key) ?? unit.baseRev + 1;
4371
- link.units[unit.key] = { rev, files: hashes(unit.source) };
4594
+ const used = deps.get(unit.key);
4595
+ link.units[unit.key] = {
4596
+ rev,
4597
+ files: hashes(unit.source),
4598
+ ...used && Object.keys(used).length ? { deps: used } : {}
4599
+ };
4372
4600
  report.units.push({
4373
4601
  unit: unit.key,
4374
4602
  action: unit.baseRev ? "pushed" : "created",
@@ -4382,6 +4610,19 @@ async function push(ctx, options) {
4382
4610
  report.units.sort((a, b) => byUnit(a.unit, b.unit));
4383
4611
  return report;
4384
4612
  }
4613
+ function borrowedFiles(paths, key, modules) {
4614
+ const own = [unitRoot(paths, key), paths.system, paths.config];
4615
+ const out = {};
4616
+ for (const rel of modules) {
4617
+ const file = path25.join(paths.root, rel);
4618
+ if (own.some((dir) => file === dir || isInside(dir, file)) || isInside(paths.cache, file)) continue;
4619
+ try {
4620
+ out[toPosix(path25.relative(paths.design, file))] = hashFile(file).hash;
4621
+ } catch {
4622
+ }
4623
+ }
4624
+ return out;
4625
+ }
4385
4626
  function refuseLegacyGo(paths, keys) {
4386
4627
  const project = new DesignProject(paths, STATIC_URLS);
4387
4628
  const hits = /* @__PURE__ */ new Set();
@@ -4435,16 +4676,16 @@ var record2 = (value) => value && typeof value === "object" && !Array.isArray(va
4435
4676
  function parseStroke(value) {
4436
4677
  const data = record2(value);
4437
4678
  if (!data) return null;
4438
- const { id: id2, kind, ink, size: size2, points, text: text2 } = data;
4679
+ const { id: id2, kind, ink, size: size3, points, text: text2 } = data;
4439
4680
  if (typeof id2 !== "string" || !STROKE_ID.test(id2)) return null;
4440
4681
  if (!STROKE_KINDS.includes(kind) || !INKS.includes(ink)) return null;
4441
- if (typeof size2 !== "number" || !(size2 >= 0.5 && size2 <= 400)) return null;
4682
+ if (typeof size3 !== "number" || !(size3 >= 0.5 && size3 <= 400)) return null;
4442
4683
  if (!Array.isArray(points) || points.length > MAX_NUMBERS) return null;
4443
4684
  if (!points.every((n2) => typeof n2 === "number" && Number.isFinite(n2) && Math.abs(n2) <= MAX_COORD)) return null;
4444
4685
  const need = kind === "pen" ? points.length >= 3 && points.length % 3 === 0 : points.length === (kind === "text" ? 2 : 4);
4445
4686
  if (!need) return null;
4446
4687
  if (kind === "text" && (typeof text2 !== "string" || !text2.trim() || text2.length > MAX_TEXT)) return null;
4447
- const stroke = { id: id2, kind, ink, size: size2, points };
4688
+ const stroke = { id: id2, kind, ink, size: size3, points };
4448
4689
  if (kind === "text") stroke.text = text2;
4449
4690
  return stroke;
4450
4691
  }
@@ -4579,15 +4820,15 @@ function drawingGroups(strokes) {
4579
4820
  function parseLive(value) {
4580
4821
  const data = record2(value);
4581
4822
  if (!data) return null;
4582
- const { id: id2, kind, ink, size: size2, text: text2, at, points } = data;
4823
+ const { id: id2, kind, ink, size: size3, text: text2, at, points } = data;
4583
4824
  if (typeof id2 !== "string" || !STROKE_ID.test(id2)) return null;
4584
4825
  if (!STROKE_KINDS.includes(kind) || !INKS.includes(ink)) return null;
4585
- if (typeof size2 !== "number" || !(size2 >= 0.5 && size2 <= 400)) return null;
4826
+ if (typeof size3 !== "number" || !(size3 >= 0.5 && size3 <= 400)) return null;
4586
4827
  if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at > MAX_NUMBERS) return null;
4587
4828
  if (!Array.isArray(points) || points.length > MAX_NUMBERS) return null;
4588
4829
  if (!points.every((n2) => typeof n2 === "number" && Number.isFinite(n2) && Math.abs(n2) <= MAX_COORD)) return null;
4589
4830
  if (text2 !== void 0 && (typeof text2 !== "string" || text2.length > MAX_TEXT)) return null;
4590
- const stroke = { id: id2, kind, ink, size: size2, at, points };
4831
+ const stroke = { id: id2, kind, ink, size: size3, at, points };
4591
4832
  if (typeof text2 === "string") stroke.text = text2;
4592
4833
  return stroke;
4593
4834
  }
@@ -4657,10 +4898,10 @@ function sendJson(res, status, body) {
4657
4898
  function readBody(req, limit = 25 * 1024 * 1024) {
4658
4899
  return new Promise((resolve, reject) => {
4659
4900
  const chunks = [];
4660
- let size2 = 0;
4901
+ let size3 = 0;
4661
4902
  req.on("data", (chunk2) => {
4662
- size2 += chunk2.length;
4663
- if (size2 > limit) {
4903
+ size3 += chunk2.length;
4904
+ if (size3 > limit) {
4664
4905
  reject(new Error("body too large"));
4665
4906
  req.destroy();
4666
4907
  } else chunks.push(chunk2);
@@ -4845,6 +5086,8 @@ var Room = class {
4845
5086
  cloud = null;
4846
5087
  written = null;
4847
5088
  saveTimer;
5089
+ /** A change not on disk yet: only such a change is written, so looking at a canvas writes nothing. */
5090
+ dirty = false;
4848
5091
  idleTimer;
4849
5092
  get file() {
4850
5093
  return drawingsFile(this.hub.paths, this.canvas);
@@ -4885,39 +5128,39 @@ var Room = class {
4885
5128
  this.idleTimer.unref();
4886
5129
  }
4887
5130
  join(socket) {
4888
- const client = { peer: this.hub.nextPeer(), live: /* @__PURE__ */ new Map() };
4889
- this.clients.set(socket, client);
5131
+ const client2 = { peer: this.hub.nextPeer(), live: /* @__PURE__ */ new Map() };
5132
+ this.clients.set(socket, client2);
4890
5133
  this.connectCloud();
4891
- send(socket, { type: "hello", peer: client.peer, canDraw: this.canDraw(), drawings: this.drawings });
5134
+ send(socket, { type: "hello", peer: client2.peer, canDraw: this.canDraw(), drawings: this.drawings });
4892
5135
  socket.on("message", (data, isBinary) => {
4893
5136
  if (isBinary) return;
4894
5137
  const message = parseClientMessage(data.toString());
4895
- if (message) this.fromClient(socket, client, message);
5138
+ if (message) this.fromClient(socket, client2, message);
4896
5139
  else send(socket, { type: "error", message: "Not a drawings message." });
4897
5140
  });
4898
5141
  socket.on("close", () => {
4899
5142
  this.clients.delete(socket);
4900
- for (const [id2, key] of client.live) this.cloud?.send({ type: "live-end", key, id: id2 });
4901
- this.broadcast({ type: "gone", peer: client.peer });
5143
+ for (const [id2, key] of client2.live) this.cloud?.send({ type: "live-end", key, id: id2 });
5144
+ this.broadcast({ type: "gone", peer: client2.peer });
4902
5145
  this.release();
4903
5146
  });
4904
5147
  socket.on("error", () => socket.terminate());
4905
5148
  }
4906
- fromClient(socket, client, message) {
5149
+ fromClient(socket, client2, message) {
4907
5150
  if (!this.canDraw()) {
4908
5151
  send(socket, { type: "error", message: "You can look at these drawings but not draw on them." });
4909
5152
  return;
4910
5153
  }
4911
5154
  if (message.type === "ops") {
4912
- this.change(message.key, { add: message.add, erase: message.erase }, client.peer);
5155
+ this.change(message.key, { add: message.add, erase: message.erase }, client2.peer);
4913
5156
  send(socket, { type: "ack", id: message.id });
4914
5157
  } else if (message.type === "live") {
4915
- client.live.set(message.stroke.id, message.key);
4916
- this.broadcast({ ...message, peer: client.peer }, socket);
5158
+ client2.live.set(message.stroke.id, message.key);
5159
+ this.broadcast({ ...message, peer: client2.peer }, socket);
4917
5160
  this.cloud?.send(message);
4918
5161
  } else {
4919
- client.live.delete(message.id);
4920
- this.broadcast({ ...message, peer: client.peer }, socket);
5162
+ client2.live.delete(message.id);
5163
+ this.broadcast({ ...message, peer: client2.peer }, socket);
4921
5164
  this.cloud?.send(message);
4922
5165
  }
4923
5166
  }
@@ -4951,8 +5194,8 @@ var Room = class {
4951
5194
  }
4952
5195
  /** The cloud's answer came in, or its verdict on drawing changed: everyone here starts over from it. */
4953
5196
  rehello() {
4954
- for (const [socket, client] of this.clients)
4955
- send(socket, { type: "hello", peer: client.peer, canDraw: this.canDraw(), drawings: this.drawings });
5197
+ for (const [socket, client2] of this.clients)
5198
+ send(socket, { type: "hello", peer: client2.peer, canDraw: this.canDraw(), drawings: this.drawings });
4956
5199
  }
4957
5200
  broadcast(message, except) {
4958
5201
  const data = JSON.stringify(message);
@@ -4960,11 +5203,14 @@ var Room = class {
4960
5203
  if (socket !== except && socket.readyState === WebSocket.OPEN) socket.send(data);
4961
5204
  }
4962
5205
  save() {
5206
+ this.dirty = true;
4963
5207
  clearTimeout(this.saveTimer);
4964
5208
  this.saveTimer = setTimeout(() => this.flush(), 150);
4965
5209
  }
4966
5210
  flush() {
4967
5211
  clearTimeout(this.saveTimer);
5212
+ if (!this.dirty) return;
5213
+ this.dirty = false;
4968
5214
  const dir = path27.dirname(this.file);
4969
5215
  if (!fs28.existsSync(dir)) return;
4970
5216
  const drawings = Object.fromEntries(
@@ -5035,10 +5281,10 @@ var CloudRelay = class {
5035
5281
  /** Cloud peers with strokes on the way here, cleared when the socket drops. */
5036
5282
  peers = /* @__PURE__ */ new Set();
5037
5283
  url() {
5038
- const base = new URL(this.host);
5039
- base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
5040
- base.pathname = `/api/projects/${encodeURIComponent(this.project)}/canvases/${encodeURIComponent(this.room.canvas)}/drawings`;
5041
- return base.href;
5284
+ const base2 = new URL(this.host);
5285
+ base2.protocol = base2.protocol === "https:" ? "wss:" : "ws:";
5286
+ base2.pathname = `/api/projects/${encodeURIComponent(this.project)}/canvases/${encodeURIComponent(this.room.canvas)}/drawings`;
5287
+ return base2.href;
5042
5288
  }
5043
5289
  connect() {
5044
5290
  if (this.closed) return;
@@ -5219,7 +5465,7 @@ var EventHub = class {
5219
5465
  timer;
5220
5466
  constructor() {
5221
5467
  this.timer = setInterval(() => {
5222
- for (const client of this.clients) client.write(": ping\n\n");
5468
+ for (const client2 of this.clients) client2.write(": ping\n\n");
5223
5469
  }, 2e4);
5224
5470
  this.timer.unref();
5225
5471
  }
@@ -5237,11 +5483,11 @@ var EventHub = class {
5237
5483
  const data = `data: ${JSON.stringify(event)}
5238
5484
 
5239
5485
  `;
5240
- for (const client of this.clients) client.write(data);
5486
+ for (const client2 of this.clients) client2.write(data);
5241
5487
  }
5242
5488
  close() {
5243
5489
  clearInterval(this.timer);
5244
- for (const client of this.clients) client.end();
5490
+ for (const client2 of this.clients) client2.end();
5245
5491
  this.clients.clear();
5246
5492
  }
5247
5493
  };
@@ -5260,8 +5506,8 @@ function readState(paths) {
5260
5506
  }
5261
5507
  }
5262
5508
  function registryDir() {
5263
- const base = process.platform === "win32" ? process.env.LOCALAPPDATA || process.env.APPDATA || path28.join(os4.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path28.join(os4.homedir(), ".cache");
5264
- return path28.join(base, "foss-design", "previews");
5509
+ const base2 = process.platform === "win32" ? process.env.LOCALAPPDATA || process.env.APPDATA || path28.join(os4.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path28.join(os4.homedir(), ".cache");
5510
+ return path28.join(base2, "foss-design", "previews");
5265
5511
  }
5266
5512
  var entryFile = (pid) => path28.join(registryDir(), `${pid}.json`);
5267
5513
  function writeState(paths, state) {
@@ -5391,9 +5637,12 @@ async function startDevServer(options) {
5391
5637
  const httpServer = http2.createServer();
5392
5638
  const listening = () => httpServer.address()?.port ?? 0;
5393
5639
  dropStaleDepCache(paths, project.appDir());
5394
- const base = baseConfig(project);
5640
+ const deps = installStamp(paths, project.appDir());
5641
+ const fromApp = await appPlugins(project, "serve");
5642
+ for (const problem of fromApp.problems) console.warn(`warning: ${problem}`);
5643
+ const base2 = baseConfig(project, fromApp.plugins);
5395
5644
  const vite = await createServer({
5396
- ...base,
5645
+ ...base2,
5397
5646
  base: "/_fs/",
5398
5647
  appType: "custom",
5399
5648
  server: {
@@ -5575,6 +5824,19 @@ async function startDevServer(options) {
5575
5824
  }
5576
5825
  }, 60);
5577
5826
  };
5827
+ const scanned = entries.scannedDirs();
5828
+ vite.watcher.add(scanned.filter((dir) => !isInside(paths.design, dir)));
5829
+ let cssTimer;
5830
+ const rebuildCss = () => {
5831
+ clearTimeout(cssTimer);
5832
+ cssTimer = setTimeout(() => {
5833
+ for (const mod of vite.moduleGraph.getModulesByFile(entries.systemCssFile()) ?? []) void vite.reloadModule(mod);
5834
+ }, 80);
5835
+ };
5836
+ vite.watcher.on("add", (file) => {
5837
+ if (file.split(path29.sep).includes("node_modules") || isInside(paths.cache, file)) return;
5838
+ if (scanned.some((dir) => isInside(dir, file))) rebuildCss();
5839
+ });
5578
5840
  vite.watcher.on("all", (event, file) => {
5579
5841
  if (publicDir && isInside(publicDir, file)) project.invalidatePublic();
5580
5842
  const rel = toPosix(path29.relative(paths.design, file));
@@ -5616,7 +5878,8 @@ async function startDevServer(options) {
5616
5878
  url,
5617
5879
  root: paths.root,
5618
5880
  version: packageVersion(),
5619
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
5881
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5882
+ deps
5620
5883
  });
5621
5884
  }
5622
5885
  let closed = false;
@@ -5686,9 +5949,11 @@ async function stopServer(paths) {
5686
5949
  async function ensureServer(paths, options) {
5687
5950
  const live2 = await liveServer(paths);
5688
5951
  const stale = live2 && live2.version !== packageVersion();
5689
- if (live2 && !options.restart && !stale) return live2;
5952
+ const installed = live2?.deps !== void 0 && live2.deps !== installStamp(paths, new DesignProject(paths, DEV_URLS).appDir());
5953
+ if (live2 && !options.restart && !stale && !installed) return live2;
5690
5954
  if (stale)
5691
5955
  print(dim(`Restarting the preview server: it runs foss-design ${live2.version}, this is ${packageVersion()}.`));
5956
+ else if (installed) print(dim("Restarting the preview server: the app's packages changed since it started."));
5692
5957
  if (live2) await stopServer(paths);
5693
5958
  const recorded = readState(paths);
5694
5959
  if (recorded && pidAlive(recorded.pid)) await stopPreview(recorded);
@@ -5887,8 +6152,8 @@ function canvasArg(value, usage) {
5887
6152
  if (key === SYSTEM_UNIT) throw new CliError(`The design system is not a canvas. Usage: ${usage}`);
5888
6153
  return key.slice("canvas/".length);
5889
6154
  }
5890
- async function remoteCanvas(client, project, canvas) {
5891
- const unit = (await client.units(project)).get(canvasUnit(canvas));
6155
+ async function remoteCanvas(client2, project, canvas) {
6156
+ const unit = (await client2.units(project)).get(canvasUnit(canvas));
5892
6157
  if (!unit) throw new CliError(`${canvas} is not in the cloud yet: \`design push ${canvas}\` uploads it`);
5893
6158
  return unit;
5894
6159
  }
@@ -5919,7 +6184,7 @@ async function runCanvases(paths, options) {
5919
6184
  const rows = ids.map((id2) => {
5920
6185
  const doc = local.find((entry) => entry.id === id2);
5921
6186
  const unit = remote?.get(canvasUnit(id2));
5922
- const status = link && remote ? unitStatus(canvasUnit(id2), link, scan?.units.get(canvasUnit(id2))?.manifest, remote) : null;
6187
+ const status = link && remote ? unitStatus(canvasUnit(id2), link, scan?.units.get(canvasUnit(id2))?.manifest, remote, paths.design) : null;
5923
6188
  const frames = doc?.pages.flatMap((page) => page.sections.flatMap((section) => section.items)).filter((item) => item.kind === "screen" || item.kind === "url");
5924
6189
  return {
5925
6190
  id: id2,
@@ -5955,8 +6220,8 @@ async function runCanvases(paths, options) {
5955
6220
  }
5956
6221
  async function runUrl(paths, value, options) {
5957
6222
  const canvas = canvasArg(value, "design url <canvas>");
5958
- const { link, client } = cloud(paths);
5959
- const unit = await remoteCanvas(client, link.project, canvas);
6223
+ const { link, client: client2 } = cloud(paths);
6224
+ const unit = await remoteCanvas(client2, link.project, canvas);
5960
6225
  const url = webUrl(link, canvasUnit(canvas));
5961
6226
  const pub = unit.publicId ? publicUrl(link.host, unit.publicId) : null;
5962
6227
  const screens = unit.screens.map((s) => ({ screen: s.item, publicUrl: screenUrl(link.host, s.publicId) }));
@@ -5974,12 +6239,12 @@ async function runPublish(paths, value, publish) {
5974
6239
  const screen = value && slash > 0 ? value.slice(slash + 1) : null;
5975
6240
  const canvas = canvasArg(value && slash > 0 ? value.slice(0, slash) : value, usage);
5976
6241
  if (screen === "") throw new CliError(`Usage: ${usage}`);
5977
- const { link, client } = cloud(paths);
5978
- const unit = await remoteCanvas(client, link.project, canvas);
6242
+ const { link, client: client2 } = cloud(paths);
6243
+ const unit = await remoteCanvas(client2, link.project, canvas);
5979
6244
  if (screen) {
5980
6245
  if (!publish && !unit.screens.some((s) => s.item === screen))
5981
6246
  return print(dim(`${canvas}/${screen} has no public link`));
5982
- const { publicId: publicId2 } = await client.screenAction(link.project, canvas, screen, verb);
6247
+ const { publicId: publicId2 } = await client2.screenAction(link.project, canvas, screen, verb);
5983
6248
  if (publish) {
5984
6249
  print(
5985
6250
  `${green("\u2713")} Published ${bold(`${canvas}/${screen}`)} on its own: anyone with the link sees this screen alone`
@@ -5988,9 +6253,9 @@ async function runPublish(paths, value, publish) {
5988
6253
  } else print(`${green("\u2713")} ${bold(`${canvas}/${screen}`)} has no public link now`);
5989
6254
  return;
5990
6255
  }
5991
- const { publicId } = await client.canvasAction(link.project, canvas, verb);
6256
+ const { publicId } = await client2.canvasAction(link.project, canvas, verb);
5992
6257
  if (publish) {
5993
- const id2 = publicId ?? (await remoteCanvas(client, link.project, canvas)).publicId;
6258
+ const id2 = publicId ?? (await remoteCanvas(client2, link.project, canvas)).publicId;
5994
6259
  print(`${green("\u2713")} Published ${bold(canvas)}: anyone with the link can view it`);
5995
6260
  if (id2) print(` ${cyan(publicUrl(link.host, id2))}`);
5996
6261
  } else print(`${green("\u2713")} ${bold(canvas)} has no public link now; members still open it in the project`);
@@ -5998,9 +6263,9 @@ async function runPublish(paths, value, publish) {
5998
6263
  async function runArchive(paths, value, archive) {
5999
6264
  const verb = archive ? "archive" : "unarchive";
6000
6265
  const canvas = canvasArg(value, `design ${verb} <canvas>`);
6001
- const { link, client } = cloud(paths);
6002
- await remoteCanvas(client, link.project, canvas);
6003
- await client.canvasAction(link.project, canvas, verb);
6266
+ const { link, client: client2 } = cloud(paths);
6267
+ await remoteCanvas(client2, link.project, canvas);
6268
+ await client2.canvasAction(link.project, canvas, verb);
6004
6269
  print(
6005
6270
  archive ? `${green("\u2713")} Archived ${bold(canvas)} in the cloud; its history is kept. \`design unarchive ${canvas}\` brings it back.` : `${green("\u2713")} ${bold(canvas)} is active again ${dim(webUrl(link, canvasUnit(canvas)))}`
6006
6271
  );
@@ -6008,10 +6273,10 @@ async function runArchive(paths, value, archive) {
6008
6273
  async function runHistory(paths, value, options) {
6009
6274
  if (!value) throw new CliError("Usage: design history <canvas|system>");
6010
6275
  const key = unitArg(value);
6011
- const { link, client } = cloud(paths);
6276
+ const { link, client: client2 } = cloud(paths);
6012
6277
  let revisions;
6013
6278
  try {
6014
- revisions = await client.revisions(link.project, key);
6279
+ revisions = await client2.revisions(link.project, key);
6015
6280
  } catch (error) {
6016
6281
  if (error instanceof CloudError && error.status === 404)
6017
6282
  throw new CliError(`${key} is not in the cloud yet: \`design push\` uploads it`);
@@ -6039,8 +6304,8 @@ async function runRollback(paths, value, revArg) {
6039
6304
  const rev = Number((revArg ?? "").replace(/^r/i, ""));
6040
6305
  if (!Number.isInteger(rev) || rev < 1)
6041
6306
  throw new CliError("Usage: design rollback <canvas> <rev> (see `design history <canvas>`)");
6042
- const { link, client } = cloud(paths);
6043
- const head = await client.rollback(link.project, canvasUnit(canvas), rev);
6307
+ const { link, client: client2 } = cloud(paths);
6308
+ const head = await client2.rollback(link.project, canvasUnit(canvas), rev);
6044
6309
  print(`${green("\u2713")} ${bold(canvas)}: r${rev} is current again as r${head} in the cloud`);
6045
6310
  await runPull(paths, [canvas], { theirs: [], json: false });
6046
6311
  }
@@ -6050,18 +6315,18 @@ async function runMe(paths, options) {
6050
6315
  const link = paths ? readLink(paths) : null;
6051
6316
  const host = link?.host ?? cloudHost();
6052
6317
  const credential = requireCredential(host);
6053
- const client = new CloudClient(host, credential.token);
6318
+ const client2 = new CloudClient(host, credential.token);
6054
6319
  const [me, usage, activity] = await Promise.all([
6055
- client.request("GET", "/me").then(obj),
6056
- client.request("GET", "/me/usage").then(obj),
6057
- client.request("GET", "/me/activity").then(obj)
6320
+ client2.request("GET", "/me").then(obj),
6321
+ client2.request("GET", "/me/usage").then(obj),
6322
+ client2.request("GET", "/me/activity").then(obj)
6058
6323
  ]);
6059
6324
  const plan = obj(usage.plan);
6060
6325
  const limits = obj(usage.limits);
6061
6326
  const totals = obj(activity.totals);
6062
6327
  let linked = null;
6063
6328
  if (link) {
6064
- const projects = await client.projects().catch(() => null);
6329
+ const projects = await client2.projects().catch(() => null);
6065
6330
  const project = projects && [...projects.owned, ...projects.shared].find((p) => p.id === link.project);
6066
6331
  linked = project ? { id: project.id, name: project.name, role: project.role } : { id: link.project, name: "", role: "" };
6067
6332
  }
@@ -6187,9 +6452,11 @@ async function runCheck(paths, targets2, options) {
6187
6452
  );
6188
6453
  }
6189
6454
  const issues = canvases.length ? canvases.flatMap((id2) => project.canvas(id2)?.doc.issues ?? []) : project.issues();
6455
+ for (const message of (await appPlugins(project, "serve")).problems)
6456
+ issues.push({ severity: "warning", file: relToRoot(paths, paths.config), message });
6190
6457
  let frames = [];
6191
6458
  if (options.render) {
6192
- let base;
6459
+ let base2;
6193
6460
  let docOf = (id2) => project.canvas(id2).doc;
6194
6461
  let systemDoc = project.system()?.doc ?? null;
6195
6462
  let close = async () => {
@@ -6202,24 +6469,24 @@ async function runCheck(paths, targets2, options) {
6202
6469
  });
6203
6470
  docOf = (id2) => build2.read(`api/canvas/${id2}.json`) ?? project.canvas(id2).doc;
6204
6471
  systemDoc = canvases.length ? null : build2.read("api/system.json");
6205
- base = build2.base;
6472
+ base2 = build2.base;
6206
6473
  close = build2.close;
6207
- if (!options.json) print(dim(`Serving the build at ${base}`));
6474
+ if (!options.json) print(dim(`Serving the build at ${base2}`));
6208
6475
  } else {
6209
6476
  const wasRunning = await liveServer(paths);
6210
6477
  const server = await ensureServer(paths, {});
6211
6478
  if (!wasRunning && !options.json)
6212
6479
  print(dim(`Started the preview server at ${server.url} (\`design stop\` stops it).`));
6213
- base = `http://127.0.0.1:${server.port}`;
6480
+ base2 = `http://127.0.0.1:${server.port}`;
6214
6481
  }
6215
6482
  const jobs = (canvases.length ? canvases : known).flatMap((id2) => {
6216
6483
  const doc = docOf(id2);
6217
6484
  const theme2 = doc.theme ?? "light";
6218
6485
  const screens = picked.get(id2);
6219
6486
  const filter = { includeUrls: false, page: options.page };
6220
- return screens ? [...screens].flatMap((screen) => canvasFrames(doc, base, theme2, { ...filter, id: screen })) : canvasFrames(doc, base, theme2, filter);
6487
+ return screens ? [...screens].flatMap((screen) => canvasFrames(doc, base2, theme2, { ...filter, id: screen })) : canvasFrames(doc, base2, theme2, filter);
6221
6488
  });
6222
- if (!canvases.length && systemDoc) jobs.unshift(...systemFrames(systemDoc, base, "light"));
6489
+ if (!canvases.length && systemDoc) jobs.unshift(...systemFrames(systemDoc, base2, "light"));
6223
6490
  if (!options.json) print(dim(`Loading ${plural2(jobs.length, "frame")} in Chrome\u2026`));
6224
6491
  try {
6225
6492
  frames = await renderFrames(jobs);
@@ -6331,8 +6598,8 @@ import path30 from "path";
6331
6598
  var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6332
6599
  async function runLogin() {
6333
6600
  const host = cloudHost();
6334
- const client = new CloudClient(host, null);
6335
- const code = await client.deviceCode(os5.hostname());
6601
+ const client2 = new CloudClient(host, null);
6602
+ const code = await client2.deviceCode(os5.hostname());
6336
6603
  const url = new URL(code.verification_uri_complete || code.verification_uri, host).toString();
6337
6604
  const expiresIn = code.expires_in ?? 600;
6338
6605
  print(`${bold("Sign in to foss-design Cloud")} ${dim(host)}`);
@@ -6351,7 +6618,7 @@ async function runLogin() {
6351
6618
  if (Date.now() > deadline) throw new CliError("The sign-in code expired. Run `design login` again.");
6352
6619
  let result;
6353
6620
  try {
6354
- result = await client.deviceToken(code.device_code);
6621
+ result = await client2.deviceToken(code.device_code);
6355
6622
  } catch (error) {
6356
6623
  if (error instanceof CloudError && error.status === 0) continue;
6357
6624
  throw error;
@@ -6384,7 +6651,11 @@ function printProjects(title, projects, linked) {
6384
6651
  const width = Math.max(...projects.map((project) => project.id.length));
6385
6652
  const nameWidth = Math.max(...projects.map((project) => project.name.length));
6386
6653
  for (const project of projects) {
6387
- const flags = [project.archived && yellow("archived"), project.banned && yellow("banned")].filter(Boolean);
6654
+ const flags = [
6655
+ project.kind === "pages" && dim("pages"),
6656
+ project.archived && yellow("archived"),
6657
+ project.banned && yellow("banned")
6658
+ ].filter(Boolean);
6388
6659
  print(
6389
6660
  ` ${project.id.padEnd(width)} ${project.name.padEnd(nameWidth)} ${dim(project.role.padEnd(6))}` + (flags.length ? ` ${flags.join(" ")}` : "") + (project.id === linked ? ` ${green("linked")}` : "")
6390
6661
  );
@@ -6394,16 +6665,16 @@ async function runLink(paths, wanted, options) {
6394
6665
  if (wanted && options.newName !== void 0)
6395
6666
  throw new CliError("Pass a project or --new <name>, not both. `design link` lists your projects.");
6396
6667
  const host = cloudHost();
6397
- const client = new CloudClient(host, requireCredential(host).token);
6668
+ const client2 = new CloudClient(host, requireCredential(host).token);
6398
6669
  const current = readLink(paths);
6399
6670
  let project;
6400
6671
  if (options.newName !== void 0) {
6401
6672
  const name = options.newName.trim();
6402
6673
  if (!name) throw new CliError("--new takes a project name");
6403
- project = await client.createProject(name);
6674
+ project = await client2.createProject(name);
6404
6675
  print(`${green("created")} ${project.name} ${dim(`(${project.id})`)}`);
6405
6676
  } else {
6406
- const { owned, shared } = await client.projects();
6677
+ const { owned, shared } = await client2.projects();
6407
6678
  if (!wanted) {
6408
6679
  const linked = current?.host === host ? current.project : void 0;
6409
6680
  print(`${bold("Projects on")} ${host}`);
@@ -6424,6 +6695,10 @@ async function runLink(paths, wanted, options) {
6424
6695
  project = found;
6425
6696
  }
6426
6697
  if (project.banned) throw new CliError(`${project.name} is blocked by moderation and cannot be linked.`);
6698
+ if (project.kind === "pages")
6699
+ throw new CliError(
6700
+ `${project.name} is a pages project: \`design page\` publishes into it, and .design links to a project of canvases.`
6701
+ );
6427
6702
  if (current?.host === host && current.project === project.id) {
6428
6703
  print(`${dim("Already linked to")} ${project.name} ${dim(`(${project.id}, ${project.role})`)}`);
6429
6704
  } else {
@@ -6434,12 +6709,12 @@ async function runLink(paths, wanted, options) {
6434
6709
  }
6435
6710
  if (project.archived) print(yellow("The project is archived: unarchive it in the web app before pushing or pulling."));
6436
6711
  const link = readLink(paths);
6437
- for (const line of await syncProjectMeta(paths, client, link)) print(dim(line));
6712
+ for (const line of await syncProjectMeta(paths, client2, link)) print(dim(line));
6438
6713
  writeLink(paths, link);
6439
- const remote = project.archived ? null : await client.units(project.id);
6714
+ const remote = project.archived ? null : await client2.units(project.id);
6440
6715
  const scan = scanLocal(paths);
6441
6716
  const states = allUnitKeys(link, scan.units, remote).map(
6442
- (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote).state
6717
+ (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote, paths.design).state
6443
6718
  );
6444
6719
  const count = (state) => states.filter((item) => item === state).length;
6445
6720
  const ahead = count("remote_ahead");
@@ -6456,10 +6731,10 @@ async function runLink(paths, wanted, options) {
6456
6731
 
6457
6732
  // src/cli/commands/drawings.ts
6458
6733
  import http3 from "http";
6459
- function call(port, method, path39) {
6734
+ function call(port, method, path40) {
6460
6735
  return new Promise((resolve, reject) => {
6461
6736
  const req = http3.request(
6462
- { host: "127.0.0.1", port, method, path: path39, headers: { host: `localhost:${port}` }, timeout: 3e4 },
6737
+ { host: "127.0.0.1", port, method, path: path40, headers: { host: `localhost:${port}` }, timeout: 3e4 },
6463
6738
  (res) => {
6464
6739
  const chunks = [];
6465
6740
  res.on("data", (chunk2) => chunks.push(chunk2));
@@ -6576,10 +6851,10 @@ async function toCloud(paths, file) {
6576
6851
  print(yellow("Not signed in: the icon goes to the cloud on the next `design push` after `design login`."));
6577
6852
  return;
6578
6853
  }
6579
- const client = new CloudClient(link.host, credential.token);
6854
+ const client2 = new CloudClient(link.host, credential.token);
6580
6855
  try {
6581
6856
  const ext = file ? path31.extname(file).slice(1) : null;
6582
- await client.setProjectIcon(
6857
+ await client2.setProjectIcon(
6583
6858
  link.project,
6584
6859
  file && ext ? { data: fs33.readFileSync(file), type: ICON_TYPES[ext] } : null
6585
6860
  );
@@ -7076,7 +7351,7 @@ function elementJsx(element, ctx) {
7076
7351
  function convertFile(name, source, ctx) {
7077
7352
  const document2 = parseMarkup(source);
7078
7353
  const root = findElement(document2, (el) => el.tag.toLowerCase() === "x-dc");
7079
- const script = findElement(
7354
+ const script2 = findElement(
7080
7355
  document2,
7081
7356
  (el) => el.tag.toLowerCase() === "script" && attr(el, "data-dc-script") !== void 0
7082
7357
  );
@@ -7085,7 +7360,7 @@ function convertFile(name, source, ctx) {
7085
7360
  const helmet = /<helmet>([\s\S]*?)<\/helmet>/i.exec(source)?.[1]?.trim() ?? "";
7086
7361
  const body = childrenJsx(root.children, file, "x-dc");
7087
7362
  let spec = {};
7088
- const rawSpec = script ? attr(script, "data-props") : null;
7363
+ const rawSpec = script2 ? attr(script2, "data-props") : null;
7089
7364
  if (rawSpec) {
7090
7365
  try {
7091
7366
  spec = JSON.parse(decodeEntities(rawSpec));
@@ -7093,7 +7368,7 @@ function convertFile(name, source, ctx) {
7093
7368
  file.warn("has data-props that are not JSON; the props keep no defaults");
7094
7369
  }
7095
7370
  }
7096
- const code = script?.children.map((child) => child.type === "text" ? child.text : "").join("") ?? "";
7371
+ const code = script2?.children.map((child) => child.type === "text" ? child.text : "").join("") ?? "";
7097
7372
  const className = /class\s+([A-Za-z_$][\w$]*)\s+extends\s+DCLogic/.exec(code)?.[1];
7098
7373
  const logic = className ? `const __Logic = (() => {
7099
7374
  ${code.trim()}
@@ -7222,10 +7497,10 @@ export const flag = (v) => v === true || v === 'true' || (!!v && v !== 'false')
7222
7497
  export const list = (v) => (Array.isArray(v) ? v : v == null ? [] : Array.from(v))
7223
7498
  `;
7224
7499
  function boardId(file, used) {
7225
- let base = kebab(file.slice(0, -EXT.length)) || "screen";
7226
- if (!ID_PATTERN.test(base)) base = `s-${base}`;
7227
- let id2 = base;
7228
- for (let n2 = 2; used.has(id2); n2++) id2 = `${base}-${n2}`;
7500
+ let base2 = kebab(file.slice(0, -EXT.length)) || "screen";
7501
+ if (!ID_PATTERN.test(base2)) base2 = `s-${base2}`;
7502
+ let id2 = base2;
7503
+ for (let n2 = 2; used.has(id2); n2++) id2 = `${base2}-${n2}`;
7229
7504
  used.add(id2);
7230
7505
  return id2;
7231
7506
  }
@@ -7486,15 +7761,297 @@ async function runMerge(paths, targets2, options) {
7486
7761
  }
7487
7762
  }
7488
7763
 
7489
- // src/cli/commands/rename.ts
7764
+ // src/cloud/page.ts
7490
7765
  import fs36 from "fs";
7766
+ import path35 from "path";
7767
+ var PAGE_SLUG = /^[a-z0-9][a-z0-9_-]{0,99}$/;
7768
+ var PAGE_ENTRY = "index.html";
7769
+ var HTML = /\.html?$/i;
7770
+ var MAX_TITLE = 200;
7771
+ function slugFor(target) {
7772
+ const base2 = path35.basename(path35.resolve(target)).replace(HTML, "");
7773
+ const slug2 = base2.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "").slice(0, 100).replace(/[-_]+$/, "");
7774
+ return slug2 || "page";
7775
+ }
7776
+ var script = null;
7777
+ function pageScript() {
7778
+ if (script === null) {
7779
+ let code;
7780
+ try {
7781
+ code = fs36.readFileSync(PKG.pageRuntime, "utf8").trim();
7782
+ } catch {
7783
+ throw new CliError(`${PKG.pageRuntime} is missing; this foss-design install is incomplete.`);
7784
+ }
7785
+ script = `<script data-foss-design>${code.replace(/<\/script/gi, "<\\/script")}</script>`;
7786
+ }
7787
+ return script;
7788
+ }
7789
+ function withPageScript(html) {
7790
+ const tag = pageScript();
7791
+ const at = (pattern) => {
7792
+ const match = pattern.exec(html);
7793
+ return match ? match.index + match[0].length : -1;
7794
+ };
7795
+ let index = at(/<head(?:\s[^>]*)?>/i);
7796
+ if (index < 0) index = at(/<html(?:\s[^>]*)?>/i);
7797
+ if (index < 0) index = at(/<!doctype[^>]*>/i);
7798
+ if (index < 0) index = 0;
7799
+ return html.slice(0, index) + tag + html.slice(index);
7800
+ }
7801
+ var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
7802
+ function htmlTitle(html) {
7803
+ const raw = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i.exec(html)?.[1];
7804
+ if (raw === void 0) return null;
7805
+ const text2 = raw.replace(/<[^>]*>/g, "").replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body) => {
7806
+ if (body[0] !== "#") return ENTITIES[body.toLowerCase()] ?? whole;
7807
+ const code = body[1] === "x" || body[1] === "X" ? Number.parseInt(body.slice(2), 16) : Number(body.slice(1));
7808
+ return Number.isFinite(code) && code > 0 && code < 1114112 ? String.fromCodePoint(code) : " ";
7809
+ }).replace(/\s+/g, " ").trim().slice(0, MAX_TITLE);
7810
+ return text2 || null;
7811
+ }
7812
+ function siblingsLoaded(file, html) {
7813
+ const dir = path35.dirname(file);
7814
+ const found = /* @__PURE__ */ new Set();
7815
+ for (const match of html.matchAll(/\b(?:src|href|poster)\s*=\s*["']([^"'#?]+)/gi)) {
7816
+ const ref = match[1].trim();
7817
+ if (!ref || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("/") || ref.startsWith("//")) continue;
7818
+ let rel;
7819
+ try {
7820
+ rel = decodeURIComponent(ref);
7821
+ } catch {
7822
+ continue;
7823
+ }
7824
+ const full = path35.resolve(dir, rel);
7825
+ if (full !== file && fs36.existsSync(full) && fs36.statSync(full).isFile()) found.add(rel);
7826
+ }
7827
+ return [...found];
7828
+ }
7829
+ function walk2(dir, root, out, skipped) {
7830
+ for (const entry of fs36.readdirSync(dir, { withFileTypes: true })) {
7831
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
7832
+ const full = path35.join(dir, entry.name);
7833
+ const rel = path35.relative(root, full).split(path35.sep).join("/");
7834
+ if (entry.isSymbolicLink()) {
7835
+ try {
7836
+ const target = fs36.realpathSync(full);
7837
+ if (target.startsWith(fs36.realpathSync(root) + path35.sep) && fs36.statSync(target).isFile()) out.push(rel);
7838
+ else skipped.push(rel);
7839
+ } catch {
7840
+ skipped.push(rel);
7841
+ }
7842
+ } else if (entry.isDirectory()) walk2(full, root, out, skipped);
7843
+ else if (entry.isFile()) out.push(rel);
7844
+ }
7845
+ }
7846
+ function readPage(target) {
7847
+ const full = path35.resolve(target);
7848
+ let stat;
7849
+ try {
7850
+ stat = fs36.statSync(full);
7851
+ } catch {
7852
+ throw new CliError(`${target} does not exist`);
7853
+ }
7854
+ const files = /* @__PURE__ */ new Map();
7855
+ const warnings = [];
7856
+ const add = (rel, file) => {
7857
+ const data = fs36.readFileSync(file);
7858
+ files.set(rel, HTML.test(rel) ? Buffer.from(withPageScript(data.toString("utf8"))) : data);
7859
+ };
7860
+ let entryHtml;
7861
+ if (stat.isDirectory()) {
7862
+ const list = [];
7863
+ const skipped = [];
7864
+ walk2(full, full, list, skipped);
7865
+ if (!list.includes(PAGE_ENTRY)) {
7866
+ const pages = list.filter((rel) => HTML.test(rel) && !rel.includes("/"));
7867
+ throw new CliError(
7868
+ `${target} has no ${PAGE_ENTRY}, which a page opens with.` + (pages.length === 1 ? ` Rename ${pages[0]} to ${PAGE_ENTRY}, or pass that file alone.` : "")
7869
+ );
7870
+ }
7871
+ for (const rel of list) add(rel, path35.join(full, rel));
7872
+ if (skipped.length) warnings.push(`Not uploaded, they link outside ${target}: ${skipped.slice(0, 5).join(", ")}`);
7873
+ entryHtml = fs36.readFileSync(path35.join(full, PAGE_ENTRY), "utf8");
7874
+ } else {
7875
+ if (!HTML.test(full))
7876
+ throw new CliError(`${target} is not an HTML file; pass an .html file or a folder with ${PAGE_ENTRY}`);
7877
+ entryHtml = fs36.readFileSync(full, "utf8");
7878
+ add(PAGE_ENTRY, full);
7879
+ const loaded = siblingsLoaded(full, entryHtml);
7880
+ if (loaded.length)
7881
+ warnings.push(
7882
+ `${path35.basename(full)} loads ${loaded.slice(0, 3).join(", ")}${loaded.length > 3 ? ` and ${loaded.length - 3} more` : ""} next to it, which a single file does not take along: pass the folder (with the page as ${PAGE_ENTRY}) instead.`
7883
+ );
7884
+ }
7885
+ const manifest = {};
7886
+ let bytes = 0;
7887
+ for (const [rel, data] of files) {
7888
+ manifest[rel] = { hash: hashBuffer(data), size: data.length };
7889
+ bytes += data.length;
7890
+ }
7891
+ return { files, manifest, title: htmlTitle(entryHtml), bytes, warnings };
7892
+ }
7893
+
7894
+ // src/cli/commands/pages.ts
7895
+ var DEFAULT_NAME = "Pages";
7896
+ var base = (host) => host.replace(/\/+$/, "");
7897
+ var size2 = (bytes) => bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : megabytes(bytes);
7898
+ var pageUrl = (host, project, slug2) => `${base(host)}/p/${project}/pages/${encodeURIComponent(slug2)}`;
7899
+ var publicUrl2 = (host, publicId) => `${base(host)}/a/${publicId}`;
7900
+ function client() {
7901
+ const host = cloudHost();
7902
+ return { host, client: new CloudClient(host, requireCredential(host).token) };
7903
+ }
7904
+ async function pagesProject(client2, wanted, options) {
7905
+ const { owned, shared } = await client2.projects();
7906
+ if (wanted) {
7907
+ const all = [...owned, ...shared];
7908
+ const byName = all.filter((project) => project.name === wanted);
7909
+ const found = all.find((project) => project.id === wanted) ?? (byName.length === 1 ? byName[0] : void 0);
7910
+ if (!found)
7911
+ throw new CliError(
7912
+ byName.length > 1 ? `Several projects are called "${wanted}"; pass its id (\`design pages\` names them).` : `No project "${wanted}" among yours on ${client2.host}.`
7913
+ );
7914
+ if (found.kind !== "pages")
7915
+ throw new CliError(`${found.name} holds canvases, not pages; pages go to a pages project.`);
7916
+ return found;
7917
+ }
7918
+ const mine = owned.filter((project) => project.kind === "pages" && !project.archived);
7919
+ if (mine.length === 1) return mine[0];
7920
+ if (mine.length > 1)
7921
+ throw new CliError(
7922
+ `You have several pages projects; pass one with --project:
7923
+ ${mine.map((p) => ` ${p.id} ${p.name}`).join("\n")}`
7924
+ );
7925
+ if (!options.create) return null;
7926
+ const made = await client2.createProject(DEFAULT_NAME, "pages");
7927
+ if (made.kind !== "pages") throw new CliError(`${client2.host} does not host pages yet.`);
7928
+ options.log(`${green("created")} the pages project ${bold(made.name)} ${dim(`(${made.id})`)}`);
7929
+ return made;
7930
+ }
7931
+ function check(project) {
7932
+ if (project.banned) throw new CliError(`${project.name} is blocked by moderation.`);
7933
+ if (project.archived) throw new CliError(`${project.name} is archived; unarchive it in the web app first.`);
7934
+ if (project.role === "viewer") throw new CliError(`You can view ${project.name} but not publish to it.`);
7935
+ }
7936
+ async function runPage(target, options) {
7937
+ if (!target) throw new CliError("Usage: design page <file.html|folder> [--name <slug>] [--public]");
7938
+ const slug2 = options.name ?? slugFor(target);
7939
+ if (!PAGE_SLUG.test(slug2))
7940
+ throw new CliError(`--name takes lowercase letters, digits, - and _ (up to 100, starting with a letter or digit)`);
7941
+ const bundle = readPage(target);
7942
+ const log = options.json ? () => {
7943
+ } : (line) => print(line);
7944
+ for (const warning of bundle.warnings) log(yellow(warning));
7945
+ const { host, client: cloud2 } = client();
7946
+ const project = await pagesProject(cloud2, options.project, { create: true, log });
7947
+ check(project);
7948
+ if (options.public && project.role !== "owner")
7949
+ throw new CliError(`Only the owner of ${project.name} gives pages a public link; publish without --public.`);
7950
+ const title = options.title?.trim() || bundle.title || slug2;
7951
+ const byHash = new Map([...bundle.files].map(([rel, data2]) => [bundle.manifest[rel].hash, data2]));
7952
+ const missing = await cloud2.missing(project.id, [...byHash.keys()]);
7953
+ if (missing.length) {
7954
+ const bar = options.json ? null : progress();
7955
+ let sent = 0;
7956
+ await mapLimit(missing, TRANSFERS, async (hash) => {
7957
+ const data2 = byHash.get(hash);
7958
+ if (!data2) throw new CliError(`The cloud asked for ${hash.slice(0, 12)}, which is not part of this page`);
7959
+ await cloud2.putBlob(project.id, hash, data2);
7960
+ bar?.update(`Uploading ${++sent}/${missing.length} files`);
7961
+ });
7962
+ bar?.done(`Uploaded ${plural(missing.length, "file")}`);
7963
+ }
7964
+ let rev = 0;
7965
+ let page;
7966
+ for (let attempt = 0; ; attempt++) {
7967
+ page = (await cloud2.pages(project.id)).find((item) => item.slug === slug2);
7968
+ if (page?.banned) throw new CliError(`The page ${slug2} is blocked by moderation.`);
7969
+ if (page?.archived) throw new CliError(`The page ${slug2} is archived; unarchive it in the web app first.`);
7970
+ try {
7971
+ const revs2 = await cloud2.push(project.id, [
7972
+ {
7973
+ key: `page/${slug2}`,
7974
+ baseRev: page?.headRev ?? 0,
7975
+ title,
7976
+ source: {},
7977
+ build: bundle.manifest,
7978
+ systemRev: null
7979
+ }
7980
+ ]);
7981
+ rev = revs2.get(`page/${slug2}`) ?? (page?.headRev ?? 0) + 1;
7982
+ break;
7983
+ } catch (error) {
7984
+ if (attempt < 2 && error instanceof CloudError && error.status === 409 && error.error === "conflict") continue;
7985
+ throw error;
7986
+ }
7987
+ }
7988
+ const publicId = options.public ? (await cloud2.pageAction(project.id, slug2, "publish")).publicId : page?.publicId;
7989
+ const data = {
7990
+ ok: true,
7991
+ project: { id: project.id, name: project.name },
7992
+ page: { slug: slug2, title, rev, bytes: bundle.bytes, files: bundle.files.size },
7993
+ url: pageUrl(host, project.id, slug2),
7994
+ publicUrl: publicId ? publicUrl2(host, publicId) : null,
7995
+ warnings: bundle.warnings
7996
+ };
7997
+ if (options.json) return print(JSON.stringify(data, null, 2));
7998
+ print(
7999
+ `${green("\u2713")} ${rev > 1 ? "Updated" : "Published"} ${bold(title)} ${dim(`(${slug2}, r${rev}, ${plural(bundle.files.size, "file")}, ${size2(bundle.bytes)})`)} in ${project.name}`
8000
+ );
8001
+ print(` ${dim("Page ")} ${data.url}`);
8002
+ if (data.publicUrl) print(` ${dim("Public")} ${data.publicUrl}`);
8003
+ else
8004
+ print(
8005
+ dim(
8006
+ project.role === "owner" ? " Only the project\u2019s members see it; --public gives it a link anyone can open." : " Only the project\u2019s members see it; its owner can give it a public link."
8007
+ )
8008
+ );
8009
+ }
8010
+ async function runPages(options) {
8011
+ const { host, client: cloud2 } = client();
8012
+ const project = await pagesProject(cloud2, options.project, { create: false, log: () => {
8013
+ } });
8014
+ if (!project) {
8015
+ if (options.json) return print(JSON.stringify({ project: null, pages: [] }, null, 2));
8016
+ return print(dim("No pages project yet: `design page <file.html>` makes one and publishes the page."));
8017
+ }
8018
+ const pages = await cloud2.pages(project.id);
8019
+ if (options.json)
8020
+ return print(
8021
+ JSON.stringify(
8022
+ {
8023
+ project: { id: project.id, name: project.name, role: project.role },
8024
+ pages: pages.map((page) => ({
8025
+ ...page,
8026
+ url: pageUrl(host, project.id, page.slug),
8027
+ publicUrl: page.publicId ? publicUrl2(host, page.publicId) : null
8028
+ }))
8029
+ },
8030
+ null,
8031
+ 2
8032
+ )
8033
+ );
8034
+ print(`${bold(project.name)} ${dim(`${plural(pages.length, "page")} \xB7 ${base(host)}/p/${project.id}`)}`);
8035
+ if (!pages.length) return print(dim(" none yet: `design page <file.html>` publishes one"));
8036
+ const width = Math.max(...pages.map((page) => page.slug.length));
8037
+ for (const page of pages) {
8038
+ const state = page.banned ? yellow("blocked") : page.archived ? yellow("archived") : "";
8039
+ print(
8040
+ ` ${page.slug.padEnd(width)} ${page.title} ${dim(`r${page.headRev} \xB7 ${size2(page.bytes)} \xB7 ${ago(page.updatedAt)}`)}${state ? ` ${state}` : ""}`
8041
+ );
8042
+ if (page.publicId) print(` ${" ".repeat(width)} ${dim("public")} ${publicUrl2(host, page.publicId)}`);
8043
+ }
8044
+ }
8045
+
8046
+ // src/cli/commands/rename.ts
8047
+ import fs37 from "fs";
7491
8048
  async function runRename(paths, name) {
7492
8049
  const trimmed = name.trim();
7493
8050
  if (!trimmed) throw new CliError("Usage: design rename <name>");
7494
8051
  const link = readLink(paths);
7495
8052
  if (link) {
7496
- const client = new CloudClient(link.host, requireCredential(link.host).token);
7497
- const project = await client.renameProject(link.project, trimmed);
8053
+ const client2 = new CloudClient(link.host, requireCredential(link.host).token);
8054
+ const project = await client2.renameProject(link.project, trimmed);
7498
8055
  link.name = project.name;
7499
8056
  writeLink(paths, link);
7500
8057
  print(`${green("Renamed")} the cloud project to ${project.name}`);
@@ -7502,17 +8059,17 @@ async function runRename(paths, name) {
7502
8059
  }
7503
8060
  let config = {};
7504
8061
  try {
7505
- config = JSON.parse(fs36.readFileSync(paths.config, "utf8"));
8062
+ config = JSON.parse(fs37.readFileSync(paths.config, "utf8"));
7506
8063
  } catch {
7507
8064
  }
7508
- fs36.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
8065
+ fs37.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
7509
8066
  `);
7510
8067
  print(`${green("Renamed")} the project to ${trimmed} (.design/design.json)`);
7511
8068
  }
7512
8069
 
7513
8070
  // src/cli/commands/scaffold.ts
7514
- import fs37 from "fs";
7515
- import path35 from "path";
8071
+ import fs38 from "fs";
8072
+ import path36 from "path";
7516
8073
 
7517
8074
  // src/cli/templates.ts
7518
8075
  var TOKENS_CSS = `/*
@@ -7723,40 +8280,40 @@ function mainScreen(canvasId) {
7723
8280
 
7724
8281
  // src/cli/commands/scaffold.ts
7725
8282
  function write(file, content, root) {
7726
- if (fs37.existsSync(file)) {
7727
- print(` ${dim("exists ")} ${path35.relative(root, file)}`);
8283
+ if (fs38.existsSync(file)) {
8284
+ print(` ${dim("exists ")} ${path36.relative(root, file)}`);
7728
8285
  return false;
7729
8286
  }
7730
- fs37.mkdirSync(path35.dirname(file), { recursive: true });
7731
- fs37.writeFileSync(file, content);
7732
- print(` ${green("created")} ${path35.relative(root, file)}`);
8287
+ fs38.mkdirSync(path36.dirname(file), { recursive: true });
8288
+ fs38.writeFileSync(file, content);
8289
+ print(` ${green("created")} ${path36.relative(root, file)}`);
7733
8290
  return true;
7734
8291
  }
7735
8292
  function ensureGitignored(root) {
7736
- const file = path35.join(root, ".gitignore");
7737
- const current = fs37.existsSync(file) ? fs37.readFileSync(file, "utf8") : "";
8293
+ const file = path36.join(root, ".gitignore");
8294
+ const current = fs38.existsSync(file) ? fs38.readFileSync(file, "utf8") : "";
7738
8295
  const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => [".design", ".design/", "/.design", "/.design/", ".design/*"].includes(line));
7739
8296
  if (covered) return "present";
7740
8297
  const prefix = current && !current.endsWith("\n") ? "\n" : "";
7741
- fs37.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
8298
+ fs38.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
7742
8299
  .design/
7743
8300
  `);
7744
8301
  return "added";
7745
8302
  }
7746
8303
  function projectName(root) {
7747
8304
  try {
7748
- const manifest = JSON.parse(fs37.readFileSync(path35.join(root, "package.json"), "utf8"));
8305
+ const manifest = JSON.parse(fs38.readFileSync(path36.join(root, "package.json"), "utf8"));
7749
8306
  if (typeof manifest.name === "string" && manifest.name) return manifest.name;
7750
8307
  } catch {
7751
8308
  }
7752
- return path35.basename(root);
8309
+ return path36.basename(root);
7753
8310
  }
7754
8311
  function runInit(root, options) {
7755
8312
  const paths = designPaths(root);
7756
8313
  print(`Setting up ${DESIGN_DIR} in ${root}`);
7757
8314
  write(paths.config, `${JSON.stringify({ name: options.name ?? projectName(root) }, null, 2)}
7758
8315
  `, root);
7759
- fs37.mkdirSync(paths.canvases, { recursive: true });
8316
+ fs38.mkdirSync(paths.canvases, { recursive: true });
7760
8317
  if (options.gitignore) {
7761
8318
  const result = ensureGitignored(root);
7762
8319
  print(` ${result === "added" ? green("ignored") : dim("ignored")} .design/ in .gitignore`);
@@ -7768,46 +8325,46 @@ function runInit(root, options) {
7768
8325
  }
7769
8326
  function runSystemInit(root, options) {
7770
8327
  const paths = designPaths(root);
7771
- let base = projectName(root);
8328
+ let base2 = projectName(root);
7772
8329
  try {
7773
- base = JSON.parse(fs37.readFileSync(paths.config, "utf8")).name || base;
8330
+ base2 = JSON.parse(fs38.readFileSync(paths.config, "utf8")).name || base2;
7774
8331
  } catch {
7775
8332
  }
7776
- const name = options.name ?? `${base} design system`;
7777
- print(`Scaffolding the design system in ${path35.relative(root, paths.system)}`);
7778
- write(path35.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
8333
+ const name = options.name ?? `${base2} design system`;
8334
+ print(`Scaffolding the design system in ${path36.relative(root, paths.system)}`);
8335
+ write(path36.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
7779
8336
  `, root);
7780
- write(path35.join(paths.system, "tokens.css"), TOKENS_CSS, root);
8337
+ write(path36.join(paths.system, "tokens.css"), TOKENS_CSS, root);
7781
8338
  if (!options.empty) {
7782
- write(path35.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
7783
- write(path35.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
7784
- write(path35.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
8339
+ write(path36.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
8340
+ write(path36.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
8341
+ write(path36.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
7785
8342
  }
7786
8343
  }
7787
8344
  function runNew(root, id2, options) {
7788
8345
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is not a valid canvas id: use letters, digits, "-" and "_"`);
7789
8346
  const paths = designPaths(root);
7790
- const dir = path35.join(paths.canvases, id2);
7791
- if (fs37.existsSync(path35.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
8347
+ const dir = path36.join(paths.canvases, id2);
8348
+ if (fs38.existsSync(path36.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
7792
8349
  print(`Creating canvas "${id2}"`);
7793
8350
  const title = options.title ?? titleize(id2);
7794
8351
  if (options.empty) {
7795
8352
  write(
7796
- path35.join(dir, "canvas.json"),
8353
+ path36.join(dir, "canvas.json"),
7797
8354
  `${JSON.stringify({ title, pages: [{ id: "main", title: "Main", sections: [] }] }, null, 2)}
7798
8355
  `,
7799
8356
  root
7800
8357
  );
7801
- fs37.mkdirSync(path35.join(dir, "screens"), { recursive: true });
8358
+ fs38.mkdirSync(path36.join(dir, "screens"), { recursive: true });
7802
8359
  } else {
7803
- write(path35.join(dir, "canvas.json"), canvasJson(title), root);
7804
- write(path35.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
8360
+ write(path36.join(dir, "canvas.json"), canvasJson(title), root);
8361
+ write(path36.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
7805
8362
  }
7806
8363
  }
7807
8364
 
7808
8365
  // src/cli/commands/shot.ts
7809
- import fs38 from "fs";
7810
- import path36 from "path";
8366
+ import fs39 from "fs";
8367
+ import path37 from "path";
7811
8368
  var OVERVIEW_TIMEOUT = 9e4;
7812
8369
  var DRAWING_TIMEOUT = 45e3;
7813
8370
  var DRAWING_MAX_SIDE = 1280;
@@ -7833,16 +8390,16 @@ async function runShot(paths, target, options) {
7833
8390
  throw new CliError(`No page "${options.page}" in "${canvasId}". Pages: ${doc.pages.map((p) => p.id).join(", ")}`);
7834
8391
  }
7835
8392
  const theme2 = options.theme ?? doc.theme ?? "light";
7836
- const outBase = path36.resolve(options.out ?? paths.shots);
7837
- const outDir = path36.join(outBase, canvasId);
7838
- fs38.mkdirSync(outDir, { recursive: true });
7839
- const base = await serverBase(paths);
8393
+ const outBase = path37.resolve(options.out ?? paths.shots);
8394
+ const outDir = path37.join(outBase, canvasId);
8395
+ fs39.mkdirSync(outDir, { recursive: true });
8396
+ const base2 = await serverBase(paths);
7840
8397
  const browser = await launchChrome();
7841
8398
  let failed = false;
7842
8399
  try {
7843
8400
  if (options.board || options.markup) {
7844
- if (!fs38.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there are no drawings to shoot.");
7845
- const drawingsFailed = await shootDrawings(browser, base, canvasId, outDir, theme2, {
8401
+ if (!fs39.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there are no drawings to shoot.");
8402
+ const drawingsFailed = await shootDrawings(browser, base2, canvasId, outDir, theme2, {
7846
8403
  board: !!options.board,
7847
8404
  page: options.page,
7848
8405
  screen: screenId,
@@ -7852,11 +8409,11 @@ async function runShot(paths, target, options) {
7852
8409
  return;
7853
8410
  }
7854
8411
  if (options.overview) {
7855
- if (!fs38.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
8412
+ if (!fs39.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
7856
8413
  const pages = options.page ? [options.page] : screenId ? doc.pages.filter((page) => page.sections.some((section) => section.items.some((item) => item.id === screenId))).map((page) => page.id) : doc.pages.map((page) => page.id);
7857
8414
  for (const pageId of pages) {
7858
8415
  const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
7859
- const url = `${base}/c/${encodeURIComponent(canvasId)}?page=${encodeURIComponent(pageId)}&capture=1&theme=${theme2}`;
8416
+ const url = `${base2}/c/${encodeURIComponent(canvasId)}?page=${encodeURIComponent(pageId)}&capture=1&theme=${theme2}`;
7860
8417
  await page.goto(url, { waitUntil: "load" });
7861
8418
  try {
7862
8419
  await page.waitForFunction(
@@ -7868,14 +8425,14 @@ async function runShot(paths, target, options) {
7868
8425
  warn(`page "${pageId}": not every frame reported ready within ${OVERVIEW_TIMEOUT / 1e3}s; shooting anyway`);
7869
8426
  }
7870
8427
  await page.waitForTimeout(400);
7871
- const file = path36.join(outDir, `${pageId}.overview.png`);
8428
+ const file = path37.join(outDir, `${pageId}.overview.png`);
7872
8429
  await page.screenshot({ path: file });
7873
8430
  await page.close();
7874
8431
  print(file);
7875
8432
  }
7876
8433
  return;
7877
8434
  }
7878
- const jobs = canvasFrames(doc, base, theme2, { page: options.page, id: screenId });
8435
+ const jobs = canvasFrames(doc, base2, theme2, { page: options.page, id: screenId });
7879
8436
  if (!jobs.length) {
7880
8437
  throw new CliError(
7881
8438
  screenId ? `No screen "${screenId}" in "${canvasId}"${options.page ? ` on page "${options.page}"` : ""}.` : `Nothing to shoot in "${canvasId}"${options.page ? ` on page "${options.page}"` : ""}.`
@@ -7887,8 +8444,8 @@ async function runShot(paths, target, options) {
7887
8444
  }
7888
8445
  if (failed) process.exitCode = 1;
7889
8446
  }
7890
- async function shootDrawings(browser, base, canvasId, outDir, theme2, want) {
7891
- const port = Number(new URL(base).port);
8447
+ async function shootDrawings(browser, base2, canvasId, outDir, theme2, want) {
8448
+ const port = Number(new URL(base2).port);
7892
8449
  const { drawings, cloud: cloud2 } = await readDrawings(port, canvasId);
7893
8450
  if (cloud2 === "offline" || cloud2 === "connecting")
7894
8451
  warn("the cloud did not answer in time: these are the drawings on this machine");
@@ -7906,21 +8463,21 @@ async function shootDrawings(browser, base, canvasId, outDir, theme2, want) {
7906
8463
  const kind = want.board ? "board" : "markup";
7907
8464
  const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: theme2 });
7908
8465
  try {
7909
- const url = `${base}/c/${encodeURIComponent(canvasId)}/${kind}/${encodeURIComponent(id2)}?capture=1&theme=${theme2}&max=${want.max}`;
8466
+ const url = `${base2}/c/${encodeURIComponent(canvasId)}/${kind}/${encodeURIComponent(id2)}?capture=1&theme=${theme2}&max=${want.max}`;
7910
8467
  await page.goto(url, { waitUntil: "load" });
7911
8468
  await page.waitForFunction(
7912
8469
  () => window.__DESIGN_DRAWING_READY__ === true,
7913
8470
  null,
7914
8471
  { timeout: DRAWING_TIMEOUT }
7915
8472
  );
7916
- const size2 = await page.evaluate(
8473
+ const size3 = await page.evaluate(
7917
8474
  () => window.__DESIGN_DRAWING_SIZE__
7918
8475
  );
7919
- if (size2?.width && size2.height)
7920
- await page.setViewportSize({ width: Math.round(size2.width), height: Math.min(Math.round(size2.height), 16e3) });
8476
+ if (size3?.width && size3.height)
8477
+ await page.setViewportSize({ width: Math.round(size3.width), height: Math.min(Math.round(size3.height), 16e3) });
7921
8478
  await page.waitForTimeout(200);
7922
8479
  if (!want.board) {
7923
- const file = path36.join(outDir, `${id2}.markup.${theme2}.png`);
8480
+ const file = path37.join(outDir, `${id2}.markup.${theme2}.png`);
7924
8481
  await page.screenshot({ path: file });
7925
8482
  print(file);
7926
8483
  continue;
@@ -7929,7 +8486,7 @@ async function shootDrawings(browser, base, canvasId, outDir, theme2, want) {
7929
8486
  () => window.__DESIGN_DRAWING_PARTS__ ?? 0
7930
8487
  );
7931
8488
  for (let part = 0; part < parts; part++) {
7932
- const file = path36.join(outDir, parts > 1 ? `${id2}.board.${part + 1}.${theme2}.png` : `${id2}.board.${theme2}.png`);
8489
+ const file = path37.join(outDir, parts > 1 ? `${id2}.board.${part + 1}.${theme2}.png` : `${id2}.board.${theme2}.png`);
7933
8490
  await page.locator(`[data-part="${part}"]`).screenshot({ path: file });
7934
8491
  print(file);
7935
8492
  }
@@ -7953,7 +8510,7 @@ async function shootFrames(browser, jobs, outDir) {
7953
8510
  let failed = false;
7954
8511
  const results = await mapLimit(jobs, 4, async (job) => {
7955
8512
  const page = await browser.newPage({ viewport: { width: job.width, height: job.height } });
7956
- const file = path36.join(outDir, `${job.id}.${job.theme}.png`);
8513
+ const file = path37.join(outDir, `${job.id}.${job.theme}.png`);
7957
8514
  try {
7958
8515
  const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
7959
8516
  await page.screenshot({ path: file, fullPage: job.fullPage });
@@ -7985,10 +8542,10 @@ async function shootSystem(paths, project, id2, options) {
7985
8542
  if (id2 && !all.some((job) => job.id === id2)) {
7986
8543
  throw new CliError(`No specimen "${id2}" in the design system. Specimens: ${all.map((job) => job.id).join(", ")}`);
7987
8544
  }
7988
- const outDir = path36.join(path36.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
7989
- fs38.mkdirSync(outDir, { recursive: true });
7990
- const base = await serverBase(paths);
7991
- const jobs = systemFrames(system.doc, base, theme2).filter((job) => !id2 || job.id === id2);
8545
+ const outDir = path37.join(path37.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
8546
+ fs39.mkdirSync(outDir, { recursive: true });
8547
+ const base2 = await serverBase(paths);
8548
+ const jobs = systemFrames(system.doc, base2, theme2).filter((job) => !id2 || job.id === id2);
7992
8549
  const browser = await launchChrome();
7993
8550
  try {
7994
8551
  if (await shootFrames(browser, jobs, outDir)) process.exitCode = 1;
@@ -8036,7 +8593,7 @@ async function runSnapshots(paths, targets2, options) {
8036
8593
  }
8037
8594
 
8038
8595
  // src/cli/commands/cloud-status.ts
8039
- import path37 from "path";
8596
+ import path38 from "path";
8040
8597
  var STATE_TEXT = {
8041
8598
  in_sync: green,
8042
8599
  local_changes: yellow,
@@ -8055,11 +8612,15 @@ function detail(paths, status) {
8055
8612
  if (!status.baseRev && status.headRev === null) parts.push("new");
8056
8613
  else if (!status.local) parts.push("deleted here");
8057
8614
  else if (status.changed.length) parts.push(plural(status.changed.length, "file"));
8615
+ if (status.usesChanged.length)
8616
+ parts.push(
8617
+ `uses ${plural(status.usesChanged.length, "changed file")} from outside: ${status.usesChanged.slice(0, 2).join(", ")}${status.usesChanged.length > 2 ? ", \u2026" : ""}`
8618
+ );
8058
8619
  }
8059
8620
  if (status.state === "remote_ahead" && !status.local && !status.baseRev) parts.push("not pulled yet");
8060
8621
  if (status.state === "remote_ahead" && status.changed.length && !status.deletedRemotely)
8061
8622
  parts.push(`${plural(status.changed.length, "file")} changed here; the cloud only rebuilt it`);
8062
- if (status.conflictRev !== void 0) parts.push(`merge ${path37.relative(paths.root, incomingDir(paths, status.key))}`);
8623
+ if (status.conflictRev !== void 0) parts.push(`merge ${path38.relative(paths.root, incomingDir(paths, status.key))}`);
8063
8624
  return parts.join(" \xB7 ");
8064
8625
  }
8065
8626
  async function printCloudStatus(paths, label) {
@@ -8072,17 +8633,17 @@ async function printCloudStatus(paths, label) {
8072
8633
  }
8073
8634
  const host = link?.host ?? cloudHost();
8074
8635
  const credential = getCredential(host);
8075
- const client = credential ? new CloudClient(host, credential.token) : null;
8636
+ const client2 = credential ? new CloudClient(host, credential.token) : null;
8076
8637
  let remote = null;
8077
8638
  let problem = null;
8078
- if (!client || !credential) {
8639
+ if (!client2 || !credential) {
8079
8640
  problem = "not signed in";
8080
8641
  print(`${label("Cloud")}${dim(`not signed in to ${host} \u2014 \`design login\``)}`);
8081
8642
  } else {
8082
8643
  try {
8083
- const me = await client.me();
8644
+ const me = await client2.me();
8084
8645
  print(`${label("Cloud")}${me.email || credential.email} ${dim(host)}`);
8085
- if (link) remote = await client.units(link.project);
8646
+ if (link) remote = await client2.units(link.project);
8086
8647
  } catch (error) {
8087
8648
  problem = error.message;
8088
8649
  if (error instanceof CloudError && error.status === 401)
@@ -8095,9 +8656,9 @@ async function printCloudStatus(paths, label) {
8095
8656
  return;
8096
8657
  }
8097
8658
  let name = link.project;
8098
- if (client && !problem) {
8659
+ if (client2 && !problem) {
8099
8660
  try {
8100
- const { owned, shared } = await client.projects();
8661
+ const { owned, shared } = await client2.projects();
8101
8662
  const found = [...owned, ...shared].find((project) => project.id === link.project);
8102
8663
  if (found) name = `${found.name} ${dim(`(${found.id}, ${found.role}${found.archived ? ", archived" : ""})`)}`;
8103
8664
  } catch {
@@ -8107,7 +8668,7 @@ async function printCloudStatus(paths, label) {
8107
8668
  if (problem) print(`${" ".repeat(9)}${yellow(`cloud state unknown (${problem}); local changes only`)}`);
8108
8669
  const scan = scanLocal(paths);
8109
8670
  const statuses = allUnitKeys(link, scan.units, remote).map(
8110
- (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote)
8671
+ (key) => unitStatus(key, link, scan.units.get(key)?.manifest, remote, paths.design)
8111
8672
  );
8112
8673
  if (!statuses.length) print(`${label("Units")}${dim("none yet")}`);
8113
8674
  else {
@@ -8257,6 +8818,14 @@ ${bold("Cloud")} ${dim("units: system (design.json + system/) and canvas/<id>;
8257
8818
  history kept
8258
8819
  unarchive <canvas> Bring an archived canvas back
8259
8820
 
8821
+ ${bold("Pages")} ${dim("HTML pages published to the cloud, each at its own link; no .design needed")}
8822
+ page <file.html|folder> [--name <slug>] Publish an HTML file, or a folder with an index.html and
8823
+ [--title <title>] [--project <id>] what it loads, as a page of your pages project (made on
8824
+ [--public] [--json] first use). The same name again is a new version at the
8825
+ same links. --public gives it a link anyone can open
8826
+ (owner only); links to other sites open in a new tab
8827
+ pages [--project <id>] [--json] The pages: name, title, version, size, public link
8828
+
8260
8829
  ${bold("Options")}
8261
8830
  --root <dir> Project folder (default: the nearest folder with .design, or the current one)
8262
8831
  -h, --help This help
@@ -8266,7 +8835,7 @@ ${bold("Environment")}
8266
8835
  FOSS_DESIGN_CLOUD Cloud to sign in and link to (default https://app.fossdesign.dev)
8267
8836
  `;
8268
8837
  function projectPaths(rootOption, requireDesign = true) {
8269
- const found = rootOption ? path38.resolve(rootOption) : findProjectRoot();
8838
+ const found = rootOption ? path39.resolve(rootOption) : findProjectRoot();
8270
8839
  if (!found) {
8271
8840
  if (!requireDesign) return designPaths(process.cwd());
8272
8841
  throw new CliError("No .design folder here or above. Run `design init` in the project root first.");
@@ -8318,6 +8887,8 @@ async function main(argv) {
8318
8887
  full: { type: "boolean" },
8319
8888
  tar: { type: "boolean" },
8320
8889
  new: { type: "string" },
8890
+ project: { type: "string" },
8891
+ public: { type: "boolean" },
8321
8892
  resolved: { type: "string", multiple: true },
8322
8893
  theirs: { type: "string", multiple: true },
8323
8894
  help: { type: "boolean", short: "h" },
@@ -8333,7 +8904,7 @@ async function main(argv) {
8333
8904
  if (values.theme && !theme2) throw new CliError("--theme is light or dark");
8334
8905
  switch (command) {
8335
8906
  case "init":
8336
- return runInit(values.root ? path38.resolve(values.root) : process.cwd(), {
8907
+ return runInit(values.root ? path39.resolve(values.root) : process.cwd(), {
8337
8908
  name: values.name,
8338
8909
  gitignore: !values["no-gitignore"]
8339
8910
  });
@@ -8430,6 +9001,17 @@ async function main(argv) {
8430
9001
  case "archive":
8431
9002
  case "unarchive":
8432
9003
  return runArchive(projectPaths(values.root), rest[0], command === "archive");
9004
+ case "page":
9005
+ if (rest.length > 1) throw new CliError("Usage: design page <file.html|folder> [--name <slug>] [--public]");
9006
+ return runPage(rest[0], {
9007
+ name: values.name,
9008
+ title: values.title,
9009
+ project: values.project,
9010
+ public: !!values.public,
9011
+ json: !!values.json
9012
+ });
9013
+ case "pages":
9014
+ return runPages({ project: values.project, json: !!values.json });
8433
9015
  case "push":
8434
9016
  return runPush(projectPaths(values.root), rest, { resolved: values.resolved ?? [], json: !!values.json });
8435
9017
  case "pull":
@@ -8440,18 +9022,18 @@ async function main(argv) {
8440
9022
  }
8441
9023
  function relaunchFromRealPath() {
8442
9024
  if (process.platform !== "win32" || process.env.FOSS_DESIGN_RELAUNCHED) return false;
8443
- const script = process.argv[1];
8444
- if (!script) return false;
9025
+ const script2 = process.argv[1];
9026
+ if (!script2) return false;
8445
9027
  let realScript;
8446
9028
  let realCwd;
8447
9029
  try {
8448
- realScript = fs39.realpathSync.native(script);
8449
- realCwd = fs39.realpathSync.native(process.cwd());
9030
+ realScript = fs40.realpathSync.native(script2);
9031
+ realCwd = fs40.realpathSync.native(process.cwd());
8450
9032
  } catch {
8451
9033
  return false;
8452
9034
  }
8453
- const same = (a, b) => path38.resolve(a).toLowerCase() === path38.resolve(b).toLowerCase();
8454
- if (same(realScript, script) && same(realCwd, process.cwd())) return false;
9035
+ const same = (a, b) => path39.resolve(a).toLowerCase() === path39.resolve(b).toLowerCase();
9036
+ if (same(realScript, script2) && same(realCwd, process.cwd())) return false;
8455
9037
  const result = spawnSync3(process.execPath, [...process.execArgv, realScript, ...process.argv.slice(2)], {
8456
9038
  stdio: "inherit",
8457
9039
  cwd: realCwd,