@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.
- package/README.md +18 -0
- package/dist/index.mjs +463 -31
- 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 {
|
|
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.
|
|
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
|
|
335
|
-
*
|
|
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
|
-
|
|
340
|
-
|
|
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
|
|
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, "
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
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).
|
|
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.
|
|
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"
|