@design-drafts/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -0
  2. package/dist/index.mjs +463 -31
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -53,6 +53,24 @@ Scaffold a new draft directory locally.
53
53
  design-drafts init draft ./my-draft
54
54
  ```
55
55
 
56
+ ### `design-drafts preview [path]`
57
+
58
+ Serve a work-in-progress draft directory (default `.`) over HTTP so you can view
59
+ it locally before pushing. The directory must contain a `design-drafts.config.json`.
60
+
61
+ ```sh
62
+ design-drafts preview ./my-draft
63
+ ```
64
+
65
+ - `--port <n>` — port to serve on (default `4321`; auto-increments to the next
66
+ free port unless you set it explicitly).
67
+ - `--no-open` — don't open a browser, just print the URL.
68
+
69
+ When a requested directory has no `index.html` (e.g. a draft whose pages are
70
+ `about.html`, `pricing.html`, … with no home page yet), the server returns a
71
+ generated index linking to every `.html` page in the draft so you can navigate
72
+ without one.
73
+
56
74
  ## Configuration
57
75
 
58
76
  Shared options (`--repo`, `--site-name`, `--template-ref`) can be supplied via
package/dist/index.mjs CHANGED
@@ -1,18 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
- import { ConfigurationProviders, cli } from "cli-forge";
4
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { cli } from "cli-forge";
6
6
  import { confirm, isCancel, select, text } from "@clack/prompts";
7
- import { execSync } from "node:child_process";
7
+ import { execSync, spawn } from "node:child_process";
8
+ import { createServer } from "node:http";
8
9
  //#region package.json
9
- var version = "0.1.0";
10
+ var version = "0.2.0";
10
11
  //#endregion
11
12
  //#region src/config.ts
12
13
  const CONFIG_FILENAME = "design-drafts.config.json";
13
14
  const DEFAULT_PREFIX = "drafts/";
14
15
  const homeConfigPath = join(homedir(), CONFIG_FILENAME);
15
- const localConfigPath = join(process.cwd(), CONFIG_FILENAME);
16
16
  const homeJsonProvider = {
17
17
  resolve: () => existsSync(homeConfigPath) ? homeConfigPath : void 0,
18
18
  load: (filename) => JSON.parse(readFileSync(filename, "utf-8"))
@@ -43,15 +43,6 @@ async function promptForValue(existing, label, promptMessage, validate) {
43
43
  if (typeof value !== "string") process.exit(1);
44
44
  return value;
45
45
  }
46
- async function promptAndPersist(existing, argKey, configPath, promptMessage, validate) {
47
- if (existing) return existing;
48
- const value = await promptForValue(existing, argKey, promptMessage, validate);
49
- writeFileSync(configPath, JSON.stringify({
50
- ...readConfig(configPath),
51
- [argKey]: value
52
- }, null, 2) + "\n");
53
- return value;
54
- }
55
46
  function persistHomeConfigValue(key, value) {
56
47
  const previousFile = readConfig(homeConfigPath);
57
48
  if (previousFile[key] === value) return;
@@ -331,13 +322,24 @@ dist
331
322
  .gh-pages-staging
332
323
  `;
333
324
  /**
334
- * The starter manifest written by \`init draft\`. The push command reads the
335
- * \`prompt\` field for the commit trailer.
325
+ * The starter manifest written by `init draft`. It is a valid DraftManifest
326
+ * (see `@design-drafts/conventions`): a single-page, axis-free proposal whose
327
+ * one page is `index.html`. A schema-valid manifest matters because the toolbar
328
+ * silently no-ops on a manifest missing `name`/`pages`, so a stub with the wrong
329
+ * shape would leave a fresh draft without a working toolbar. The push command
330
+ * reads the `prompt` field for the commit trailer; the author fills in `axes`
331
+ * and additional `pages` as the draft grows.
336
332
  */
337
- function draftConfig(siteName) {
333
+ function draftConfig(siteName, createdAt) {
338
334
  return JSON.stringify({
339
- siteName,
340
- prompt: ""
335
+ $schema: "https://design-drafts.dev/schemas/draft-manifest.schema.json",
336
+ name: siteName,
337
+ prompt: "",
338
+ pages: [{
339
+ coordinates: {},
340
+ path: "index.html"
341
+ }],
342
+ createdAt
341
343
  }, null, 2) + "\n";
342
344
  }
343
345
  const DRAFT_INDEX_HTML = `<!doctype html>
@@ -352,6 +354,19 @@ const DRAFT_INDEX_HTML = `<!doctype html>
352
354
  <h1>Draft preview</h1>
353
355
  <p>Replace this with your design draft, then run <code>design-drafts</code> to publish.</p>
354
356
  </main>
357
+
358
+ <!--
359
+ design-drafts overlays, loaded from the CDN — no build step, no file to copy:
360
+ - toolbar: switches between the axes declared in design-drafts.config.json
361
+ - annotate: lets reviewers leave comments anchored to the page
362
+ Both are inert until they have something to do (the toolbar needs a
363
+ design-drafts.config.json; annotate waits for ?annotate=1 or its toggle), so they
364
+ are safe to keep on every page. Pinned to the current major (@0); drop a
365
+ tag to remove that overlay, or bump the pin when you upgrade. See each
366
+ package's README for self-hosting and version options.
367
+ -->
368
+ <script src="https://unpkg.com/@design-drafts/toolbar@0/dist/toolbar.js" defer><\/script>
369
+ <script src="https://unpkg.com/@design-drafts/annotate@0/dist/annotate.js" defer><\/script>
355
370
  </body>
356
371
  </html>
357
372
  `;
@@ -372,7 +387,7 @@ function writeIfAbsent(filePath, contents) {
372
387
  async function initDraft(opts) {
373
388
  const targetDir = resolve(opts.path);
374
389
  if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
375
- let siteName = await promptAndPersist(opts.siteName, "site-name", localConfigPath, "Site name for this draft:");
390
+ let siteName = await promptForValue(opts.siteName, "site-name", "Site name for this draft:");
376
391
  const validation = validateSiteName(siteName);
377
392
  if (!validation.ok) {
378
393
  const fixed = validation.suggestion ?? slugifySiteName(siteName);
@@ -380,7 +395,7 @@ async function initDraft(opts) {
380
395
  siteName = fixed;
381
396
  }
382
397
  console.log(`\nScaffolding draft "${siteName}" in ${targetDir}:`);
383
- const wroteManifest = writeIfAbsent(join(targetDir, "draft.config.json"), draftConfig(siteName));
398
+ const wroteManifest = writeIfAbsent(join(targetDir, "design-drafts.config.json"), draftConfig(siteName, (/* @__PURE__ */ new Date()).toISOString()));
384
399
  const wroteIndex = writeIfAbsent(join(targetDir, "index.html"), DRAFT_INDEX_HTML);
385
400
  if (!wroteManifest && !wroteIndex) {
386
401
  console.log(`\nDraft "${siteName}" was already scaffolded; nothing to write.`);
@@ -738,6 +753,345 @@ async function init(opts) {
738
753
  console.log("\nWhen the draft looks right, run `design-drafts` to publish.");
739
754
  }
740
755
  //#endregion
756
+ //#region src/draft-dir.ts
757
+ /**
758
+ * Resolves the draft directory a command should act on: the explicit path when
759
+ * given, otherwise the current working directory. Throws when the resolved
760
+ * directory has no `design-drafts.config.json`, so callers fail with an
761
+ * actionable message instead of silently operating on a non-draft directory.
762
+ */
763
+ function resolveDraftDir(explicit) {
764
+ const candidate = resolve(explicit ?? process.cwd());
765
+ if (!existsSync(join(candidate, "design-drafts.config.json"))) throw new Error(`No design-drafts.config.json at ${candidate}. Run from inside a draft directory or pass --draft <dir>.`);
766
+ return candidate;
767
+ }
768
+ //#endregion
769
+ //#region src/preview.ts
770
+ const DEFAULT_PORT = 4321;
771
+ const PORT_SCAN_ATTEMPTS = 20;
772
+ const MIME_TYPES = {
773
+ ".html": "text/html; charset=utf-8",
774
+ ".htm": "text/html; charset=utf-8",
775
+ ".css": "text/css; charset=utf-8",
776
+ ".js": "text/javascript; charset=utf-8",
777
+ ".mjs": "text/javascript; charset=utf-8",
778
+ ".json": "application/json; charset=utf-8",
779
+ ".map": "application/json; charset=utf-8",
780
+ ".svg": "image/svg+xml",
781
+ ".png": "image/png",
782
+ ".jpg": "image/jpeg",
783
+ ".jpeg": "image/jpeg",
784
+ ".webp": "image/webp",
785
+ ".gif": "image/gif",
786
+ ".ico": "image/x-icon",
787
+ ".avif": "image/avif",
788
+ ".woff": "font/woff",
789
+ ".woff2": "font/woff2",
790
+ ".ttf": "font/ttf",
791
+ ".otf": "font/otf",
792
+ ".txt": "text/plain; charset=utf-8",
793
+ ".md": "text/markdown; charset=utf-8"
794
+ };
795
+ /** Maps a file path to a Content-Type by extension, defaulting to a generic
796
+ * binary type for anything unrecognised. */
797
+ function contentTypeFor(filePath) {
798
+ return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
799
+ }
800
+ /**
801
+ * Resolves a request URL to an absolute path inside `draftDir`, or `null` when
802
+ * the request is malformed or tries to escape the draft root.
803
+ *
804
+ * The query string and hash are stripped, percent-escapes are decoded (so an
805
+ * encoded `..` can't sneak past), and the result is confirmed to live under
806
+ * `draftDir` via a `relative()` check. The returned path may be a file or a
807
+ * directory — the caller decides how to serve it.
808
+ */
809
+ function resolveServedFile(draftDir, urlPath) {
810
+ const withoutQuery = urlPath.split("?")[0].split("#")[0];
811
+ let decoded;
812
+ try {
813
+ decoded = decodeURIComponent(withoutQuery);
814
+ } catch {
815
+ return null;
816
+ }
817
+ const candidate = resolve(draftDir, "." + (decoded.startsWith("/") ? decoded : `/${decoded}`));
818
+ const rel = relative(draftDir, candidate);
819
+ if (rel && (rel.startsWith("..") || isAbsolute(rel))) return null;
820
+ return candidate;
821
+ }
822
+ /**
823
+ * Recursively collects every `.html` file beneath `dir`, returned as
824
+ * root-relative POSIX paths (e.g. `pages/sub/p.html`) sorted for stable output.
825
+ * Used to build the generated index when a directory has no index.html.
826
+ */
827
+ function collectHtmlPages(dir) {
828
+ const pages = [];
829
+ const walk = (current) => {
830
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
831
+ const abs = join(current, entry.name);
832
+ if (entry.isDirectory()) walk(abs);
833
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === ".html") pages.push(relative(dir, abs).split(sep).join("/"));
834
+ }
835
+ };
836
+ walk(dir);
837
+ return pages.sort();
838
+ }
839
+ function escapeHtml(value) {
840
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
841
+ }
842
+ /**
843
+ * Renders a fallback index page that links to every `.html` page in the draft,
844
+ * shown when the requested directory has no index.html of its own. Links are
845
+ * root-absolute so they resolve regardless of which directory was requested.
846
+ */
847
+ function renderDirectoryIndex(draftDir) {
848
+ const pages = collectHtmlPages(draftDir);
849
+ return `<!doctype html>
850
+ <html lang="en">
851
+ <head>
852
+ <meta charset="utf-8" />
853
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
854
+ <title>Draft pages</title>
855
+ <style>
856
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 3rem auto; max-width: 40rem; padding: 0 1rem; }
857
+ h1 { font-size: 1.25rem; }
858
+ ul { list-style: none; padding: 0; }
859
+ li { margin: 0.25rem 0; }
860
+ a { color: #2563eb; text-decoration: none; }
861
+ a:hover { text-decoration: underline; }
862
+ .empty { color: #6b7280; }
863
+ </style>
864
+ </head>
865
+ <body>
866
+ <h1>Draft pages</h1>
867
+ <p>No <code>index.html</code> here — listing the pages in this draft:</p>
868
+ <ul>
869
+ ${pages.length ? pages.map((page) => ` <li><a href="/${page}">${escapeHtml(page)}</a></li>`).join("\n") : " <li class=\"empty\">No pages found in this draft yet.</li>"}
870
+ </ul>
871
+ </body>
872
+ </html>
873
+ `;
874
+ }
875
+ /** Builds the static file server for a draft directory without binding it to a
876
+ * port, so it can be exercised directly in tests. */
877
+ function createPreviewServer(draftDir) {
878
+ return createServer((req, res) => {
879
+ const safePath = resolveServedFile(draftDir, req.url ?? "/");
880
+ if (safePath === null) {
881
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
882
+ res.end("Forbidden");
883
+ return;
884
+ }
885
+ try {
886
+ let filePath = safePath;
887
+ if (statSync(filePath).isDirectory()) {
888
+ const indexPath = join(filePath, "index.html");
889
+ if (!existsSync(indexPath)) {
890
+ const listing = renderDirectoryIndex(draftDir);
891
+ res.writeHead(200, {
892
+ "Content-Type": "text/html; charset=utf-8",
893
+ "Content-Length": Buffer.byteLength(listing)
894
+ });
895
+ res.end(listing);
896
+ return;
897
+ }
898
+ filePath = indexPath;
899
+ }
900
+ const body = readFileSync(filePath);
901
+ res.writeHead(200, {
902
+ "Content-Type": contentTypeFor(filePath),
903
+ "Content-Length": body.length
904
+ });
905
+ res.end(body);
906
+ } catch {
907
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
908
+ res.end("Not found");
909
+ }
910
+ });
911
+ }
912
+ /**
913
+ * Binds the server to a port. With an explicit `requestedPort` we try only that
914
+ * port and surface a clear error if it's taken; without one we scan upward from
915
+ * the default until a free port is found.
916
+ */
917
+ function listen(server, requestedPort) {
918
+ const startPort = requestedPort ?? DEFAULT_PORT;
919
+ const maxAttempts = requestedPort === void 0 ? PORT_SCAN_ATTEMPTS : 1;
920
+ const tryPort = (port, attempt) => new Promise((resolvePort, rejectPort) => {
921
+ const onError = (error) => {
922
+ server.removeListener("listening", onListening);
923
+ if (error.code === "EADDRINUSE" && attempt + 1 < maxAttempts) {
924
+ resolvePort(tryPort(port + 1, attempt + 1));
925
+ return;
926
+ }
927
+ if (error.code === "EADDRINUSE") {
928
+ rejectPort(new CliError(`Port ${port} is already in use. Pass --port <n> to pick another.`));
929
+ return;
930
+ }
931
+ rejectPort(error);
932
+ };
933
+ const onListening = () => {
934
+ server.removeListener("error", onError);
935
+ resolvePort(port);
936
+ };
937
+ server.once("error", onError);
938
+ server.once("listening", onListening);
939
+ server.listen(port, "localhost");
940
+ });
941
+ return tryPort(startPort, 0);
942
+ }
943
+ /** Opens the given URL in the user's default browser. Best-effort: a missing
944
+ * opener or a spawn failure is swallowed so it never breaks the server. */
945
+ function openBrowser(url) {
946
+ const { platform } = process;
947
+ const [command, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
948
+ "/c",
949
+ "start",
950
+ "",
951
+ url
952
+ ]] : ["xdg-open", [url]];
953
+ try {
954
+ const child = spawn(command, args, {
955
+ stdio: "ignore",
956
+ detached: true
957
+ });
958
+ child.on("error", () => {});
959
+ child.unref();
960
+ } catch {}
961
+ }
962
+ /** Serves a work-in-progress draft directory over HTTP for local viewing. */
963
+ async function preview(opts) {
964
+ const url = `http://localhost:${await listen(createPreviewServer(resolveDraftDir(opts.draft)), opts.port)}/`;
965
+ console.log(`\nServing draft at ${url}`);
966
+ console.log("Press Ctrl+C to stop.");
967
+ if (opts.open !== false) openBrowser(url);
968
+ }
969
+ //#endregion
970
+ //#region src/ref-add.ts
971
+ const IMAGE_EXTENSIONS = new Set([
972
+ ".png",
973
+ ".webp",
974
+ ".jpg",
975
+ ".jpeg"
976
+ ]);
977
+ const REFERENCES_DIRNAME = "references";
978
+ const INSPIRATION_DIRNAME = "inspiration";
979
+ const LINKS_FILENAME = "links.md";
980
+ const LINKS_HEADER = "# Links\n\nAnnotated reference URLs. See `docs/conventions/references-protocol.md` — one URL per bullet, an em-dash, then a sentence saying what is being cited.\n\n";
981
+ async function refAdd(options) {
982
+ const draftDir = resolveDraftDir(options.draft);
983
+ ensureReferencesScaffold(draftDir);
984
+ if (isUrl(options.source)) {
985
+ if (looksLikeImageUrl(options.source)) {
986
+ await downloadInspiration({
987
+ url: options.source,
988
+ draftDir,
989
+ name: options.name,
990
+ note: options.note
991
+ });
992
+ return;
993
+ }
994
+ if (!options.note?.trim()) throw new Error("URL references require --note explaining what is being cited (e.g. 'typography pairing, NOT the color').");
995
+ appendLink({
996
+ url: options.source,
997
+ draftDir,
998
+ note: options.note
999
+ });
1000
+ return;
1001
+ }
1002
+ copyLocalInspiration({
1003
+ filePath: options.source,
1004
+ draftDir,
1005
+ name: options.name
1006
+ });
1007
+ }
1008
+ function isUrl(source) {
1009
+ try {
1010
+ const parsed = new URL(source);
1011
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
1012
+ } catch {
1013
+ return false;
1014
+ }
1015
+ }
1016
+ function looksLikeImageUrl(url) {
1017
+ const pathname = new URL(url).pathname.toLowerCase();
1018
+ return IMAGE_EXTENSIONS.has(extname(pathname));
1019
+ }
1020
+ function ensureReferencesScaffold(draftDir) {
1021
+ const refs = join(draftDir, REFERENCES_DIRNAME);
1022
+ if (!existsSync(refs)) mkdirSync(refs, { recursive: true });
1023
+ const linksPath = join(refs, LINKS_FILENAME);
1024
+ if (!existsSync(linksPath)) writeFileSync(linksPath, LINKS_HEADER);
1025
+ }
1026
+ function appendLink({ url, draftDir, note }) {
1027
+ const linksPath = join(draftDir, REFERENCES_DIRNAME, LINKS_FILENAME);
1028
+ const existing = existsSync(linksPath) ? readFileSync(linksPath, "utf-8") : LINKS_HEADER;
1029
+ const line = `- ${url} — ${note.trim()}\n`;
1030
+ writeFileSync(linksPath, existing.endsWith("\n") ? existing + line : existing + "\n" + line);
1031
+ process.stdout.write(`Added link to ${join(REFERENCES_DIRNAME, LINKS_FILENAME)}: ${url}\n`);
1032
+ }
1033
+ async function downloadInspiration({ url, draftDir, name, note }) {
1034
+ const inspirationDir = join(draftDir, REFERENCES_DIRNAME, INSPIRATION_DIRNAME);
1035
+ if (!existsSync(inspirationDir)) mkdirSync(inspirationDir, { recursive: true });
1036
+ const ext = extname(new URL(url).pathname).toLowerCase() || ".png";
1037
+ const finalName = uniqueFilename(inspirationDir, ensureExtension(name ?? deriveImageName(url), ext));
1038
+ const finalPath = join(inspirationDir, finalName);
1039
+ const response = await fetch(url);
1040
+ if (!response.ok) throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
1041
+ writeFileSync(finalPath, new Uint8Array(await response.arrayBuffer()));
1042
+ const relPath = join(REFERENCES_DIRNAME, INSPIRATION_DIRNAME, finalName);
1043
+ process.stdout.write(`Downloaded to ${relPath}\n`);
1044
+ if (!name) process.stdout.write(` Heads up: filename was derived from the URL. Per the references protocol,
1045
+ the filename IS the citation — rename to describe what's being cited
1046
+ (e.g. linear-empty-state-density${ext}).\n`);
1047
+ if (note?.trim()) appendLink({
1048
+ url,
1049
+ draftDir,
1050
+ note: `${note.trim()} (see ${INSPIRATION_DIRNAME}/${finalName})`
1051
+ });
1052
+ }
1053
+ function copyLocalInspiration({ filePath, draftDir, name }) {
1054
+ const absPath = resolve(filePath);
1055
+ if (!existsSync(absPath)) throw new Error(`File does not exist: ${absPath}`);
1056
+ if (!statSync(absPath).isFile()) throw new Error(`Not a regular file: ${absPath}`);
1057
+ const ext = extname(absPath).toLowerCase();
1058
+ if (!IMAGE_EXTENSIONS.has(ext)) throw new Error(`Inspiration files must be one of ${[...IMAGE_EXTENSIONS].join(", ")} per the references protocol. Got: ${ext || "(no extension)"}`);
1059
+ const inspirationDir = join(draftDir, REFERENCES_DIRNAME, INSPIRATION_DIRNAME);
1060
+ if (!existsSync(inspirationDir)) mkdirSync(inspirationDir, { recursive: true });
1061
+ const finalName = uniqueFilename(inspirationDir, ensureExtension(name ?? basename(absPath), ext));
1062
+ copyFileSync(absPath, join(inspirationDir, finalName));
1063
+ const relPath = join(REFERENCES_DIRNAME, INSPIRATION_DIRNAME, finalName);
1064
+ process.stdout.write(`Copied to ${relPath}\n`);
1065
+ if (!name) process.stdout.write(" Heads up: kept the original filename. Per the references protocol,\n the filename IS the citation — rename to describe what's being cited.\n");
1066
+ }
1067
+ function deriveImageName(url) {
1068
+ try {
1069
+ const parsed = new URL(url);
1070
+ const segments = parsed.pathname.split("/").filter(Boolean);
1071
+ const stem = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "");
1072
+ const host = parsed.hostname.replace(/^www\./, "").replace(/\./g, "-");
1073
+ return slugify(stem ? `${host}-${stem}` : host);
1074
+ } catch {
1075
+ return "reference";
1076
+ }
1077
+ }
1078
+ function slugify(input) {
1079
+ return input.toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
1080
+ }
1081
+ function ensureExtension(name, ext) {
1082
+ return extname(name).toLowerCase() === ext.toLowerCase() ? name : `${name}${ext}`;
1083
+ }
1084
+ function uniqueFilename(dir, candidate) {
1085
+ if (!existsSync(join(dir, candidate))) return candidate;
1086
+ const ext = extname(candidate);
1087
+ const stem = candidate.slice(0, candidate.length - ext.length);
1088
+ for (let i = 2; i < 1e3; i++) {
1089
+ const next = `${stem}-${i}${ext}`;
1090
+ if (!existsSync(join(dir, next))) return next;
1091
+ }
1092
+ throw new Error(`Could not find a unique filename for ${candidate} in ${dir}`);
1093
+ }
1094
+ //#endregion
741
1095
  //#region src/index.ts
742
1096
  const CLI_VERSION = version;
743
1097
  const DEFAULT_BRANCH = "main";
@@ -800,6 +1154,38 @@ function readManifestPrompt(manifestPath, sourcePath) {
800
1154
  } catch {}
801
1155
  return prompt.replace(/\s+/g, " ");
802
1156
  }
1157
+ /** Reads the manifest's human-readable `name` so the push can derive a
1158
+ * site-name from it. Returns undefined when there is no manifest, it doesn't
1159
+ * parse, or it has no usable `name`. */
1160
+ function readManifestName(manifestPath) {
1161
+ let parsed;
1162
+ try {
1163
+ parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
1164
+ } catch {
1165
+ return;
1166
+ }
1167
+ if (!parsed || typeof parsed !== "object") return void 0;
1168
+ const name = parsed.name;
1169
+ return typeof name === "string" && name.trim() ? name : void 0;
1170
+ }
1171
+ /**
1172
+ * Resolves the site-name (the branch/preview directory name) for a push.
1173
+ *
1174
+ * Precedence: an explicit `--site-name` wins; otherwise we derive it from the
1175
+ * manifest's `name` by slugifying it (so "Toolbar redesign demo" becomes
1176
+ * "toolbar-redesign-demo"). Only when there is no manifest to read from do we
1177
+ * fall back to prompting. The manifest is the single per-draft store now, so we
1178
+ * never persist the answer to a separate file.
1179
+ */
1180
+ async function resolveSiteName(explicit, manifestPath) {
1181
+ if (explicit) return explicit;
1182
+ const fromManifest = readManifestName(manifestPath);
1183
+ if (fromManifest) {
1184
+ const slug = slugifySiteName(fromManifest);
1185
+ if (slug) return slug;
1186
+ }
1187
+ return promptForValue(void 0, "site-name", "Site name for this preview:");
1188
+ }
803
1189
  function sanitizeAuthorName(name) {
804
1190
  return name.replace(/</g, "(").replace(/>/g, ")");
805
1191
  }
@@ -814,7 +1200,7 @@ function sanitizeAuthorName(name) {
814
1200
  * source-repo: <repo>
815
1201
  * author: <Name <email>>
816
1202
  * prompt: <one-line summary or path>
817
- * draft-config-sha: <sha256 of draft.config.json>
1203
+ * draft-config-sha: <sha256 of design-drafts.config.json>
818
1204
  *
819
1205
  * Each trailer line is omitted when its data is unavailable (no source git
820
1206
  * repo, no manifest, no `prompt` field, etc.). The site-name subject line is
@@ -836,9 +1222,14 @@ function buildCommitMessage(opts) {
836
1222
  }
837
1223
  async function pushHandler(args) {
838
1224
  const repo = await promptForValue(args.repo, "repo", "GitHub repo (org/repo):", (v) => validateRepo(v).ok ? void 0 : "use \"owner/name\" form");
839
- const siteName = await promptAndPersist(args["site-name"], "site-name", localConfigPath, "Site name for this preview:");
840
- const prefix = resolvePrefix(args.prefix);
841
1225
  const sourcePath = resolve(args.path ?? ".");
1226
+ if (!existsSync(sourcePath)) {
1227
+ console.error(`Path does not exist: ${sourcePath}`);
1228
+ process.exit(1);
1229
+ }
1230
+ const manifestPath = join(sourcePath, "design-drafts.config.json");
1231
+ const siteName = await resolveSiteName(args["site-name"], manifestPath);
1232
+ const prefix = resolvePrefix(args.prefix);
842
1233
  const validation = validateSiteName(siteName);
843
1234
  if (!validation.ok) {
844
1235
  console.error(`Invalid site-name "${siteName}": ${validation.reason}`);
@@ -855,11 +1246,6 @@ async function pushHandler(args) {
855
1246
  console.error(`Invalid prefix "${prefix}": ${prefixCheck.reason}`);
856
1247
  process.exit(1);
857
1248
  }
858
- if (!existsSync(sourcePath)) {
859
- console.error(`Path does not exist: ${sourcePath}`);
860
- process.exit(1);
861
- }
862
- const manifestPath = join(sourcePath, "draft.config.json");
863
1249
  const metadata = getSourceMetadata(sourcePath);
864
1250
  const draftConfigSha = hashManifest(manifestPath);
865
1251
  const commitMessage = buildCommitMessage({
@@ -905,7 +1291,7 @@ await cli("design-drafts", {
905
1291
  }).option("template-ref", {
906
1292
  type: "string",
907
1293
  description: "Ref of the canonical repo to scaffold the host site from (default: matching version tag, else main)"
908
- }).env({ prefix: "DESIGN_DRAFTS" }).config(homeJsonProvider).config(ConfigurationProviders.JsonFile(CONFIG_FILENAME)).command("init", {
1294
+ }).env({ prefix: "DESIGN_DRAFTS" }).config(homeJsonProvider).command("init", {
909
1295
  description: "Set up a host (if needed) and scaffold a draft, ready to publish",
910
1296
  builder: (initArgs) => initArgs.command("host", {
911
1297
  description: "Scaffold a GitHub repo to host draft previews",
@@ -946,6 +1332,52 @@ await cli("design-drafts", {
946
1332
  templateRef: a["template-ref"],
947
1333
  cliVersion: CLI_VERSION
948
1334
  }))
1335
+ }).command("ref", {
1336
+ description: "Manage a draft's references/ directory (links and inspiration screenshots).",
1337
+ builder: (refArgs) => refArgs.command("add", {
1338
+ description: "Add a reference URL or screenshot to references/. URL → references/links.md (with --note); image URL or local image → references/inspiration/.",
1339
+ builder: (b) => b.positional("source", {
1340
+ type: "string",
1341
+ description: "URL or local file path to add as a reference"
1342
+ }).option("note", {
1343
+ type: "string",
1344
+ description: "Annotation describing what is being cited. Required for non-image URLs."
1345
+ }).option("name", {
1346
+ type: "string",
1347
+ description: "Override the filename used in references/inspiration/ (extension is added if missing)."
1348
+ }).option("draft", {
1349
+ type: "string",
1350
+ description: "Path to the draft directory containing design-drafts.config.json (default: cwd)."
1351
+ }),
1352
+ handler: (a) => runHandler(() => {
1353
+ if (!a.source) throw new CliError("design-drafts ref add requires a <source> argument (URL or file path).");
1354
+ return refAdd({
1355
+ source: a.source,
1356
+ note: a.note,
1357
+ name: a.name,
1358
+ draft: a.draft
1359
+ });
1360
+ })
1361
+ })
1362
+ }).command("preview", {
1363
+ description: "Serve a work-in-progress draft directory over HTTP for local viewing",
1364
+ builder: (b) => b.positional("path", {
1365
+ type: "string",
1366
+ default: ".",
1367
+ description: "Draft directory to serve (must contain design-drafts.config.json; default: cwd)"
1368
+ }).option("port", {
1369
+ type: "number",
1370
+ description: "Port to serve on (default: 4321; auto-increments if busy unless set explicitly)"
1371
+ }).option("open", {
1372
+ type: "boolean",
1373
+ default: true,
1374
+ description: "Open the preview in a browser (use --no-open to just print the URL)"
1375
+ }),
1376
+ handler: (a) => runHandler(() => preview({
1377
+ draft: a.path,
1378
+ port: a.port,
1379
+ open: a.open
1380
+ }))
949
1381
  }).command("push", {
950
1382
  alias: ["$0"],
951
1383
  description: "Push a built directory as a draft preview branch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-drafts/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for scaffolding and deploying design-drafts previews to GitHub Pages.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,7 +43,8 @@
43
43
  "tsdown": "^0.21.7",
44
44
  "typescript": "5.9.3",
45
45
  "vitest": "^4.1.4",
46
- "yaml": "^2.8.3"
46
+ "yaml": "^2.8.3",
47
+ "@design-drafts/conventions": "0.0.0"
47
48
  },
48
49
  "nx": {
49
50
  "name": "cli"