@vincentt-xr/cli 0.2.0 → 0.4.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 +3 -1
- package/dist/cli/args.d.ts +9 -0
- package/dist/cli/args.js +20 -0
- package/dist/cli/index.js +114 -25
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +0 -34
- package/dist/lib/initCommand.d.ts +18 -1
- package/dist/lib/initCommand.js +70 -7
- package/dist/lib/lifecycle.d.ts +12 -4
- package/dist/lib/lifecycle.js +36 -30
- package/dist/lib/version.d.ts +34 -0
- package/dist/lib/version.js +82 -0
- package/dist/publish/api.d.ts +67 -0
- package/dist/publish/api.js +96 -0
- package/dist/publish/bounds.d.ts +25 -0
- package/dist/publish/bounds.js +53 -0
- package/dist/publish/classify.d.ts +51 -0
- package/dist/publish/classify.js +138 -0
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +328 -0
- package/dist/publish/consoleVerbs.d.ts +17 -0
- package/dist/publish/consoleVerbs.js +26 -0
- package/dist/publish/copy.d.ts +180 -0
- package/dist/publish/copy.js +606 -0
- package/dist/publish/defaults.d.ts +16 -0
- package/dist/publish/defaults.js +52 -0
- package/dist/publish/manifest.d.ts +27 -0
- package/dist/publish/manifest.js +81 -0
- package/dist/publish/paths.d.ts +25 -0
- package/dist/publish/paths.js +78 -0
- package/dist/publish/progress.d.ts +17 -0
- package/dist/publish/progress.js +45 -0
- package/dist/publish/prompt.d.ts +7 -0
- package/dist/publish/prompt.js +43 -0
- package/dist/publish/qa.f4.fixtures.d.ts +25 -0
- package/dist/publish/qa.f4.fixtures.js +140 -0
- package/dist/publish/upload.d.ts +30 -0
- package/dist/publish/upload.js +93 -0
- package/dist/scaffold/index.d.ts +26 -0
- package/dist/scaffold/index.js +43 -1
- package/package.json +2 -3
- package/dist/mcp/server.d.ts +0 -16
- package/dist/mcp/server.js +0 -215
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// `vincentt version` — which CLI is running, and which backend it will actually
|
|
2
|
+
// talk to.
|
|
3
|
+
//
|
|
4
|
+
// The second half is the point. A creator can have the right CLI pointed at the
|
|
5
|
+
// wrong API (a leftover VINCENTT_API_URL, a machine config written months ago,
|
|
6
|
+
// or the localhost default on a fresh install), and every failure that follows
|
|
7
|
+
// reads like a server outage rather than a misconfiguration. So this reports the
|
|
8
|
+
// resolved host AND its provenance, using the same decideApiUrl the real verbs
|
|
9
|
+
// use — a version command that re-derived the URL could disagree with them.
|
|
10
|
+
import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
|
|
11
|
+
/**
|
|
12
|
+
* Decide what to report. Pure, so the transcript is testable without a home
|
|
13
|
+
* directory, an env var, or a built package.
|
|
14
|
+
*/
|
|
15
|
+
export function buildVersionReport(env) {
|
|
16
|
+
const decided = decideApiUrl({
|
|
17
|
+
bindingApiUrl: env.bindingApiUrl,
|
|
18
|
+
envApiUrl: env.envApiUrl,
|
|
19
|
+
machineApiUrl: env.machineApiUrl,
|
|
20
|
+
allowCustomApi: env.allowCustomApi,
|
|
21
|
+
});
|
|
22
|
+
let apiSource;
|
|
23
|
+
let isDefault = false;
|
|
24
|
+
if (decided.usedBindingApiUrl) {
|
|
25
|
+
apiSource = "this folder's binding (--allow-custom-api)";
|
|
26
|
+
}
|
|
27
|
+
else if (env.envApiUrl) {
|
|
28
|
+
apiSource = "VINCENTT_API_URL";
|
|
29
|
+
}
|
|
30
|
+
else if (env.machineApiUrl) {
|
|
31
|
+
apiSource = "~/.vincentt/config.json";
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
apiSource = "built-in default";
|
|
35
|
+
isDefault = true;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
cliVersion: env.cliVersion,
|
|
39
|
+
nodeVersion: env.nodeVersion,
|
|
40
|
+
apiUrl: decided.apiUrl,
|
|
41
|
+
apiSource,
|
|
42
|
+
ignoredBindingApiUrl: decided.ignoredBindingApiUrl,
|
|
43
|
+
isDefault,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The transcript. Two aligned columns so the host and its provenance read as one
|
|
48
|
+
* fact, not two.
|
|
49
|
+
*/
|
|
50
|
+
export function renderVersion(r) {
|
|
51
|
+
const out = [
|
|
52
|
+
"",
|
|
53
|
+
`vincentt ${r.cliVersion}`,
|
|
54
|
+
"",
|
|
55
|
+
` api ${r.apiUrl}`,
|
|
56
|
+
` from ${r.apiSource}`,
|
|
57
|
+
];
|
|
58
|
+
// A localhost default on a machine that never configured anything is the
|
|
59
|
+
// single most confusing state to be in: every verb fails with a connection
|
|
60
|
+
// error that looks like the platform is down.
|
|
61
|
+
if (r.isDefault && /localhost|127\.0\.0\.1/.test(r.apiUrl)) {
|
|
62
|
+
out.push("", " ! Nothing is configured, so this points at a local backend.", " Set VINCENTT_API_URL, or run `vincentt login` to save one.");
|
|
63
|
+
}
|
|
64
|
+
if (r.ignoredBindingApiUrl) {
|
|
65
|
+
out.push("", ` ! This folder's binding names a different API (${r.ignoredBindingApiUrl})`, " and was ignored. Re-run with --allow-custom-api to use it.");
|
|
66
|
+
}
|
|
67
|
+
out.push("", ` node ${r.nodeVersion}`, "");
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Read the machine's real state and produce the report. */
|
|
71
|
+
export async function versionReport(cwd, opts) {
|
|
72
|
+
const machine = await loadMachineConfig();
|
|
73
|
+
const located = await locateProjectBinding(cwd);
|
|
74
|
+
return buildVersionReport({
|
|
75
|
+
envApiUrl: process.env.VINCENTT_API_URL,
|
|
76
|
+
machineApiUrl: machine.apiUrl,
|
|
77
|
+
bindingApiUrl: located?.binding.apiUrl,
|
|
78
|
+
allowCustomApi: opts.allowCustomApi,
|
|
79
|
+
nodeVersion: process.version,
|
|
80
|
+
cliVersion: opts.cliVersion,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
import type { ManifestEntry } from "./manifest.js";
|
|
3
|
+
export interface PublishProject {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
liveUrl: string;
|
|
8
|
+
publishState: "never" | "live" | "unpublished" | "not_serving";
|
|
9
|
+
firstPublishedAt: string | null;
|
|
10
|
+
liveVersion: number | null;
|
|
11
|
+
highestVersion: number | null;
|
|
12
|
+
/** The LIVE version's publishedAt. Ledger-derived; it is why the checklist needs one call. */
|
|
13
|
+
livePublishedAt: string | null;
|
|
14
|
+
rolledBackFrom: number | null;
|
|
15
|
+
}
|
|
16
|
+
export interface UploadTarget {
|
|
17
|
+
path: string;
|
|
18
|
+
/** The presigned S3 PUT. THE CLI NEVER PARSES IT. */
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* The map the CLI MUST send verbatim. Content-Type and x-amz-checksum-sha256
|
|
22
|
+
* are both covered by the signature: dropping either yields
|
|
23
|
+
* SignatureDoesNotMatch, which reads as an auth bug and gets "fixed" by
|
|
24
|
+
* deleting the header — §2.7's contract traps 3 and 4.
|
|
25
|
+
*/
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface OpenedUpload {
|
|
29
|
+
uploadId: string;
|
|
30
|
+
uploads: UploadTarget[];
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CommitResult {
|
|
34
|
+
version: number;
|
|
35
|
+
liveVersion: number;
|
|
36
|
+
highestVersion: number;
|
|
37
|
+
liveUrl: string;
|
|
38
|
+
publishedAt: string;
|
|
39
|
+
/** Response-side name of the request's `note`. The asymmetry is deliberate. */
|
|
40
|
+
releaseNote?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ApiErrorDetail {
|
|
43
|
+
limit?: number;
|
|
44
|
+
observed?: number;
|
|
45
|
+
missing?: number;
|
|
46
|
+
mismatched?: number;
|
|
47
|
+
reason?: string;
|
|
48
|
+
path?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare class PublishApiError extends Error {
|
|
51
|
+
readonly status: number;
|
|
52
|
+
readonly code: string;
|
|
53
|
+
readonly detail: ApiErrorDetail;
|
|
54
|
+
readonly retryAfterSec?: number | undefined;
|
|
55
|
+
constructor(status: number, code: string, message: string, detail?: ApiErrorDetail, retryAfterSec?: number | undefined);
|
|
56
|
+
}
|
|
57
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
58
|
+
export declare function readProject(cfg: ResolvedConfig, projectId: string, fetchImpl?: typeof fetch): Promise<PublishProject>;
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openUpload(cfg: ResolvedConfig, projectId: string, files: ManifestEntry[], note: string | undefined, fetchImpl?: typeof fetch): Promise<OpenedUpload>;
|
|
65
|
+
export declare function commitUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<CommitResult>;
|
|
66
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
67
|
+
export declare function abandonUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The three-call publish transaction against the Go API, built against
|
|
2
|
+
// implementation.md §2.7's contract table — every wire field named canonically
|
|
3
|
+
// once, and the deliberate asymmetry (request `note`, response `releaseNote`)
|
|
4
|
+
// carried as written.
|
|
5
|
+
//
|
|
6
|
+
// The bytes do NOT transit the API. Open returns one presigned PUT per file,
|
|
7
|
+
// each signed for one exact key; the CLI PUTs direct to S3 and then commits.
|
|
8
|
+
export class PublishApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
detail;
|
|
12
|
+
retryAfterSec;
|
|
13
|
+
constructor(status, code, message, detail = {}, retryAfterSec) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
this.retryAfterSec = retryAfterSec;
|
|
19
|
+
this.name = "PublishApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readError(res) {
|
|
23
|
+
let body = {};
|
|
24
|
+
try {
|
|
25
|
+
body = (await res.json());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A non-JSON error body is still an error; the status carries the meaning.
|
|
29
|
+
}
|
|
30
|
+
const retryAfter = res.headers.get("retry-after");
|
|
31
|
+
return new PublishApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, body.detail ?? {}, retryAfter ? Number(retryAfter) : undefined);
|
|
32
|
+
}
|
|
33
|
+
function authHeaders(cfg) {
|
|
34
|
+
return { authorization: `Bearer ${cfg.pat}` };
|
|
35
|
+
}
|
|
36
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
37
|
+
export async function readProject(cfg, projectId, fetchImpl = fetch) {
|
|
38
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}`, {
|
|
39
|
+
headers: authHeaders(cfg),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw await readError(res);
|
|
43
|
+
const raw = (await res.json());
|
|
44
|
+
const num = (k) => typeof raw[k] === "number" ? raw[k] : null;
|
|
45
|
+
const str = (k) => typeof raw[k] === "string" ? raw[k] : null;
|
|
46
|
+
return {
|
|
47
|
+
id: String(raw.id ?? projectId),
|
|
48
|
+
slug: String(raw.slug ?? ""),
|
|
49
|
+
name: typeof raw.name === "string" ? raw.name : String(raw.slug ?? ""),
|
|
50
|
+
liveUrl: str("liveUrl") ?? "",
|
|
51
|
+
publishState: (str("publishState") ?? "never"),
|
|
52
|
+
firstPublishedAt: str("firstPublishedAt"),
|
|
53
|
+
liveVersion: num("liveVersion"),
|
|
54
|
+
highestVersion: num("highestVersion"),
|
|
55
|
+
livePublishedAt: str("livePublishedAt"),
|
|
56
|
+
rolledBackFrom: num("rolledBackFrom"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch) {
|
|
65
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { ...authHeaders(cfg), "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ files, ...(note ? { note } : {}) }),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
throw await readError(res);
|
|
72
|
+
const body = (await res.json());
|
|
73
|
+
if (!body.uploadId || !Array.isArray(body.uploads)) {
|
|
74
|
+
throw new PublishApiError(res.status, "malformed_response", "open upload: response missing uploadId/uploads");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
uploadId: body.uploadId,
|
|
78
|
+
uploads: body.uploads,
|
|
79
|
+
expiresAt: body.expiresAt ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function commitUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
83
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST", headers: authHeaders(cfg) });
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw await readError(res);
|
|
86
|
+
return (await res.json());
|
|
87
|
+
}
|
|
88
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
89
|
+
export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
90
|
+
try {
|
|
91
|
+
await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: authHeaders(cfg) });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Best-effort cleanup: the TTL reaps the row either way.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Manifest, ManifestEntry } from "./manifest.js";
|
|
2
|
+
export declare const MAX_TOTAL_BYTES = 524288000;
|
|
3
|
+
export declare const MAX_FILES = 5000;
|
|
4
|
+
export declare const MAX_FILE_BYTES = 104857600;
|
|
5
|
+
export type BoundKind = "total" | "count" | "file";
|
|
6
|
+
export interface BoundViolation {
|
|
7
|
+
kind: BoundKind;
|
|
8
|
+
observed: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
/** The one offending path, for the per-file case. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** The three largest files by size — the actual diagnosis for the total case. */
|
|
13
|
+
largest: ManifestEntry[];
|
|
14
|
+
}
|
|
15
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
16
|
+
export declare function largestFiles(files: ManifestEntry[], n?: number): ManifestEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
19
|
+
* files and exactly 100 MiB all pass.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkBounds(manifest: Manifest, limits?: {
|
|
22
|
+
total?: number;
|
|
23
|
+
count?: number;
|
|
24
|
+
file?: number;
|
|
25
|
+
}): BoundViolation | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The three bounds, checked LOCALLY before any request (implementation.md §2.5,
|
|
2
|
+
// Flow L28). The manifest is built locally and the limits are known locally, so
|
|
3
|
+
// the creator learns in the same second they would otherwise have started a
|
|
4
|
+
// ten-minute upload.
|
|
5
|
+
//
|
|
6
|
+
// These are the compiled-in defaults the API also carries. A divergence would
|
|
7
|
+
// mean the CLI's printed limit is off by one from the API's, which is why QA
|
|
8
|
+
// drives a manifest exactly AT each bound.
|
|
9
|
+
export const MAX_TOTAL_BYTES = 524_288_000; // 500 MiB
|
|
10
|
+
export const MAX_FILES = 5_000;
|
|
11
|
+
export const MAX_FILE_BYTES = 104_857_600; // 100 MiB
|
|
12
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
13
|
+
export function largestFiles(files, n = 3) {
|
|
14
|
+
return [...files].sort((a, b) => b.size - a.size).slice(0, n);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
18
|
+
* files and exactly 100 MiB all pass.
|
|
19
|
+
*/
|
|
20
|
+
export function checkBounds(manifest, limits = {}) {
|
|
21
|
+
const maxTotal = limits.total ?? MAX_TOTAL_BYTES;
|
|
22
|
+
const maxCount = limits.count ?? MAX_FILES;
|
|
23
|
+
const maxFile = limits.file ?? MAX_FILE_BYTES;
|
|
24
|
+
// Per-file first: one pathological object is a more specific diagnosis than
|
|
25
|
+
// the total it also blows, and naming the file is the whole remedy.
|
|
26
|
+
const oversize = manifest.files.find((f) => f.size > maxFile);
|
|
27
|
+
if (oversize) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "file",
|
|
30
|
+
observed: oversize.size,
|
|
31
|
+
limit: maxFile,
|
|
32
|
+
path: oversize.path,
|
|
33
|
+
largest: largestFiles(manifest.files),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (manifest.files.length > maxCount) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "count",
|
|
39
|
+
observed: manifest.files.length,
|
|
40
|
+
limit: maxCount,
|
|
41
|
+
largest: largestFiles(manifest.files),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (manifest.totalBytes > maxTotal) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "total",
|
|
47
|
+
observed: manifest.totalBytes,
|
|
48
|
+
limit: maxTotal,
|
|
49
|
+
largest: largestFiles(manifest.files),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type Tier = "present" | "missing" | "template-default" | "likely-default" | "dead-image";
|
|
2
|
+
/** The five tags the checklist reads. `twitter:card` is read but reported only through `social`. */
|
|
3
|
+
export type FieldKey = "title" | "description" | "image";
|
|
4
|
+
export interface Finding {
|
|
5
|
+
field: FieldKey;
|
|
6
|
+
tier: Tier;
|
|
7
|
+
/** The value found, if any. Both default tiers PRINT it — evidence is what makes them safe. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** The tag as it appears in the source, e.g. `<meta property="og:image">`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileFindings {
|
|
13
|
+
/** The dist-relative html path, e.g. `index.html`. */
|
|
14
|
+
file: string;
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
}
|
|
17
|
+
export interface ParsedMeta {
|
|
18
|
+
title?: string;
|
|
19
|
+
ogTitle?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
ogDescription?: string;
|
|
22
|
+
ogImage?: string;
|
|
23
|
+
ogUrl?: string;
|
|
24
|
+
twitterCard?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseMeta(html: string): ParsedMeta;
|
|
27
|
+
export interface ClassifyContext {
|
|
28
|
+
/** Every normalized path in the manifest — what makes the dead-og:image check structural. */
|
|
29
|
+
manifestPaths: ReadonlySet<string>;
|
|
30
|
+
/** `<slug>.<apex>`, for the og:url host heuristic. Absent skips that check. */
|
|
31
|
+
expectedHost?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* CLASSIFY, per 2A:
|
|
35
|
+
* absent -> MISSING
|
|
36
|
+
* value in the bundled corpus -> TEMPLATE-DEFAULT (high confidence)
|
|
37
|
+
* value matches a generic shape -> LIKELY-DEFAULT (low confidence)
|
|
38
|
+
* else -> PRESENT
|
|
39
|
+
*
|
|
40
|
+
* Plus the one structural certainty: an og:image naming a path that is not in the
|
|
41
|
+
* manifest is a dead social image, and the manifest either contains it or it does
|
|
42
|
+
* not.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classify(meta: ParsedMeta, ctx: ClassifyContext): Finding[];
|
|
45
|
+
/**
|
|
46
|
+
* The manifest path an og:image refers to, or undefined when it is an absolute
|
|
47
|
+
* URL on some other host — which the CLI cannot check and must not guess about.
|
|
48
|
+
*/
|
|
49
|
+
export declare function localManifestPath(value: string): string | undefined;
|
|
50
|
+
/** Every `.html` in the manifest, classified. Multiple files group under their filename. */
|
|
51
|
+
export declare function classifyDist(distDir: string, manifestPaths: ReadonlySet<string>, readFile: (abs: string) => Promise<string>, join: (dir: string, rel: string) => string, ctx?: Omit<ClassifyContext, "manifestPaths">): Promise<FileFindings[]>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The link-metadata read: parse every .html in the dist, classify what it finds,
|
|
2
|
+
// and report. Four tiers, three registers of copy (Flow L25).
|
|
3
|
+
//
|
|
4
|
+
// THE RESULT IS NEVER SENT. There is no wire field for a title, a description or
|
|
5
|
+
// an image URL, and that absence is B24's mechanism rather than a promise
|
|
6
|
+
// (D-Metadata-never-crosses-the-wire). This module returns findings to the
|
|
7
|
+
// terminal and to nothing else.
|
|
8
|
+
//
|
|
9
|
+
// The parse is regex-based on purpose: adding an HTML parser to the CLI would be
|
|
10
|
+
// a dependency on hostile-ish input for a report that never blocks, and the tags
|
|
11
|
+
// this reads are emitted by build tools in a shape a regex reads correctly.
|
|
12
|
+
import { FRAMEWORK_NAMES, TEMPLATE_DEFAULT_DESCRIPTIONS, TEMPLATE_DEFAULT_IMAGES, TEMPLATE_DEFAULT_TITLES, inCorpus, } from "./defaults.js";
|
|
13
|
+
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
|
|
14
|
+
/** A `<meta>` whose name/property matches `key`, in either attribute order. */
|
|
15
|
+
function metaContent(html, key) {
|
|
16
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
const forward = new RegExp(`<meta[^>]*\\b(?:name|property)\\s*=\\s*["']${escaped}["'][^>]*>`, "i");
|
|
18
|
+
const tag = forward.exec(html)?.[0];
|
|
19
|
+
if (!tag)
|
|
20
|
+
return undefined;
|
|
21
|
+
const content = /\bcontent\s*=\s*["']([\s\S]*?)["']/i.exec(tag)?.[1];
|
|
22
|
+
return content === undefined ? undefined : decodeEntities(content.trim());
|
|
23
|
+
}
|
|
24
|
+
function decodeEntities(s) {
|
|
25
|
+
return s
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/�?39;/g, "'")
|
|
30
|
+
.replace(/'/g, "'")
|
|
31
|
+
.replace(/&/g, "&");
|
|
32
|
+
}
|
|
33
|
+
export function parseMeta(html) {
|
|
34
|
+
const rawTitle = TITLE_RE.exec(html)?.[1];
|
|
35
|
+
return {
|
|
36
|
+
title: rawTitle === undefined ? undefined : decodeEntities(rawTitle.trim()),
|
|
37
|
+
ogTitle: metaContent(html, "og:title"),
|
|
38
|
+
description: metaContent(html, "description"),
|
|
39
|
+
ogDescription: metaContent(html, "og:description"),
|
|
40
|
+
ogImage: metaContent(html, "og:image"),
|
|
41
|
+
ogUrl: metaContent(html, "og:url"),
|
|
42
|
+
twitterCard: metaContent(html, "twitter:card"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* CLASSIFY, per 2A:
|
|
47
|
+
* absent -> MISSING
|
|
48
|
+
* value in the bundled corpus -> TEMPLATE-DEFAULT (high confidence)
|
|
49
|
+
* value matches a generic shape -> LIKELY-DEFAULT (low confidence)
|
|
50
|
+
* else -> PRESENT
|
|
51
|
+
*
|
|
52
|
+
* Plus the one structural certainty: an og:image naming a path that is not in the
|
|
53
|
+
* manifest is a dead social image, and the manifest either contains it or it does
|
|
54
|
+
* not.
|
|
55
|
+
*/
|
|
56
|
+
export function classify(meta, ctx) {
|
|
57
|
+
return [classifyTitle(meta), classifyDescription(meta), classifyImage(meta, ctx)];
|
|
58
|
+
}
|
|
59
|
+
function classifyTitle(meta) {
|
|
60
|
+
const value = meta.ogTitle ?? meta.title;
|
|
61
|
+
const tag = meta.ogTitle !== undefined ? `<meta property="og:title">` : "<title>";
|
|
62
|
+
if (value === undefined || value === "") {
|
|
63
|
+
return { field: "title", tier: "missing", tag: "<title>" };
|
|
64
|
+
}
|
|
65
|
+
if (inCorpus(value, TEMPLATE_DEFAULT_TITLES)) {
|
|
66
|
+
return { field: "title", tier: "template-default", value, tag };
|
|
67
|
+
}
|
|
68
|
+
// A title under 4 characters, or one that is exactly a framework name.
|
|
69
|
+
if (value.trim().length < 4 || FRAMEWORK_NAMES.includes(value.trim().toLowerCase())) {
|
|
70
|
+
return { field: "title", tier: "likely-default", value, tag };
|
|
71
|
+
}
|
|
72
|
+
return { field: "title", tier: "present", value, tag };
|
|
73
|
+
}
|
|
74
|
+
function classifyDescription(meta) {
|
|
75
|
+
const value = meta.description ?? meta.ogDescription;
|
|
76
|
+
const tag = meta.description !== undefined
|
|
77
|
+
? `<meta name="description">`
|
|
78
|
+
: `<meta property="og:description">`;
|
|
79
|
+
if (value === undefined || value === "") {
|
|
80
|
+
return { field: "description", tier: "missing", tag: `<meta name="description">` };
|
|
81
|
+
}
|
|
82
|
+
if (inCorpus(value, TEMPLATE_DEFAULT_DESCRIPTIONS)) {
|
|
83
|
+
return { field: "description", tier: "template-default", value, tag };
|
|
84
|
+
}
|
|
85
|
+
if (value.trim().length < 16) {
|
|
86
|
+
return { field: "description", tier: "likely-default", value, tag };
|
|
87
|
+
}
|
|
88
|
+
return { field: "description", tier: "present", value, tag };
|
|
89
|
+
}
|
|
90
|
+
function classifyImage(meta, ctx) {
|
|
91
|
+
const tag = `<meta property="og:image">`;
|
|
92
|
+
const value = meta.ogImage;
|
|
93
|
+
if (value === undefined || value === "") {
|
|
94
|
+
return { field: "image", tier: "missing", tag };
|
|
95
|
+
}
|
|
96
|
+
// The strongest single finding in the checklist, and purely structural: a
|
|
97
|
+
// social image that 404s, on a project that never touched the template.
|
|
98
|
+
const local = localManifestPath(value);
|
|
99
|
+
if (local !== undefined && !ctx.manifestPaths.has(local)) {
|
|
100
|
+
return { field: "image", tier: "dead-image", value, tag };
|
|
101
|
+
}
|
|
102
|
+
if (inCorpus(value, TEMPLATE_DEFAULT_IMAGES) && local !== undefined) {
|
|
103
|
+
return { field: "image", tier: "template-default", value, tag };
|
|
104
|
+
}
|
|
105
|
+
return { field: "image", tier: "present", value, tag };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The manifest path an og:image refers to, or undefined when it is an absolute
|
|
109
|
+
* URL on some other host — which the CLI cannot check and must not guess about.
|
|
110
|
+
*/
|
|
111
|
+
export function localManifestPath(value) {
|
|
112
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.startsWith("//"))
|
|
113
|
+
return undefined;
|
|
114
|
+
const withoutQuery = value.split(/[?#]/)[0];
|
|
115
|
+
const trimmed = withoutQuery.replace(/^\.?\//, "");
|
|
116
|
+
return trimmed === "" ? undefined : trimmed;
|
|
117
|
+
}
|
|
118
|
+
/** Every `.html` in the manifest, classified. Multiple files group under their filename. */
|
|
119
|
+
export async function classifyDist(distDir, manifestPaths, readFile, join, ctx = {}) {
|
|
120
|
+
const htmlPaths = [...manifestPaths].filter((p) => /\.html?$/i.test(p)).sort();
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const rel of htmlPaths) {
|
|
123
|
+
let html;
|
|
124
|
+
try {
|
|
125
|
+
html = await readFile(join(distDir, rel));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// An unreadable html file is not a finding about the creator's metadata;
|
|
129
|
+
// the checklist reports what it could read and blocks nothing.
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push({
|
|
133
|
+
file: rel,
|
|
134
|
+
findings: classify(parseMeta(html), { ...ctx, manifestPaths }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
export interface PublishDeps {
|
|
3
|
+
print: (line: string) => void;
|
|
4
|
+
/** Raw write, for the redrawn progress line only. */
|
|
5
|
+
write: (s: string) => void;
|
|
6
|
+
/** Resolve the confirmation. `defaultYes` decides what a bare Enter means. */
|
|
7
|
+
confirm: (prompt: string, defaultYes: boolean) => Promise<boolean>;
|
|
8
|
+
cwd: string;
|
|
9
|
+
config: () => Promise<ResolvedConfig>;
|
|
10
|
+
binding: () => Promise<{
|
|
11
|
+
projectId: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
} | undefined>;
|
|
14
|
+
/** Absent means not signed in — the gate is LOCAL and nothing is attempted. */
|
|
15
|
+
token: () => Promise<string | undefined>;
|
|
16
|
+
isTTY: boolean;
|
|
17
|
+
now: () => number;
|
|
18
|
+
sleep: (ms: number) => Promise<void>;
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
orgName?: () => Promise<string>;
|
|
21
|
+
}
|
|
22
|
+
export interface PublishOptions {
|
|
23
|
+
distDir?: string;
|
|
24
|
+
note?: string;
|
|
25
|
+
yes?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function publishCommand(deps: PublishDeps, opts?: PublishOptions): Promise<number>;
|