@anyday/sanity-workspace-manager 0.5.0 → 0.7.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 +146 -96
- package/config.schema.json +157 -0
- package/dist/cli.mjs +1024 -182
- package/package.json +3 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "node:fs";
|
|
5
|
+
import { join as join4, resolve } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
8
|
|
|
@@ -174,6 +174,10 @@ var workspace_manager_schema_default = {
|
|
|
174
174
|
$schema: {
|
|
175
175
|
type: "string"
|
|
176
176
|
},
|
|
177
|
+
$generated: {
|
|
178
|
+
description: "Set by the CLI's generate: a note and a hash that check uses to spot hand edits.",
|
|
179
|
+
type: "string"
|
|
180
|
+
},
|
|
177
181
|
id: {
|
|
178
182
|
description: "Stable id stored on jobs and used as the Vercel project prefix.",
|
|
179
183
|
type: "string",
|
|
@@ -229,6 +233,10 @@ var workspace_manager_schema_default = {
|
|
|
229
233
|
},
|
|
230
234
|
baseUrl: {
|
|
231
235
|
$ref: "#/definitions/name"
|
|
236
|
+
},
|
|
237
|
+
workspacesApiUrl: {
|
|
238
|
+
description: "Set to the Workspaces API's address, so the site's own /studio lists every workspace.",
|
|
239
|
+
$ref: "#/definitions/name"
|
|
232
240
|
}
|
|
233
241
|
}
|
|
234
242
|
},
|
|
@@ -452,7 +460,7 @@ function humanize(name) {
|
|
|
452
460
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
453
461
|
}
|
|
454
462
|
|
|
455
|
-
// src/
|
|
463
|
+
// src/generate/extract.ts
|
|
456
464
|
var PLUGIN_TYPES = /^(sanity\.|system\.|assist\.|media\.tag$|translation\.metadata$|fieldTranslation\.metadata$)/;
|
|
457
465
|
var Extract = class {
|
|
458
466
|
byName;
|
|
@@ -519,7 +527,8 @@ var Extract = class {
|
|
|
519
527
|
}
|
|
520
528
|
};
|
|
521
529
|
|
|
522
|
-
// src/
|
|
530
|
+
// src/generate/draft.ts
|
|
531
|
+
var SETTINGS_ROLES = ["siteName", "domain", "logo", "favicon"];
|
|
523
532
|
var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["_rev", "_createdAt", "_updatedAt", "_system"]);
|
|
524
533
|
var ENV_DEFAULTS = {
|
|
525
534
|
projectId: "NEXT_PUBLIC_SANITY_PROJECT_ID",
|
|
@@ -540,21 +549,33 @@ function suggestNames(repo) {
|
|
|
540
549
|
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
541
550
|
return { id: id || "site", title: humanize(name.replace(/[-_]+/g, " ").trim()) || "Site" };
|
|
542
551
|
}
|
|
543
|
-
|
|
552
|
+
var guessSingletons = (schema) => schema.contentDocuments.map((type) => type.name).filter((name) => /settings$/i.test(name));
|
|
553
|
+
function sectionCandidates(schema, singletons) {
|
|
554
|
+
const setupTypes = draftLanguages(schema, () => {
|
|
555
|
+
})?.setupTypes ?? [];
|
|
556
|
+
return schema.contentDocuments.map((type) => type.name).filter((name) => !singletons.includes(name) && !setupTypes.includes(name));
|
|
557
|
+
}
|
|
558
|
+
function draftManifest(repo, answers, live = {}, choices) {
|
|
544
559
|
const notes = [];
|
|
545
560
|
const note = (message) => void notes.push(message);
|
|
546
561
|
const schema = new Extract(repo.types);
|
|
547
|
-
const singletons =
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
562
|
+
const singletons = choices?.singletons ?? guessSingletons(schema);
|
|
563
|
+
const homePage = choices?.homePage ?? findHomePage(schema, singletons);
|
|
564
|
+
if (!choices) {
|
|
565
|
+
if (singletons.length)
|
|
566
|
+
note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
|
|
567
|
+
else note("No singletons found: list your settings documents in content.singletons");
|
|
568
|
+
if (homePage) note(`Home page: ${homePage.documentId}.${homePage.field}`);
|
|
569
|
+
else note("No home page reference found: set content.homePage by hand");
|
|
570
|
+
}
|
|
554
571
|
const home = homePage ?? { documentId: singletons[0] ?? "siteSettings", field: "homePage" };
|
|
555
572
|
const languages = draftLanguages(schema, note);
|
|
556
|
-
const sections = schema
|
|
557
|
-
|
|
573
|
+
const sections = sectionCandidates(schema, singletons).map((name) => [
|
|
574
|
+
name,
|
|
575
|
+
humanize(name)
|
|
576
|
+
]);
|
|
577
|
+
if (!choices)
|
|
578
|
+
note(`Copy sections list every content type (${sections.length}): remove the ones not to offer`);
|
|
558
579
|
const siteEnv = Object.fromEntries(
|
|
559
580
|
Object.entries(ENV_PATTERNS).map(([key, pattern]) => {
|
|
560
581
|
const matches = repo.envNames.filter((name) => pattern.test(name));
|
|
@@ -566,6 +587,9 @@ function draftManifest(repo, answers, live = {}) {
|
|
|
566
587
|
return [key, found ?? ENV_DEFAULTS[key]];
|
|
567
588
|
})
|
|
568
589
|
);
|
|
590
|
+
const apiUrlNames = repo.envNames.filter((name) => /WORKSPACES_API_URL$/.test(name));
|
|
591
|
+
const apiUrl = apiUrlNames.find((name) => name.startsWith("NEXT_PUBLIC_")) ?? apiUrlNames[0];
|
|
592
|
+
if (apiUrl) siteEnv.workspacesApiUrl = apiUrl;
|
|
569
593
|
if (!repo.repo) note("No GitHub remote found: set site.repo to owner/name");
|
|
570
594
|
if (!repo.hasRevalidateRoute) note("No /api/revalidate route found: check site.revalidatePath");
|
|
571
595
|
if (answers.vercelTeamId)
|
|
@@ -593,13 +617,40 @@ function draftManifest(repo, answers, live = {}) {
|
|
|
593
617
|
sections,
|
|
594
618
|
singletons,
|
|
595
619
|
homePage: home,
|
|
596
|
-
starter: draftStarter(schema, home, live, note)
|
|
620
|
+
starter: draftStarter(schema, home, live, note, Boolean(choices))
|
|
597
621
|
},
|
|
598
|
-
brand: draftBrand(
|
|
622
|
+
brand: draftBrand(
|
|
623
|
+
schema,
|
|
624
|
+
home,
|
|
625
|
+
live,
|
|
626
|
+
note,
|
|
627
|
+
choices?.settingsFields ?? guessSettingsFields(schema, home.documentId),
|
|
628
|
+
Boolean(choices)
|
|
629
|
+
)
|
|
599
630
|
},
|
|
600
631
|
notes
|
|
601
632
|
};
|
|
602
633
|
}
|
|
634
|
+
function fitsRole(schema, documentId, role, field) {
|
|
635
|
+
const value = schema.fields(documentId)[field]?.value;
|
|
636
|
+
if (!value) return false;
|
|
637
|
+
if (role === "logo" || role === "favicon") return schema.isImage(value);
|
|
638
|
+
if (role === "siteName")
|
|
639
|
+
return value.type === "string" || schema.inlineName(value) === "internationalizedArrayString";
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
var SITE_NAME_FIELDS = ["siteName", "siteTitle", "title", "name"];
|
|
643
|
+
function guessSettingsFields(schema, documentId) {
|
|
644
|
+
return Object.fromEntries(
|
|
645
|
+
SETTINGS_ROLES.map((role) => {
|
|
646
|
+
const names = role === "siteName" ? SITE_NAME_FIELDS : [role];
|
|
647
|
+
return [role, names.find((name) => fitsRole(schema, documentId, role, name)) ?? null];
|
|
648
|
+
})
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
var themeTypeOf = (schema) => schema.contentDocuments.find((type) => /theme/i.test(type.name))?.name ?? null;
|
|
652
|
+
var colorRolesOf = (schema, themeId) => Object.entries(schema.fields(themeId)).filter(([, { value }]) => schema.colorShape(value)).map(([role]) => role);
|
|
653
|
+
var fontFieldsOf = (schema, themeId) => Object.entries(schema.fields(themeId)).filter(([, { value }]) => schema.fontShape(value)).map(([field]) => field);
|
|
603
654
|
function findHomePage(schema, singletons) {
|
|
604
655
|
for (const documentId of singletons) {
|
|
605
656
|
const fields = schema.fields(documentId);
|
|
@@ -638,17 +689,17 @@ function draftLanguages(schema, note) {
|
|
|
638
689
|
note(`Language documents: ${type.name}, ids like ${document._id}; check the id pattern`);
|
|
639
690
|
return { document, setupTypes };
|
|
640
691
|
}
|
|
641
|
-
function draftStarter(schema, home, live, note) {
|
|
692
|
+
function draftStarter(schema, home, live, note, quiet = false) {
|
|
642
693
|
if (live.starter?.length) {
|
|
643
694
|
const siteName = siteNameOf(live.settings);
|
|
644
695
|
const language = live.starter.find((doc) => typeof doc.language === "string")?.language;
|
|
645
696
|
const ids = new Set(live.starter.map((doc) => doc._id));
|
|
646
|
-
const
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
);
|
|
697
|
+
const dropped = [];
|
|
698
|
+
const documents = live.starter.map(
|
|
699
|
+
(doc) => placeholders(dropOutsideLinks(stripSystem(doc), ids, dropped), siteName, language)
|
|
700
|
+
);
|
|
701
|
+
if (dropped.length)
|
|
702
|
+
note(`Starter: removed what links outside the picked pages: ${dropped.join(", ")}`);
|
|
652
703
|
const liveHome = live.settings?.[home.field]?._ref;
|
|
653
704
|
const homeId = liveHome && ids.has(liveHome) ? liveHome : live.starter[0]._id;
|
|
654
705
|
note(`Starter: ${documents.length} documents from the live project, home ${homeId}`);
|
|
@@ -660,15 +711,15 @@ function draftStarter(schema, home, live, note) {
|
|
|
660
711
|
const page = { _id: "starter-home", _type: type };
|
|
661
712
|
if ("title" in fields) page.title = "{{siteName}}";
|
|
662
713
|
if ("language" in fields) page.language = "{{language}}";
|
|
663
|
-
note("Starter is one empty home page: run with --live to pick real pages instead");
|
|
714
|
+
if (!quiet) note("Starter is one empty home page: run with --live to pick real pages instead");
|
|
664
715
|
return { homeId: "starter-home", documents: [page] };
|
|
665
716
|
}
|
|
666
|
-
function draftBrand(schema, home, live, note) {
|
|
717
|
+
function draftBrand(schema, home, live, note, roles, quiet = false) {
|
|
667
718
|
const settingsFields = schema.fields(home.documentId);
|
|
668
719
|
const fields = {};
|
|
669
|
-
const siteName = settingsFields.siteName;
|
|
720
|
+
const siteName = roles.siteName ? settingsFields[roles.siteName] : void 0;
|
|
670
721
|
if (siteName) {
|
|
671
|
-
fields.siteName = schema.inlineName(siteName.value) === "internationalizedArrayString" ? [
|
|
722
|
+
fields[roles.siteName] = schema.inlineName(siteName.value) === "internationalizedArrayString" ? [
|
|
672
723
|
{
|
|
673
724
|
_key: "{{language}}",
|
|
674
725
|
_type: "internationalizedArrayStringValue",
|
|
@@ -678,21 +729,22 @@ function draftBrand(schema, home, live, note) {
|
|
|
678
729
|
] : "{{siteName}}";
|
|
679
730
|
}
|
|
680
731
|
fields[home.field] = { _type: "reference", _ref: "{{homePageId}}" };
|
|
681
|
-
if (settingsFields.domain) fields.domain = "{{domain}}";
|
|
682
|
-
for (const [
|
|
732
|
+
if (roles.domain && settingsFields[roles.domain]) fields[roles.domain] = "{{domain}}";
|
|
733
|
+
for (const [role, id] of [
|
|
683
734
|
["logo", "logoAssetId"],
|
|
684
735
|
["favicon", "faviconAssetId"]
|
|
685
736
|
]) {
|
|
686
|
-
const
|
|
687
|
-
|
|
737
|
+
const field = roles[role];
|
|
738
|
+
const value = field ? settingsFields[field]?.value : void 0;
|
|
739
|
+
if (!field || !value || !schema.isImage(value)) continue;
|
|
688
740
|
fields[field] = {
|
|
689
741
|
_type: "image",
|
|
690
742
|
asset: { _type: "reference", _ref: `{{${id}}}` },
|
|
691
|
-
...
|
|
743
|
+
...role === "logo" && "alt" in (value.attributes ?? {}) ? { alt: "{{siteName}}" } : {}
|
|
692
744
|
};
|
|
693
745
|
}
|
|
694
|
-
const themeType = schema
|
|
695
|
-
const themeId = themeType
|
|
746
|
+
const themeType = themeTypeOf(schema);
|
|
747
|
+
const themeId = themeType ?? "themeSettings";
|
|
696
748
|
if (!themeType) note("No theme document found: brand colours and fonts left empty");
|
|
697
749
|
const themeFields = schema.fields(themeId);
|
|
698
750
|
let color = { $color: "{{hex}}" };
|
|
@@ -716,7 +768,7 @@ function draftBrand(schema, home, live, note) {
|
|
|
716
768
|
label: typeof current?.label === "string" ? current.label : humanize(role)
|
|
717
769
|
});
|
|
718
770
|
}
|
|
719
|
-
if (colorRoles.length && !live.theme)
|
|
771
|
+
if (colorRoles.length && !live.theme && !quiet)
|
|
720
772
|
note("Colour defaults are #000000: run with --live to read them");
|
|
721
773
|
const fontShape = Object.values(themeFields).map(({ value }) => schema.fontShape(value)).find(Boolean);
|
|
722
774
|
const fonts = fontShape ? {
|
|
@@ -734,7 +786,8 @@ function draftBrand(schema, home, live, note) {
|
|
|
734
786
|
const defaults = live.theme ? compactColors(stripSystem(live.theme)) : {};
|
|
735
787
|
delete defaults._id;
|
|
736
788
|
delete defaults._type;
|
|
737
|
-
if (!live.theme
|
|
789
|
+
if (!live.theme && !quiet)
|
|
790
|
+
note("Theme defaults are empty: run with --live to copy the current theme");
|
|
738
791
|
const linked = Object.keys(defaults).filter((field) => hasReference(defaults[field]));
|
|
739
792
|
for (const field of linked) delete defaults[field];
|
|
740
793
|
if (linked.length) {
|
|
@@ -772,18 +825,41 @@ function placeholders(value, siteName, language, key, parent) {
|
|
|
772
825
|
}
|
|
773
826
|
return value;
|
|
774
827
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
828
|
+
var hasReference = (value) => JSON.stringify(value ?? null).includes('"_ref":');
|
|
829
|
+
var linksOutside = (value, ids) => references(value).some((ref) => !ids.has(ref));
|
|
830
|
+
function dropOutsideLinks(doc, ids, dropped) {
|
|
831
|
+
const walk = (value, path) => {
|
|
832
|
+
if (Array.isArray(value)) {
|
|
833
|
+
return value.flatMap((item, index) => {
|
|
834
|
+
const at = `${path}[${item?._key ?? index}]`;
|
|
835
|
+
if (!linksOutside(item, ids) || hasArray(item)) return [walk(item, at)];
|
|
836
|
+
dropped.push(`${doc._id} ${at}`);
|
|
837
|
+
return [];
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
if (!value || typeof value !== "object") return value;
|
|
841
|
+
const result = {};
|
|
842
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
843
|
+
if (!Array.isArray(inner) && linksOutside(inner, ids) && !hasArray(inner)) {
|
|
844
|
+
dropped.push(`${doc._id} ${path ? `${path}.` : ""}${key}`);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
result[key] = walk(inner, path ? `${path}.${key}` : key);
|
|
848
|
+
}
|
|
849
|
+
return dropDanglingMarks(result);
|
|
850
|
+
};
|
|
851
|
+
return walk(doc, "");
|
|
852
|
+
}
|
|
853
|
+
var hasArray = (value) => Boolean(value && typeof value === "object") && Object.values(value).some((inner) => Array.isArray(inner) || hasArray(inner));
|
|
854
|
+
function dropDanglingMarks(block) {
|
|
855
|
+
if (!Array.isArray(block.markDefs) || !Array.isArray(block.children)) return block;
|
|
856
|
+
const defs = new Set(block.markDefs.map((def) => def._key));
|
|
857
|
+
const decorator = (mark) => /^[a-z][a-z-]*$/i.test(mark) && mark.length < 12;
|
|
858
|
+
block.children = block.children.map(
|
|
859
|
+
(child) => Array.isArray(child.marks) ? { ...child, marks: child.marks.filter((mark) => defs.has(mark) || decorator(mark)) } : child
|
|
780
860
|
);
|
|
781
|
-
|
|
782
|
-
if (kept.length)
|
|
783
|
-
draft.notes.push(`Kept from the existing manifest's theme defaults: ${kept.join(", ")}`);
|
|
784
|
-
return kept;
|
|
861
|
+
return block;
|
|
785
862
|
}
|
|
786
|
-
var hasReference = (value) => JSON.stringify(value ?? null).includes('"_ref":');
|
|
787
863
|
function references(value) {
|
|
788
864
|
if (Array.isArray(value)) return value.flatMap(references);
|
|
789
865
|
if (value && typeof value === "object") {
|
|
@@ -803,16 +879,603 @@ function compactColors(value) {
|
|
|
803
879
|
return value;
|
|
804
880
|
}
|
|
805
881
|
|
|
806
|
-
// src/
|
|
882
|
+
// src/generate/build.ts
|
|
883
|
+
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["_id", "_type", "_key"]);
|
|
884
|
+
function buildManifest(input) {
|
|
885
|
+
const { repo, config, live, previous } = input;
|
|
886
|
+
const schema = new Extract(repo.types);
|
|
887
|
+
const draft = draftManifest(
|
|
888
|
+
repo,
|
|
889
|
+
{
|
|
890
|
+
id: config.id,
|
|
891
|
+
title: config.title,
|
|
892
|
+
organizationId: config.organizationId,
|
|
893
|
+
...config.vercelTeamId ? { vercelTeamId: config.vercelTeamId } : {},
|
|
894
|
+
schemaSourceProjectId: config.schemaSourceProjectId,
|
|
895
|
+
...config.studioOrigins ? { studioOrigins: config.studioOrigins } : {}
|
|
896
|
+
},
|
|
897
|
+
live ?? {},
|
|
898
|
+
{
|
|
899
|
+
singletons: config.singletons,
|
|
900
|
+
homePage: config.homePage,
|
|
901
|
+
settingsFields: config.settingsFields
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
const kept = new Set(config.themeFiles ?? []);
|
|
905
|
+
const notes = draft.notes.filter((text) => !/studioOrigins|^Studio: |No GitHub remote/.test(text)).flatMap((text) => {
|
|
906
|
+
const [lead, list] = text.split(/: (?=[^:]+$)/);
|
|
907
|
+
if (!lead.startsWith("Left out of theme defaults") || !list) return [text];
|
|
908
|
+
const left = list.split(", ").filter((field) => !kept.has(field));
|
|
909
|
+
return left.length ? [`${lead}: ${left.join(", ")}`] : [];
|
|
910
|
+
});
|
|
911
|
+
const note = (message) => void notes.push(message);
|
|
912
|
+
const { manifest } = draft;
|
|
913
|
+
manifest.site = { ...manifest.site, ...config.site };
|
|
914
|
+
manifest.content.sections = Object.entries(config.sections).filter(
|
|
915
|
+
(entry) => entry[1] !== false
|
|
916
|
+
);
|
|
917
|
+
const theme = manifest.brand.theme;
|
|
918
|
+
const previousRoles = new Map(previous?.brand.colorRoles.map((role) => [role.role, role]));
|
|
919
|
+
for (const role of manifest.brand.colorRoles) {
|
|
920
|
+
role.description = config.colorRoles[role.role] ?? "";
|
|
921
|
+
const before = previousRoles.get(role.role);
|
|
922
|
+
if (!live?.theme && before) Object.assign(role, { default: before.default, label: before.label });
|
|
923
|
+
}
|
|
924
|
+
const themeFields = schema.fields(themeTypeOf(schema) ?? theme.documentId);
|
|
925
|
+
if (live?.theme) {
|
|
926
|
+
for (const role of manifest.brand.colorRoles) delete theme.defaults[role.role];
|
|
927
|
+
for (const field of config.themeFiles ?? [])
|
|
928
|
+
if (live.theme[field] !== void 0) theme.defaults[field] = live.theme[field];
|
|
929
|
+
} else if (previous) {
|
|
930
|
+
theme.defaults = { ...previous.brand.theme.defaults };
|
|
931
|
+
}
|
|
932
|
+
for (const field of Object.keys(theme.defaults).filter((field2) => !(field2 in themeFields))) {
|
|
933
|
+
delete theme.defaults[field];
|
|
934
|
+
note(`Theme default ${field} is gone from the schema: dropped`);
|
|
935
|
+
}
|
|
936
|
+
if (config.starter?.length !== 0 && !live) {
|
|
937
|
+
if (previous) manifest.content.starter = previous.content.starter;
|
|
938
|
+
else note("Starter is one empty home page until generate can read the live pages");
|
|
939
|
+
}
|
|
940
|
+
manifest.content.starter = fitStarter(schema, manifest.content.starter, note);
|
|
941
|
+
if (!live) {
|
|
942
|
+
if (!previousRoles.size && manifest.brand.colorRoles.length)
|
|
943
|
+
note("Colour defaults are #000000: sign in to Sanity and run generate again");
|
|
944
|
+
if (!previous) note("Theme defaults are empty: sign in to Sanity and run generate again");
|
|
945
|
+
}
|
|
946
|
+
return { manifest, notes };
|
|
947
|
+
}
|
|
948
|
+
function fitStarter(schema, starter, note) {
|
|
949
|
+
const documents = starter.documents.flatMap((value) => {
|
|
950
|
+
const doc = value;
|
|
951
|
+
if (!schema.has(doc._type)) {
|
|
952
|
+
if (doc._id === starter.homeId)
|
|
953
|
+
throw new Error(
|
|
954
|
+
`The starter home page's type ${doc._type} is gone: pick starter pages again`
|
|
955
|
+
);
|
|
956
|
+
note(`Starter ${doc._id} dropped: type ${doc._type} is gone from the schema`);
|
|
957
|
+
return [];
|
|
958
|
+
}
|
|
959
|
+
const fields = schema.fields(doc._type);
|
|
960
|
+
const gone = Object.keys(doc).filter((key) => !BUILT_IN_FIELDS.has(key) && !(key in fields));
|
|
961
|
+
if (gone.length) note(`Starter ${doc._id}: dropped ${gone.join(", ")}, gone from ${doc._type}`);
|
|
962
|
+
return [Object.fromEntries(Object.entries(doc).filter(([key]) => !gone.includes(key)))];
|
|
963
|
+
});
|
|
964
|
+
return { ...starter, documents };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/generate/config.ts
|
|
807
968
|
import { existsSync, readFileSync } from "node:fs";
|
|
969
|
+
import { Ajv as Ajv2 } from "ajv";
|
|
970
|
+
|
|
971
|
+
// config.schema.json
|
|
972
|
+
var config_schema_default = {
|
|
973
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
974
|
+
$id: "https://github.com/anydayagency/anyday-workspace-manager-sanity/workspace-manager.config.schema.json",
|
|
975
|
+
title: "Workspace manager config",
|
|
976
|
+
description: "The decisions behind a site's workspace-manager.json. Edit this file, then run `sanity-workspace-manager generate`.",
|
|
977
|
+
type: "object",
|
|
978
|
+
additionalProperties: false,
|
|
979
|
+
required: [
|
|
980
|
+
"id",
|
|
981
|
+
"title",
|
|
982
|
+
"organizationId",
|
|
983
|
+
"schemaSourceProjectId",
|
|
984
|
+
"site",
|
|
985
|
+
"singletons",
|
|
986
|
+
"homePage",
|
|
987
|
+
"settingsFields",
|
|
988
|
+
"sections",
|
|
989
|
+
"colorRoles"
|
|
990
|
+
],
|
|
991
|
+
definitions: {
|
|
992
|
+
nullableField: {
|
|
993
|
+
type: ["string", "null"]
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
properties: {
|
|
997
|
+
$schema: {
|
|
998
|
+
type: "string"
|
|
999
|
+
},
|
|
1000
|
+
id: {
|
|
1001
|
+
description: "Permanent: jobs store it and it prefixes each site's Vercel project.",
|
|
1002
|
+
type: "string",
|
|
1003
|
+
pattern: "^[a-z0-9][a-z0-9-]{0,31}$"
|
|
1004
|
+
},
|
|
1005
|
+
title: {
|
|
1006
|
+
description: "The name shown in the Workspaces app.",
|
|
1007
|
+
type: "string"
|
|
1008
|
+
},
|
|
1009
|
+
organizationId: {
|
|
1010
|
+
description: "The Sanity organisation new workspaces are created in.",
|
|
1011
|
+
type: "string"
|
|
1012
|
+
},
|
|
1013
|
+
vercelTeamId: {
|
|
1014
|
+
description: "The client's own Vercel team; leave out for Anyday's.",
|
|
1015
|
+
type: "string"
|
|
1016
|
+
},
|
|
1017
|
+
schemaSourceProjectId: {
|
|
1018
|
+
description: "The Sanity project new workspaces copy their schema from.",
|
|
1019
|
+
type: "string"
|
|
1020
|
+
},
|
|
1021
|
+
dataset: {
|
|
1022
|
+
description: "The dataset live values are read from. Default: production.",
|
|
1023
|
+
type: "string"
|
|
1024
|
+
},
|
|
1025
|
+
site: {
|
|
1026
|
+
type: "object",
|
|
1027
|
+
additionalProperties: false,
|
|
1028
|
+
required: ["repo", "branch"],
|
|
1029
|
+
properties: {
|
|
1030
|
+
repo: {
|
|
1031
|
+
description: "owner/name on GitHub.",
|
|
1032
|
+
type: "string",
|
|
1033
|
+
pattern: "^[\\w.-]+/[\\w.-]+$"
|
|
1034
|
+
},
|
|
1035
|
+
branch: {
|
|
1036
|
+
type: "string"
|
|
1037
|
+
},
|
|
1038
|
+
revalidatePath: {
|
|
1039
|
+
type: "string"
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
studioOrigins: {
|
|
1044
|
+
description: "The Studios that open new workspaces, e.g. https://acme.sanity.studio.",
|
|
1045
|
+
type: "array",
|
|
1046
|
+
items: {
|
|
1047
|
+
type: "string",
|
|
1048
|
+
pattern: "^https://"
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
singletons: {
|
|
1052
|
+
description: "Settings documents: one each, never offered for copy.",
|
|
1053
|
+
type: "array",
|
|
1054
|
+
items: {
|
|
1055
|
+
type: "string"
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
homePage: {
|
|
1059
|
+
description: "The settings field that references the home page.",
|
|
1060
|
+
type: "object",
|
|
1061
|
+
additionalProperties: false,
|
|
1062
|
+
required: ["documentId", "field"],
|
|
1063
|
+
properties: {
|
|
1064
|
+
documentId: {
|
|
1065
|
+
type: "string"
|
|
1066
|
+
},
|
|
1067
|
+
field: {
|
|
1068
|
+
type: "string"
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
settingsFields: {
|
|
1073
|
+
description: "The field on the home page's settings document that holds each brand value; null for none.",
|
|
1074
|
+
type: "object",
|
|
1075
|
+
additionalProperties: false,
|
|
1076
|
+
properties: {
|
|
1077
|
+
siteName: {
|
|
1078
|
+
$ref: "#/definitions/nullableField"
|
|
1079
|
+
},
|
|
1080
|
+
domain: {
|
|
1081
|
+
$ref: "#/definitions/nullableField"
|
|
1082
|
+
},
|
|
1083
|
+
logo: {
|
|
1084
|
+
$ref: "#/definitions/nullableField"
|
|
1085
|
+
},
|
|
1086
|
+
favicon: {
|
|
1087
|
+
$ref: "#/definitions/nullableField"
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
},
|
|
1091
|
+
sections: {
|
|
1092
|
+
description: "Each content type, with its label in the Copy picker, or false to leave it out.",
|
|
1093
|
+
type: "object",
|
|
1094
|
+
additionalProperties: {
|
|
1095
|
+
anyOf: [
|
|
1096
|
+
{
|
|
1097
|
+
type: "string",
|
|
1098
|
+
minLength: 1
|
|
1099
|
+
},
|
|
1100
|
+
{
|
|
1101
|
+
const: false
|
|
1102
|
+
}
|
|
1103
|
+
]
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
colorRoles: {
|
|
1107
|
+
description: "Each colour role on the theme, with where it is used, shown to whoever sets up a brand.",
|
|
1108
|
+
type: "object",
|
|
1109
|
+
additionalProperties: {
|
|
1110
|
+
type: "string"
|
|
1111
|
+
}
|
|
1112
|
+
},
|
|
1113
|
+
themeFiles: {
|
|
1114
|
+
description: "Theme fields with uploaded files, e.g. fonts, to copy into new sites. Fonts are often licensed per brand.",
|
|
1115
|
+
type: "array",
|
|
1116
|
+
items: {
|
|
1117
|
+
type: "string"
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
starter: {
|
|
1121
|
+
description: "What Start fresh writes: live pages (`home`, slugs or ids), or [] for one empty home page.",
|
|
1122
|
+
type: "array",
|
|
1123
|
+
items: {
|
|
1124
|
+
type: "string"
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
// src/generate/config.ts
|
|
1131
|
+
var CONFIG_FILE = "workspace-manager.config.json";
|
|
1132
|
+
var MANIFEST_FILE = "workspace-manager.json";
|
|
1133
|
+
var SCHEMA_REF = "./node_modules/@anyday/sanity-workspace-manager/config.schema.json";
|
|
1134
|
+
var validate2 = new Ajv2({ allErrors: true }).compile(config_schema_default);
|
|
1135
|
+
function parseConfig(json) {
|
|
1136
|
+
if (validate2(json)) return json;
|
|
1137
|
+
const problems = validate2.errors.map((error) => `${error.instancePath || "/"} ${error.message}`);
|
|
1138
|
+
throw new Error(`Invalid ${CONFIG_FILE}:
|
|
1139
|
+
${problems.join("\n")}`);
|
|
1140
|
+
}
|
|
1141
|
+
function readJsonFile(path) {
|
|
1142
|
+
if (!existsSync(path)) return null;
|
|
1143
|
+
try {
|
|
1144
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1145
|
+
} catch {
|
|
1146
|
+
throw new Error(`${path} isn't valid JSON`);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
function formatConfig(config) {
|
|
1150
|
+
const { $schema, ...rest2 } = config;
|
|
1151
|
+
const ordered = {
|
|
1152
|
+
$schema: $schema ?? SCHEMA_REF,
|
|
1153
|
+
id: rest2.id,
|
|
1154
|
+
title: rest2.title,
|
|
1155
|
+
organizationId: rest2.organizationId,
|
|
1156
|
+
...rest2.vercelTeamId ? { vercelTeamId: rest2.vercelTeamId } : {},
|
|
1157
|
+
schemaSourceProjectId: rest2.schemaSourceProjectId,
|
|
1158
|
+
...rest2.dataset && rest2.dataset !== "production" ? { dataset: rest2.dataset } : {},
|
|
1159
|
+
site: rest2.site,
|
|
1160
|
+
...rest2.studioOrigins?.length ? { studioOrigins: rest2.studioOrigins } : {},
|
|
1161
|
+
singletons: rest2.singletons,
|
|
1162
|
+
homePage: rest2.homePage,
|
|
1163
|
+
settingsFields: rest2.settingsFields,
|
|
1164
|
+
sections: rest2.sections,
|
|
1165
|
+
colorRoles: rest2.colorRoles,
|
|
1166
|
+
...rest2.themeFiles?.length ? { themeFiles: rest2.themeFiles } : {},
|
|
1167
|
+
...rest2.starter !== void 0 ? { starter: rest2.starter } : {}
|
|
1168
|
+
};
|
|
1169
|
+
return `${JSON.stringify(ordered, null, 2)}
|
|
1170
|
+
`;
|
|
1171
|
+
}
|
|
1172
|
+
var holds = (template, placeholder) => JSON.stringify(template ?? null).includes(`{{${placeholder}}}`);
|
|
1173
|
+
function configFromManifest(manifest, schema) {
|
|
1174
|
+
const { content, brand } = manifest;
|
|
1175
|
+
const fields = Object.entries(brand.settings.fields);
|
|
1176
|
+
const fieldFor = (placeholder, except) => fields.find(
|
|
1177
|
+
([field, template]) => field !== except && field !== content.homePage.field && holds(template, placeholder)
|
|
1178
|
+
)?.[0] ?? null;
|
|
1179
|
+
const logo = fieldFor("logoAssetId");
|
|
1180
|
+
const offered = Object.fromEntries(content.sections);
|
|
1181
|
+
const defaults = brand.theme.defaults;
|
|
1182
|
+
return {
|
|
1183
|
+
id: manifest.id,
|
|
1184
|
+
title: manifest.title,
|
|
1185
|
+
organizationId: manifest.organizationId,
|
|
1186
|
+
...manifest.vercelTeamId ? { vercelTeamId: manifest.vercelTeamId } : {},
|
|
1187
|
+
schemaSourceProjectId: manifest.schemaSourceProjectId,
|
|
1188
|
+
site: manifest.site,
|
|
1189
|
+
...manifest.studioOrigins ? { studioOrigins: manifest.studioOrigins } : {},
|
|
1190
|
+
singletons: content.singletons,
|
|
1191
|
+
homePage: content.homePage,
|
|
1192
|
+
settingsFields: {
|
|
1193
|
+
siteName: fieldFor("siteName", logo ?? void 0),
|
|
1194
|
+
domain: fieldFor("domain"),
|
|
1195
|
+
logo,
|
|
1196
|
+
favicon: fieldFor("faviconAssetId")
|
|
1197
|
+
},
|
|
1198
|
+
sections: {
|
|
1199
|
+
...offered,
|
|
1200
|
+
...Object.fromEntries(
|
|
1201
|
+
sectionCandidates(schema, content.singletons).filter((type) => !(type in offered)).map((type) => [type, false])
|
|
1202
|
+
)
|
|
1203
|
+
},
|
|
1204
|
+
colorRoles: Object.fromEntries(brand.colorRoles.map((role) => [role.role, role.description])),
|
|
1205
|
+
themeFiles: Object.keys(defaults).filter(
|
|
1206
|
+
(field) => JSON.stringify(defaults[field]).includes('"_ref":')
|
|
1207
|
+
)
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/generate/resolve.ts
|
|
1212
|
+
var yes = (answer) => /^y(es)?$/i.test(answer.trim());
|
|
1213
|
+
var ROLE_NAMES = { siteName: "site name", domain: "domain", logo: "logo", favicon: "favicon" };
|
|
1214
|
+
async function resolveIdentity(options) {
|
|
1215
|
+
const { saved, flags, repo, ask, lookups, note } = options;
|
|
1216
|
+
const known = { ...saved, ...flags };
|
|
1217
|
+
const fresh = options.askAgain || !saved.id;
|
|
1218
|
+
const pick = async (key, text, suggestion) => flags[key] !== void 0 ? flags[key] : !fresh && saved[key] !== void 0 ? saved[key] : ask({ text, suggestion: saved[key] ?? suggestion });
|
|
1219
|
+
const names = suggestNames(repo.repo);
|
|
1220
|
+
const id = await pick("id", "Project type id, short and lowercase", names.id);
|
|
1221
|
+
const title = await pick("title", "Name shown in the app", names.title);
|
|
1222
|
+
const project = await pick(
|
|
1223
|
+
"schemaSourceProjectId",
|
|
1224
|
+
"Sanity project to copy schemas from",
|
|
1225
|
+
repo.projectId ?? ""
|
|
1226
|
+
);
|
|
1227
|
+
if (!project) throw new Error("Pass --project <id>: the Sanity project to copy schemas from");
|
|
1228
|
+
const org = flags.organizationId ?? (!fresh ? saved.organizationId : void 0) ?? await ask({
|
|
1229
|
+
text: "Sanity organisation to create workspaces in",
|
|
1230
|
+
suggestion: saved.organizationId ?? await lookups.organizationOf?.(project).catch(() => "") ?? ""
|
|
1231
|
+
});
|
|
1232
|
+
if (!org) throw new Error("Pass --org <id>: the Sanity organisation for new workspaces");
|
|
1233
|
+
const vercelTeamId = flags.vercelTeamId ?? (fresh ? await ask({
|
|
1234
|
+
text: "Vercel team for the sites, only if it's the client's own (Enter for Anyday's)",
|
|
1235
|
+
suggestion: saved.vercelTeamId ?? ""
|
|
1236
|
+
}) : saved.vercelTeamId);
|
|
1237
|
+
const site = known.site ?? {
|
|
1238
|
+
repo: repo.repo ?? await ask({ text: "GitHub repo of the site (owner/name)", suggestion: "" }),
|
|
1239
|
+
branch: repo.branch ?? "main"
|
|
1240
|
+
};
|
|
1241
|
+
if (!site.repo) throw new Error("No GitHub remote found: add one, or set site.repo in the config");
|
|
1242
|
+
let studioOrigins = known.studioOrigins;
|
|
1243
|
+
if (!studioOrigins?.length || options.askAgain) {
|
|
1244
|
+
const found = await lookups.studioOriginsOf?.(project).catch(() => void 0);
|
|
1245
|
+
studioOrigins = found?.length ? found : studioOrigins;
|
|
1246
|
+
}
|
|
1247
|
+
if (!studioOrigins?.length)
|
|
1248
|
+
note(`No Studio found: add studioOrigins to the config, e.g. ["https://acme.sanity.studio"]`);
|
|
1249
|
+
return {
|
|
1250
|
+
...saved.$schema ? { $schema: saved.$schema } : {},
|
|
1251
|
+
id,
|
|
1252
|
+
title,
|
|
1253
|
+
organizationId: org,
|
|
1254
|
+
...vercelTeamId ? { vercelTeamId } : {},
|
|
1255
|
+
schemaSourceProjectId: project,
|
|
1256
|
+
...known.dataset ? { dataset: known.dataset } : {},
|
|
1257
|
+
site,
|
|
1258
|
+
...studioOrigins?.length ? { studioOrigins } : {},
|
|
1259
|
+
...known.themeFiles ? { themeFiles: known.themeFiles } : {},
|
|
1260
|
+
...known.starter !== void 0 ? { starter: known.starter } : {}
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
async function resolveChoices(options) {
|
|
1264
|
+
const { saved, schema, ask, note } = options;
|
|
1265
|
+
const contentTypes = schema.contentDocuments.map((type) => type.name);
|
|
1266
|
+
let singletons = guessSingletons(schema);
|
|
1267
|
+
if (saved.singletons) {
|
|
1268
|
+
for (const type of saved.singletons.filter((type2) => !contentTypes.includes(type2)))
|
|
1269
|
+
note(`Singleton ${type} is gone from the schema: dropped`);
|
|
1270
|
+
const known = /* @__PURE__ */ new Set([...saved.singletons, ...Object.keys(saved.sections ?? {})]);
|
|
1271
|
+
const added = [];
|
|
1272
|
+
for (const type of singletons.filter((type2) => !known.has(type2))) {
|
|
1273
|
+
const answer = await ask({
|
|
1274
|
+
text: `New type ${type} looks like settings. Treat it as a singleton, never offered for copy?`,
|
|
1275
|
+
suggestion: "y",
|
|
1276
|
+
choices: ["y", "n"]
|
|
1277
|
+
});
|
|
1278
|
+
if (yes(answer)) added.push(type);
|
|
1279
|
+
}
|
|
1280
|
+
singletons = [...saved.singletons.filter((type) => contentTypes.includes(type)), ...added];
|
|
1281
|
+
} else if (singletons.length) {
|
|
1282
|
+
note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
|
|
1283
|
+
}
|
|
1284
|
+
const homePage = await resolveHomePage(saved, schema, singletons, ask, note);
|
|
1285
|
+
const settingsFields = await resolveSettingsFields(saved, schema, homePage.documentId, ask, note);
|
|
1286
|
+
const sections = await resolveNamed({
|
|
1287
|
+
saved: saved.sections,
|
|
1288
|
+
current: sectionCandidates(schema, singletons),
|
|
1289
|
+
fresh: (type) => humanize(type),
|
|
1290
|
+
what: "Copy section",
|
|
1291
|
+
ask,
|
|
1292
|
+
note,
|
|
1293
|
+
asked: async (type) => yes(
|
|
1294
|
+
await ask({
|
|
1295
|
+
text: `New content type ${type}: offer it as a copy section?`,
|
|
1296
|
+
suggestion: "y",
|
|
1297
|
+
choices: ["y", "n"]
|
|
1298
|
+
})
|
|
1299
|
+
) ? humanize(type) : false
|
|
1300
|
+
});
|
|
1301
|
+
if (options.askAgain) {
|
|
1302
|
+
for (const [type, label] of Object.entries(sections)) {
|
|
1303
|
+
const answer = await ask({
|
|
1304
|
+
text: `Offer ${type} as a copy section?`,
|
|
1305
|
+
suggestion: label === false ? "n" : "y",
|
|
1306
|
+
choices: ["y", "n"]
|
|
1307
|
+
});
|
|
1308
|
+
sections[type] = yes(answer) ? label || humanize(type) : false;
|
|
1309
|
+
}
|
|
1310
|
+
} else if (!saved.sections)
|
|
1311
|
+
note("Copy sections list every content type: set the ones not to offer to false");
|
|
1312
|
+
const themeId = themeTypeOf(schema);
|
|
1313
|
+
const roles = themeId ? colorRolesOf(schema, themeId) : [];
|
|
1314
|
+
const colorRoles = await resolveNamed({
|
|
1315
|
+
saved: saved.colorRoles,
|
|
1316
|
+
current: roles,
|
|
1317
|
+
fresh: () => "",
|
|
1318
|
+
what: "Colour role",
|
|
1319
|
+
ask,
|
|
1320
|
+
note,
|
|
1321
|
+
asked: (role) => ask({
|
|
1322
|
+
text: `New colour role ${role}: where is it used? (a short description)`,
|
|
1323
|
+
suggestion: ""
|
|
1324
|
+
})
|
|
1325
|
+
});
|
|
1326
|
+
const undescribed = Object.keys(colorRoles).filter((role) => !colorRoles[role]);
|
|
1327
|
+
if (undescribed.length)
|
|
1328
|
+
note(`Colour roles without a description in the config: ${undescribed.join(", ")}`);
|
|
1329
|
+
const fontFields = themeId ? fontFieldsOf(schema, themeId) : [];
|
|
1330
|
+
let themeFiles = saved.themeFiles;
|
|
1331
|
+
if (themeFiles) {
|
|
1332
|
+
for (const field of themeFiles.filter((field2) => !fontFields.includes(field2)))
|
|
1333
|
+
note(`Theme file field ${field} is gone from the schema: dropped from themeFiles`);
|
|
1334
|
+
themeFiles = themeFiles.filter((field) => fontFields.includes(field));
|
|
1335
|
+
} else if (fontFields.length && !saved.id) {
|
|
1336
|
+
const answer = await ask({
|
|
1337
|
+
text: `Copy the live fonts (${fontFields.join(", ")}) into new sites? Fonts are often licensed per brand`,
|
|
1338
|
+
suggestion: "n",
|
|
1339
|
+
choices: ["y", "n"]
|
|
1340
|
+
});
|
|
1341
|
+
themeFiles = yes(answer) ? fontFields : [];
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
singletons,
|
|
1345
|
+
homePage,
|
|
1346
|
+
settingsFields,
|
|
1347
|
+
sections,
|
|
1348
|
+
colorRoles,
|
|
1349
|
+
...themeFiles ? { themeFiles } : {}
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
async function resolveHomePage(saved, schema, singletons, ask, note) {
|
|
1353
|
+
const isReference = (documentId, field) => {
|
|
1354
|
+
const value = schema.fields(documentId)[field]?.value;
|
|
1355
|
+
return singletons.includes(documentId) && Boolean(value && schema.referenceTarget(value));
|
|
1356
|
+
};
|
|
1357
|
+
if (saved.homePage && isReference(saved.homePage.documentId, saved.homePage.field))
|
|
1358
|
+
return saved.homePage;
|
|
1359
|
+
const candidates = singletons.flatMap(
|
|
1360
|
+
(documentId) => Object.keys(schema.fields(documentId)).filter((field) => isReference(documentId, field)).map((field) => `${documentId}.${field}`)
|
|
1361
|
+
);
|
|
1362
|
+
if (!candidates.length)
|
|
1363
|
+
throw new Error("No singleton has a reference field to use as the home page");
|
|
1364
|
+
const guess = findHomePage(schema, singletons);
|
|
1365
|
+
const suggestion = guess ? `${guess.documentId}.${guess.field}` : candidates[0];
|
|
1366
|
+
let answer = suggestion;
|
|
1367
|
+
if (saved.homePage) {
|
|
1368
|
+
answer = await ask({
|
|
1369
|
+
text: `${saved.homePage.documentId}.${saved.homePage.field} is gone. Which field is the home page?`,
|
|
1370
|
+
suggestion,
|
|
1371
|
+
choices: candidates
|
|
1372
|
+
});
|
|
1373
|
+
} else note(`Home page: ${suggestion}`);
|
|
1374
|
+
if (!candidates.includes(answer))
|
|
1375
|
+
throw new Error(`Pick the home page from ${candidates.join(", ")}`);
|
|
1376
|
+
const dot = answer.lastIndexOf(".");
|
|
1377
|
+
return { documentId: answer.slice(0, dot), field: answer.slice(dot + 1) };
|
|
1378
|
+
}
|
|
1379
|
+
async function resolveSettingsFields(saved, schema, documentId, ask, note) {
|
|
1380
|
+
const guessed = guessSettingsFields(schema, documentId);
|
|
1381
|
+
if (!saved.settingsFields) return guessed;
|
|
1382
|
+
const result = {};
|
|
1383
|
+
const fields = Object.keys(schema.fields(documentId));
|
|
1384
|
+
for (const role of SETTINGS_ROLES) {
|
|
1385
|
+
const field = saved.settingsFields[role];
|
|
1386
|
+
if (field && fitsRole(schema, documentId, role, field)) {
|
|
1387
|
+
result[role] = field;
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
const taken = /* @__PURE__ */ new Set([...Object.values(saved.settingsFields), ...Object.values(result)]);
|
|
1391
|
+
const candidates = fields.filter(
|
|
1392
|
+
(candidate) => !taken.has(candidate) && fitsRole(schema, documentId, role, candidate)
|
|
1393
|
+
);
|
|
1394
|
+
if (field) {
|
|
1395
|
+
const answer = await ask({
|
|
1396
|
+
text: `${documentId}.${field} is gone. Which field is the ${ROLE_NAMES[role]}?`,
|
|
1397
|
+
suggestion: guessed[role] && candidates.includes(guessed[role]) ? guessed[role] : "none",
|
|
1398
|
+
choices: [...candidates, "none"]
|
|
1399
|
+
});
|
|
1400
|
+
result[role] = candidates.includes(answer) ? answer : null;
|
|
1401
|
+
if (!result[role]) note(`No ${ROLE_NAMES[role]} field: new sites won't set one`);
|
|
1402
|
+
} else if (field === void 0 && guessed[role]) {
|
|
1403
|
+
const answer = await ask({
|
|
1404
|
+
text: `New field ${documentId}.${guessed[role]}: use it for the ${ROLE_NAMES[role]}?`,
|
|
1405
|
+
suggestion: "y",
|
|
1406
|
+
choices: ["y", "n"]
|
|
1407
|
+
});
|
|
1408
|
+
result[role] = yes(answer) ? guessed[role] : null;
|
|
1409
|
+
} else result[role] = null;
|
|
1410
|
+
}
|
|
1411
|
+
return result;
|
|
1412
|
+
}
|
|
1413
|
+
async function resolveNamed(options) {
|
|
1414
|
+
const { saved, current, ask, note } = options;
|
|
1415
|
+
if (!saved) return Object.fromEntries(current.map((name) => [name, options.fresh(name)]));
|
|
1416
|
+
let added = current.filter((name) => !(name in saved));
|
|
1417
|
+
const entries = [];
|
|
1418
|
+
for (const [name, value] of Object.entries(saved)) {
|
|
1419
|
+
if (current.includes(name)) {
|
|
1420
|
+
entries.push([name, value]);
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
const renamed = added.length ? await ask({
|
|
1424
|
+
text: `${options.what} ${name} is gone from the schema. Was it renamed?`,
|
|
1425
|
+
suggestion: "no",
|
|
1426
|
+
choices: [...added, "no"]
|
|
1427
|
+
}) : "no";
|
|
1428
|
+
if (added.includes(renamed)) {
|
|
1429
|
+
entries.push([renamed, value]);
|
|
1430
|
+
added = added.filter((name2) => name2 !== renamed);
|
|
1431
|
+
} else note(`${options.what} ${name} is gone from the schema: dropped`);
|
|
1432
|
+
}
|
|
1433
|
+
for (const name of added) entries.push([name, await options.asked(name)]);
|
|
1434
|
+
return Object.fromEntries(entries);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// src/generate/stamp.ts
|
|
1438
|
+
import { createHash } from "node:crypto";
|
|
1439
|
+
var NOTE = "GENERATED FILE, DO NOT EDIT. Change workspace-manager.config.json, then run: npx @anyday/sanity-workspace-manager generate";
|
|
1440
|
+
var hashOf = (manifest) => createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 16);
|
|
1441
|
+
var unstamped = ({ $generated: _, ...manifest }) => manifest;
|
|
1442
|
+
function stamp(manifest) {
|
|
1443
|
+
const body = unstamped(manifest);
|
|
1444
|
+
return `${JSON.stringify({ $generated: `${NOTE}. sha256:${hashOf(body)}`, ...body }, null, 2)}
|
|
1445
|
+
`;
|
|
1446
|
+
}
|
|
1447
|
+
function stampState(manifest) {
|
|
1448
|
+
const hash = manifest.$generated?.match(/sha256:([0-9a-f]+)$/)?.[1];
|
|
1449
|
+
if (!hash) return "none";
|
|
1450
|
+
return hash === hashOf(unstamped(manifest)) ? "generated" : "edited";
|
|
1451
|
+
}
|
|
1452
|
+
var show = (value) => {
|
|
1453
|
+
const text = JSON.stringify(value);
|
|
1454
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
1455
|
+
};
|
|
1456
|
+
function diffJson(before, after, path = "") {
|
|
1457
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
|
1458
|
+
const bothObjects = [before, after].every((value) => value && typeof value === "object");
|
|
1459
|
+
if (!bothObjects || Array.isArray(before) !== Array.isArray(after))
|
|
1460
|
+
return [`~ ${path || "."}: ${show(before)} \u2192 ${show(after)}`];
|
|
1461
|
+
const a = before;
|
|
1462
|
+
const b = after;
|
|
1463
|
+
const at = (key) => Array.isArray(before) ? `${path}[${key}]` : path ? `${path}.${key}` : key;
|
|
1464
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].flatMap(
|
|
1465
|
+
(key) => !(key in a) ? [`+ ${at(key)}: ${show(b[key])}`] : !(key in b) ? [`- ${at(key)}`] : diffJson(a[key], b[key], at(key))
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/generate/live.ts
|
|
1470
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
808
1471
|
import { homedir } from "node:os";
|
|
809
1472
|
import { join } from "node:path";
|
|
810
1473
|
import { createClient } from "@sanity/client";
|
|
811
1474
|
function sanityToken() {
|
|
812
1475
|
if (process.env.SANITY_AUTH_TOKEN) return process.env.SANITY_AUTH_TOKEN;
|
|
813
1476
|
const config = join(homedir(), ".config", "sanity", "config.json");
|
|
814
|
-
if (!
|
|
815
|
-
return JSON.parse(
|
|
1477
|
+
if (!existsSync2(config)) return null;
|
|
1478
|
+
return JSON.parse(readFileSync2(config, "utf8")).authToken ?? null;
|
|
816
1479
|
}
|
|
817
1480
|
async function projectInfo(projectId, token) {
|
|
818
1481
|
const response = await fetch(`https://api.sanity.io/v2021-06-07/projects/${projectId}`, {
|
|
@@ -872,9 +1535,9 @@ async function readLive(options) {
|
|
|
872
1535
|
};
|
|
873
1536
|
}
|
|
874
1537
|
|
|
875
|
-
// src/
|
|
1538
|
+
// src/generate/repo.ts
|
|
876
1539
|
import { execFileSync } from "node:child_process";
|
|
877
|
-
import { existsSync as
|
|
1540
|
+
import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3 } from "node:fs";
|
|
878
1541
|
import { join as join2 } from "node:path";
|
|
879
1542
|
var APP_DIRS = [".", "frontend", "web", "site", "studio", "apps"];
|
|
880
1543
|
var git = (dir, ...args) => {
|
|
@@ -889,24 +1552,24 @@ function repoFromRemote(remote) {
|
|
|
889
1552
|
}
|
|
890
1553
|
function appDirs(root) {
|
|
891
1554
|
const apps = join2(root, "apps");
|
|
892
|
-
const nested =
|
|
893
|
-
return [...APP_DIRS.map((dir) => join2(root, dir)), ...nested].filter((dir) =>
|
|
1555
|
+
const nested = existsSync3(apps) ? readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(apps, entry.name)) : [];
|
|
1556
|
+
return [...APP_DIRS.map((dir) => join2(root, dir)), ...nested].filter((dir) => existsSync3(dir));
|
|
894
1557
|
}
|
|
895
1558
|
function readRepo(root) {
|
|
896
1559
|
const dirs = appDirs(root);
|
|
897
|
-
const schemaPath = dirs.map((dir) => join2(dir, "sanity.schema.json")).find(
|
|
1560
|
+
const schemaPath = dirs.map((dir) => join2(dir, "sanity.schema.json")).find(existsSync3);
|
|
898
1561
|
if (!schemaPath) {
|
|
899
1562
|
throw new Error("No sanity.schema.json: run `npx sanity schema extract` in the Studio first");
|
|
900
1563
|
}
|
|
901
1564
|
const envNames = dirs.flatMap((dir) => {
|
|
902
1565
|
const file = join2(dir, ".env.example");
|
|
903
|
-
if (!
|
|
904
|
-
return [...
|
|
1566
|
+
if (!existsSync3(file)) return [];
|
|
1567
|
+
return [...readFileSync3(file, "utf8").matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((m) => m[1]);
|
|
905
1568
|
});
|
|
906
|
-
const projectId = dirs.flatMap((dir) => ["sanity.cli.ts", "sanity.config.ts"].map((file) => join2(dir, file))).filter(
|
|
1569
|
+
const projectId = dirs.flatMap((dir) => ["sanity.cli.ts", "sanity.config.ts"].map((file) => join2(dir, file))).filter(existsSync3).map((file) => readFileSync3(file, "utf8").match(/projectId:\s*['"]([a-z0-9]+)['"]/)?.[1]).find(Boolean) ?? null;
|
|
907
1570
|
const hasRevalidateRoute = dirs.some(
|
|
908
1571
|
(dir) => ["app", "src/app", "pages", "src/pages"].some(
|
|
909
|
-
(base) =>
|
|
1572
|
+
(base) => existsSync3(join2(dir, base, "api/revalidate")) || ["ts", "js"].some((ext) => existsSync3(join2(dir, base, `api/revalidate.${ext}`)))
|
|
910
1573
|
)
|
|
911
1574
|
);
|
|
912
1575
|
return {
|
|
@@ -915,16 +1578,19 @@ function readRepo(root) {
|
|
|
915
1578
|
envNames: [...new Set(envNames)],
|
|
916
1579
|
projectId,
|
|
917
1580
|
hasRevalidateRoute,
|
|
918
|
-
types: JSON.parse(
|
|
1581
|
+
types: JSON.parse(readFileSync3(schemaPath, "utf8"))
|
|
919
1582
|
};
|
|
920
1583
|
}
|
|
921
1584
|
|
|
922
|
-
// src/
|
|
1585
|
+
// src/generate/starter.ts
|
|
1586
|
+
var StarterError = class extends Error {
|
|
1587
|
+
};
|
|
923
1588
|
function resolveStarter(entries, pages, homeId) {
|
|
924
1589
|
const home = pages.find((page) => page._id === homeId);
|
|
925
1590
|
return entries.map((entry) => {
|
|
926
1591
|
if (entry === "home") {
|
|
927
|
-
if (!homeId)
|
|
1592
|
+
if (!homeId)
|
|
1593
|
+
throw new StarterError("The live settings name no home page: pass its slug instead");
|
|
928
1594
|
return homeId;
|
|
929
1595
|
}
|
|
930
1596
|
if (pages.some((page) => page._id === entry)) return entry;
|
|
@@ -932,8 +1598,8 @@ function resolveStarter(entries, pages, homeId) {
|
|
|
932
1598
|
if (matches.length > 1 && home?.language)
|
|
933
1599
|
matches = matches.filter((page) => page.language === home.language);
|
|
934
1600
|
if (matches.length === 1) return matches[0]._id;
|
|
935
|
-
if (!matches.length) throw new
|
|
936
|
-
throw new
|
|
1601
|
+
if (!matches.length) throw new StarterError(`No page with the slug or id "${entry}"`);
|
|
1602
|
+
throw new StarterError(
|
|
937
1603
|
`Several pages have the slug "${entry}": pass one of ${matches.map((page) => page._id).join(", ")}`
|
|
938
1604
|
);
|
|
939
1605
|
});
|
|
@@ -953,18 +1619,18 @@ function parsePick(answer, count) {
|
|
|
953
1619
|
}
|
|
954
1620
|
|
|
955
1621
|
// src/register.ts
|
|
956
|
-
import { readFileSync as
|
|
1622
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
957
1623
|
import { join as join3 } from "node:path";
|
|
958
1624
|
async function register(options) {
|
|
959
1625
|
const file = join3(options.dir, "workspace-manager.json");
|
|
960
|
-
const manifest = parseManifest(JSON.parse(
|
|
1626
|
+
const manifest = parseManifest(JSON.parse(readFileSync4(file, "utf8")));
|
|
961
1627
|
configFrom(manifest);
|
|
962
1628
|
const response = await (options.fetch ?? fetch)(
|
|
963
1629
|
`${options.api.replace(/\/+$/, "")}/api/project-types`,
|
|
964
1630
|
{
|
|
965
1631
|
method: "POST",
|
|
966
1632
|
headers: { "Authorization": `Bearer ${options.token}`, "Content-Type": "application/json" },
|
|
967
|
-
body: JSON.stringify(manifest)
|
|
1633
|
+
body: JSON.stringify(unstamped(manifest))
|
|
968
1634
|
}
|
|
969
1635
|
);
|
|
970
1636
|
const body = await response.json().catch(() => ({}));
|
|
@@ -974,166 +1640,342 @@ async function register(options) {
|
|
|
974
1640
|
}
|
|
975
1641
|
|
|
976
1642
|
// src/cli.ts
|
|
977
|
-
var USAGE = `Usage: sanity-workspace-manager <
|
|
978
|
-
|
|
979
|
-
init [dir]
|
|
1643
|
+
var USAGE = `Usage: sanity-workspace-manager <generate|check|register> [dir] [options]
|
|
980
1644
|
|
|
981
|
-
|
|
982
|
-
|
|
1645
|
+
generate [dir]
|
|
1646
|
+
Writes workspace-manager.json from the repo's sanity.schema.json (run \`npx sanity schema extract\`
|
|
1647
|
+
first), the live Sanity project and workspace-manager.config.json. Saves your answers in the
|
|
1648
|
+
config and, next time, asks only about what changed in the schema.
|
|
983
1649
|
|
|
984
1650
|
Options:
|
|
985
|
-
--yes
|
|
1651
|
+
--yes Take the suggested answers without asking
|
|
1652
|
+
--offline Don't read the live project; keep the live values already in the manifest
|
|
1653
|
+
--diff Show what would change, and write nothing
|
|
1654
|
+
--ask-again Ask the saved questions again, e.g. to change copy sections or starter pages
|
|
986
1655
|
--project <id> The Sanity project new workspaces copy their schema from
|
|
987
|
-
--org <id> The Sanity organisation new workspaces are created in
|
|
1656
|
+
--org <id> The Sanity organisation new workspaces are created in
|
|
988
1657
|
--vercel-team <id> The client's own Vercel team for the sites (default: Anyday's team)
|
|
989
1658
|
--studio <origin> The Studio that opens new workspaces, e.g. https://acme.sanity.studio
|
|
990
|
-
|
|
991
|
-
--
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
--print Print the draft instead of writing it
|
|
1659
|
+
--dataset <name> The dataset live values are read from (default: production)
|
|
1660
|
+
--starter <pages> Starter pages from the live site: \`home\`, slugs or ids, comma-separated
|
|
1661
|
+
|
|
1662
|
+
check [dir]
|
|
1663
|
+
Fails when workspace-manager.json is out of date with the schema or was edited by hand.
|
|
1664
|
+
Reads only the repo, so it runs in CI without signing in.
|
|
997
1665
|
|
|
998
1666
|
register [dir]
|
|
999
1667
|
Checks the repo's workspace-manager.json and saves it through the workspace manager API,
|
|
1000
1668
|
signed in as you (SANITY_AUTH_TOKEN or \`npx sanity login\`).
|
|
1001
1669
|
|
|
1002
1670
|
Options:
|
|
1003
|
-
--api <url> The workspace manager API (or set WORKSPACE_MANAGER_API_URL)
|
|
1671
|
+
--api <url> The workspace manager API (or set WORKSPACE_MANAGER_API_URL)
|
|
1672
|
+
--dry-run Check the manifest without saving it
|
|
1673
|
+
--yes Register without asking, even with placeholder colours or fonts`;
|
|
1004
1674
|
var dirOf = (positional) => resolve(process.env.INIT_CWD ?? ".", positional ?? ".");
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
options: { api: { type: "string" } }
|
|
1010
|
-
});
|
|
1011
|
-
const api = values.api ?? process.env.WORKSPACE_MANAGER_API_URL;
|
|
1012
|
-
if (!api) throw new Error("Pass --api <url> or set WORKSPACE_MANAGER_API_URL");
|
|
1013
|
-
const token = sanityToken();
|
|
1014
|
-
if (!token) throw new Error("register needs SANITY_AUTH_TOKEN or `npx sanity login`");
|
|
1015
|
-
const saved = await register({ dir: dirOf(positionals[0]), api, token });
|
|
1016
|
-
console.log(`Registered ${saved.title} (${saved.id})`);
|
|
1017
|
-
for (const warning of saved.warnings ?? []) console.error(`Warning: ${warning}`);
|
|
1675
|
+
var readText = (path) => existsSync4(path) ? readFileSync5(path, "utf8") : "";
|
|
1676
|
+
function readManifest(root) {
|
|
1677
|
+
const json = readJsonFile(join4(root, MANIFEST_FILE));
|
|
1678
|
+
return json ? parseManifest(json) : null;
|
|
1018
1679
|
}
|
|
1019
|
-
|
|
1680
|
+
function createPrompt() {
|
|
1681
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
1682
|
+
const ask = async ({ text, suggestion, choices }) => {
|
|
1683
|
+
const hint = choices ? ` [${choices.join("/")}]` : "";
|
|
1684
|
+
for (; ; ) {
|
|
1685
|
+
const typed = (await prompt.question(`${text}${hint}${suggestion ? ` (${suggestion})` : ""}: `)).trim();
|
|
1686
|
+
const answer = choices?.find((choice) => choice.toLowerCase() === typed.toLowerCase());
|
|
1687
|
+
if (!typed) return suggestion;
|
|
1688
|
+
if (!choices) return typed;
|
|
1689
|
+
if (answer) return answer;
|
|
1690
|
+
if (/^(yes|no)$/i.test(typed) && choices.includes(typed[0].toLowerCase()))
|
|
1691
|
+
return typed[0].toLowerCase();
|
|
1692
|
+
console.error(`Answer one of: ${choices.join(", ")}`);
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
return { ask, close: () => prompt.close() };
|
|
1696
|
+
}
|
|
1697
|
+
async function generate(argv) {
|
|
1020
1698
|
const { values, positionals } = parseArgs({
|
|
1021
1699
|
args: argv,
|
|
1022
1700
|
allowPositionals: true,
|
|
1023
1701
|
options: {
|
|
1024
1702
|
"yes": { type: "boolean", default: false },
|
|
1703
|
+
"offline": { type: "boolean", default: false },
|
|
1704
|
+
"diff": { type: "boolean", default: false },
|
|
1705
|
+
"ask-again": { type: "boolean", default: false },
|
|
1025
1706
|
"project": { type: "string" },
|
|
1026
1707
|
"org": { type: "string" },
|
|
1027
1708
|
"studio": { type: "string" },
|
|
1028
1709
|
"vercel-team": { type: "string" },
|
|
1029
|
-
"
|
|
1030
|
-
"
|
|
1031
|
-
"starter": { type: "string" },
|
|
1032
|
-
"force": { type: "boolean", default: false },
|
|
1033
|
-
"print": { type: "boolean", default: false }
|
|
1710
|
+
"dataset": { type: "string" },
|
|
1711
|
+
"starter": { type: "string" }
|
|
1034
1712
|
}
|
|
1035
1713
|
});
|
|
1036
1714
|
const root = dirOf(positionals[0]);
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
throw new Error(`${out} exists: pass --force to replace it`);
|
|
1040
|
-
if (values.starter && !values.live) throw new Error("--starter needs --live");
|
|
1041
|
-
const token = values.live ? sanityToken() : null;
|
|
1042
|
-
if (values.live && !token) throw new Error("--live needs SANITY_AUTH_TOKEN or `npx sanity login`");
|
|
1043
|
-
const orgFor = async (projectId) => values.org ?? (token && projectId ? await organizationOf(projectId, token) : "");
|
|
1715
|
+
const notes = [];
|
|
1716
|
+
const note = (message) => void notes.push(message);
|
|
1044
1717
|
const repo = readRepo(root);
|
|
1045
|
-
const
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
organizationId:
|
|
1058
|
-
...
|
|
1059
|
-
|
|
1060
|
-
|
|
1718
|
+
const schema = new Extract(repo.types);
|
|
1719
|
+
const previous = readManifest(root);
|
|
1720
|
+
let saved = {};
|
|
1721
|
+
const configJson = readJsonFile(join4(root, CONFIG_FILE));
|
|
1722
|
+
if (configJson) saved = parseConfig(configJson);
|
|
1723
|
+
else if (previous) {
|
|
1724
|
+
saved = configFromManifest(previous, schema);
|
|
1725
|
+
note(`Moved the choices in ${MANIFEST_FILE} into ${CONFIG_FILE}`);
|
|
1726
|
+
}
|
|
1727
|
+
const starterFlag = values.starter?.trim();
|
|
1728
|
+
const flags = {
|
|
1729
|
+
...values.project ? { schemaSourceProjectId: values.project } : {},
|
|
1730
|
+
...values.org ? { organizationId: values.org } : {},
|
|
1731
|
+
...values["vercel-team"] ? { vercelTeamId: values["vercel-team"] } : {},
|
|
1732
|
+
...values.studio ? { studioOrigins: [values.studio.replace(/\/+$/, "")] } : {},
|
|
1733
|
+
...values.dataset ? { dataset: values.dataset } : {},
|
|
1734
|
+
...starterFlag !== void 0 ? {
|
|
1735
|
+
starter: starterFlag.split(",").map((entry) => entry.trim()).filter(Boolean)
|
|
1736
|
+
} : {}
|
|
1061
1737
|
};
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1738
|
+
const prompt = values.yes ? null : createPrompt();
|
|
1739
|
+
const ask = prompt?.ask ?? (async ({ text, suggestion }) => {
|
|
1740
|
+
note(`Took "${suggestion}" for: ${text}`);
|
|
1741
|
+
return suggestion;
|
|
1742
|
+
});
|
|
1743
|
+
const askAgain = values["ask-again"];
|
|
1744
|
+
try {
|
|
1745
|
+
let token = values.offline ? null : sanityToken();
|
|
1746
|
+
const fallback = previous ? `keeping the colours, fonts and starter pages already in ${MANIFEST_FILE}` : "with black colours, system fonts and one empty home page";
|
|
1747
|
+
const goOffline = async (reason) => {
|
|
1748
|
+
console.error(`${reason}.`);
|
|
1749
|
+
const answer = await ask({
|
|
1750
|
+
text: `Continue from the schema only, ${fallback}?`,
|
|
1751
|
+
suggestion: "y",
|
|
1752
|
+
choices: ["y", "n"]
|
|
1753
|
+
});
|
|
1754
|
+
if (answer !== "y") throw new Error("Stopped: sign in with `npx sanity login` and run again");
|
|
1755
|
+
return null;
|
|
1756
|
+
};
|
|
1757
|
+
if (!values.offline && !token)
|
|
1758
|
+
token = await goOffline("Not signed in to Sanity: run `npx sanity login` to read live values");
|
|
1759
|
+
const identity = await resolveIdentity({
|
|
1760
|
+
saved,
|
|
1761
|
+
flags,
|
|
1762
|
+
repo,
|
|
1763
|
+
ask,
|
|
1764
|
+
askAgain,
|
|
1765
|
+
note,
|
|
1766
|
+
lookups: token ? {
|
|
1767
|
+
organizationOf: (projectId) => organizationOf(projectId, token),
|
|
1768
|
+
studioOriginsOf: (projectId) => studioOriginsOf(projectId, token)
|
|
1769
|
+
} : {}
|
|
1080
1770
|
});
|
|
1081
|
-
|
|
1771
|
+
const choices = await resolveChoices({ saved, schema, ask, askAgain, note });
|
|
1772
|
+
const config = { ...identity, ...choices };
|
|
1773
|
+
let live = null;
|
|
1774
|
+
if (token) {
|
|
1775
|
+
try {
|
|
1776
|
+
live = await readLiveFacts({ config, schema, token, prompt, askAgain });
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
if (error instanceof StarterError) throw error;
|
|
1779
|
+
token = await goOffline(accessProblem(error, config.schemaSourceProjectId));
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
const built = buildManifest({ repo, config, live, previous });
|
|
1783
|
+
configFrom(parseManifest(built.manifest));
|
|
1784
|
+
const configText = formatConfig(config);
|
|
1785
|
+
if (values.diff) {
|
|
1786
|
+
const changes = [
|
|
1787
|
+
...diffJson(saved, JSON.parse(configText)).map((line) => `${CONFIG_FILE} ${line}`),
|
|
1788
|
+
...diffJson(previous && unstamped(previous), built.manifest).map(
|
|
1789
|
+
(line) => `${MANIFEST_FILE} ${line}`
|
|
1790
|
+
)
|
|
1791
|
+
];
|
|
1792
|
+
console.log(changes.length ? changes.join("\n") : "No changes");
|
|
1793
|
+
if (!live) console.error(`
|
|
1794
|
+
Schema only: live values not read, ${fallback}`);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
writeFileSync(join4(root, CONFIG_FILE), configText);
|
|
1798
|
+
writeFileSync(join4(root, MANIFEST_FILE), stamp(built.manifest));
|
|
1799
|
+
console.error(
|
|
1800
|
+
live ? `Wrote ${MANIFEST_FILE}` : `Wrote ${MANIFEST_FILE} from the schema only, ${fallback}. Sign in and run generate again to read live values`
|
|
1801
|
+
);
|
|
1802
|
+
const attributes = readText(join4(root, ".gitattributes"));
|
|
1803
|
+
if (!attributes.includes(`${MANIFEST_FILE} linguist-generated`))
|
|
1804
|
+
note(
|
|
1805
|
+
`Add \`${MANIFEST_FILE} linguist-generated=true\` to .gitattributes, so GitHub collapses it in PRs`
|
|
1806
|
+
);
|
|
1807
|
+
const all = [...notes, ...built.notes];
|
|
1808
|
+
if (all.length) console.error(`
|
|
1809
|
+
Check:
|
|
1810
|
+
${all.map((line) => ` - ${line}`).join("\n")}`);
|
|
1811
|
+
} finally {
|
|
1812
|
+
prompt?.close();
|
|
1082
1813
|
}
|
|
1083
|
-
if (existsSync3(out)) keepFileDefaults(draft, readJson(out));
|
|
1084
|
-
configFrom(parseManifest(draft.manifest));
|
|
1085
|
-
const json = `${JSON.stringify(draft.manifest, null, 2)}
|
|
1086
|
-
`;
|
|
1087
|
-
if (values.print) process.stdout.write(json);
|
|
1088
|
-
else writeFileSync(out, json);
|
|
1089
|
-
console.error(`${values.print ? "Drafted" : `Wrote ${out}`}
|
|
1090
|
-
|
|
1091
|
-
Check before registering:`);
|
|
1092
|
-
for (const note of draft.notes) console.error(` - ${note}`);
|
|
1093
1814
|
}
|
|
1094
|
-
function
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
return
|
|
1099
|
-
|
|
1815
|
+
function accessProblem(error, projectId) {
|
|
1816
|
+
const message = error.message ?? String(error);
|
|
1817
|
+
const status = error.statusCode ?? Number(message.match(/\b(401|403)\b/)?.[1]);
|
|
1818
|
+
if (status === 401)
|
|
1819
|
+
return "Your Sanity session is missing or expired: run `npx sanity login` to read live values";
|
|
1820
|
+
if (status === 403)
|
|
1821
|
+
return `Your Sanity account can't read project ${projectId}: ask one of its Administrators to add you`;
|
|
1822
|
+
return `Couldn't read the live project ${projectId}: ${message}`;
|
|
1100
1823
|
}
|
|
1101
|
-
async function
|
|
1102
|
-
const
|
|
1103
|
-
const
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1824
|
+
async function readLiveFacts(options) {
|
|
1825
|
+
const { config, schema, token, prompt } = options;
|
|
1826
|
+
const project = {
|
|
1827
|
+
projectId: config.schemaSourceProjectId,
|
|
1828
|
+
dataset: config.dataset ?? "production",
|
|
1829
|
+
token
|
|
1830
|
+
};
|
|
1831
|
+
const homeField = schema.fields(config.homePage.documentId)[config.homePage.field];
|
|
1832
|
+
const pageType = (homeField && schema.referenceTarget(homeField.value)) ?? "page";
|
|
1833
|
+
const { pages, homeId } = await listPages({ ...project, pageType, home: config.homePage });
|
|
1834
|
+
if (config.starter === void 0 || options.askAgain) {
|
|
1835
|
+
const picked = prompt ? await pickStarter(prompt, pickable(pages, homeId), homeId) : [];
|
|
1836
|
+
config.starter = picked.map(
|
|
1837
|
+
(id) => id === homeId ? "home" : pages.find((page) => page._id === id)?.slug ?? id
|
|
1112
1838
|
);
|
|
1113
|
-
return { id, title, project, org, vercelTeam };
|
|
1114
|
-
} finally {
|
|
1115
|
-
prompt.close();
|
|
1116
1839
|
}
|
|
1840
|
+
const starterIds = resolveStarter(config.starter, pages, homeId);
|
|
1841
|
+
return readLive({
|
|
1842
|
+
...project,
|
|
1843
|
+
settingsId: config.homePage.documentId,
|
|
1844
|
+
themeId: themeTypeOf(schema) ?? "themeSettings",
|
|
1845
|
+
starterIds
|
|
1846
|
+
});
|
|
1117
1847
|
}
|
|
1118
|
-
async function pickStarter(pages, homeId) {
|
|
1848
|
+
async function pickStarter(prompt, pages, homeId) {
|
|
1119
1849
|
if (!pages.length) return [];
|
|
1120
1850
|
console.error("\nPages of the live site:");
|
|
1121
1851
|
pages.forEach((page, index) => {
|
|
1122
1852
|
const label = `${page.title ?? page._id} (/${page.slug})`;
|
|
1123
1853
|
console.error(` ${index + 1}. ${label}${page._id === homeId ? " \u2190 home" : ""}`);
|
|
1124
1854
|
});
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
);
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1855
|
+
for (; ; ) {
|
|
1856
|
+
const answer = await prompt.ask({
|
|
1857
|
+
text: "Starter pages, by number (e.g. 1,3; Enter for one empty home page)",
|
|
1858
|
+
suggestion: ""
|
|
1859
|
+
});
|
|
1860
|
+
try {
|
|
1861
|
+
return parsePick(answer, pages.length).map((index) => pages[index]._id);
|
|
1862
|
+
} catch (error) {
|
|
1863
|
+
console.error(error.message);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
async function check(argv) {
|
|
1868
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
|
|
1869
|
+
const root = dirOf(positionals[0]);
|
|
1870
|
+
const configJson = readJsonFile(join4(root, CONFIG_FILE));
|
|
1871
|
+
if (!configJson) throw new Error(`No ${CONFIG_FILE}: run \`sanity-workspace-manager generate\``);
|
|
1872
|
+
const config = parseConfig(configJson);
|
|
1873
|
+
const manifest = readManifest(root);
|
|
1874
|
+
if (!manifest) throw new Error(`No ${MANIFEST_FILE}: run \`sanity-workspace-manager generate\``);
|
|
1875
|
+
const failures = [];
|
|
1876
|
+
const state = stampState(manifest);
|
|
1877
|
+
if (state === "edited")
|
|
1878
|
+
failures.push(`${MANIFEST_FILE} was edited by hand: put the change in ${CONFIG_FILE} instead`);
|
|
1879
|
+
if (state === "none") failures.push(`${MANIFEST_FILE} wasn't made by generate`);
|
|
1880
|
+
const repo = readRepo(root);
|
|
1881
|
+
const schema = new Extract(repo.types);
|
|
1882
|
+
const notes = [];
|
|
1883
|
+
const note = (message) => void notes.push(message);
|
|
1884
|
+
const ask = async ({ text, suggestion }) => {
|
|
1885
|
+
failures.push(text);
|
|
1886
|
+
return suggestion;
|
|
1887
|
+
};
|
|
1888
|
+
const identity = await resolveIdentity({
|
|
1889
|
+
saved: config,
|
|
1890
|
+
flags: {},
|
|
1891
|
+
repo,
|
|
1892
|
+
ask,
|
|
1893
|
+
askAgain: false,
|
|
1894
|
+
lookups: {},
|
|
1895
|
+
note
|
|
1896
|
+
});
|
|
1897
|
+
const next = {
|
|
1898
|
+
...identity,
|
|
1899
|
+
...await resolveChoices({ saved: config, schema, ask, askAgain: false, note })
|
|
1900
|
+
};
|
|
1901
|
+
const built = buildManifest({ repo, config: next, live: null, previous: manifest });
|
|
1902
|
+
failures.push(
|
|
1903
|
+
...diffJson(JSON.parse(formatConfig(config)), JSON.parse(formatConfig(next))).map(
|
|
1904
|
+
(line) => `${CONFIG_FILE} ${line}`
|
|
1905
|
+
),
|
|
1906
|
+
...diffJson(unstamped(manifest), built.manifest).map((line) => `${MANIFEST_FILE} ${line}`)
|
|
1907
|
+
);
|
|
1908
|
+
if (!failures.length) {
|
|
1909
|
+
console.log(`${MANIFEST_FILE} is up to date`);
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
console.error(
|
|
1913
|
+
`${MANIFEST_FILE} is out of date:
|
|
1914
|
+
${failures.map((line) => ` ${line}`).join("\n")}`
|
|
1915
|
+
);
|
|
1916
|
+
console.error("\nRun `npx @anyday/sanity-workspace-manager generate` and commit the result.");
|
|
1917
|
+
process.exit(1);
|
|
1918
|
+
}
|
|
1919
|
+
function placeholderValues(manifest) {
|
|
1920
|
+
const { colorRoles, theme } = manifest.brand;
|
|
1921
|
+
const found = [];
|
|
1922
|
+
if (colorRoles.length && colorRoles.every((role) => /^#0{6}$/.test(role.default)))
|
|
1923
|
+
found.push("black colours");
|
|
1924
|
+
const hasFonts = Object.values(theme.defaults).some(
|
|
1925
|
+
(value) => JSON.stringify(value).includes('"_ref":')
|
|
1926
|
+
);
|
|
1927
|
+
if (theme.font && !hasFonts) found.push("system fonts");
|
|
1928
|
+
return found;
|
|
1929
|
+
}
|
|
1930
|
+
async function registerCommand(argv) {
|
|
1931
|
+
const { values, positionals } = parseArgs({
|
|
1932
|
+
args: argv,
|
|
1933
|
+
allowPositionals: true,
|
|
1934
|
+
options: {
|
|
1935
|
+
"api": { type: "string" },
|
|
1936
|
+
"dry-run": { type: "boolean", default: false },
|
|
1937
|
+
"yes": { type: "boolean", default: false }
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
const dir = dirOf(positionals[0]);
|
|
1941
|
+
const manifest = readManifest(dir);
|
|
1942
|
+
if (!manifest) throw new Error(`No ${MANIFEST_FILE} in ${dir}`);
|
|
1943
|
+
configFrom(manifest);
|
|
1944
|
+
if (stampState(manifest) === "edited")
|
|
1945
|
+
console.error(`Warning: ${MANIFEST_FILE} was edited by hand since generate wrote it`);
|
|
1946
|
+
const placeholders2 = placeholderValues(manifest);
|
|
1947
|
+
if (placeholders2.length) {
|
|
1948
|
+
console.error(`New sites will use ${placeholders2.join(" and ")}.`);
|
|
1949
|
+
if (!values["dry-run"] && !values.yes) {
|
|
1950
|
+
const prompt = createPrompt();
|
|
1951
|
+
try {
|
|
1952
|
+
const answer = await prompt.ask({
|
|
1953
|
+
text: "Register anyway?",
|
|
1954
|
+
suggestion: "n",
|
|
1955
|
+
choices: ["y", "n"]
|
|
1956
|
+
});
|
|
1957
|
+
if (answer !== "y")
|
|
1958
|
+
throw new Error("Not registered: run generate signed in to Sanity first");
|
|
1959
|
+
} finally {
|
|
1960
|
+
prompt.close();
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
if (values["dry-run"]) {
|
|
1965
|
+
console.log(`Valid: ${manifest.title} (${manifest.id}) is ready to register`);
|
|
1966
|
+
return;
|
|
1133
1967
|
}
|
|
1968
|
+
const api = values.api ?? process.env.WORKSPACE_MANAGER_API_URL;
|
|
1969
|
+
if (!api) throw new Error("Pass --api <url> or set WORKSPACE_MANAGER_API_URL");
|
|
1970
|
+
const token = sanityToken();
|
|
1971
|
+
if (!token) throw new Error("register needs SANITY_AUTH_TOKEN or `npx sanity login`");
|
|
1972
|
+
const saved = await register({ dir, api, token });
|
|
1973
|
+
console.log(`Registered ${saved.title} (${saved.id})`);
|
|
1974
|
+
for (const warning of saved.warnings ?? []) console.error(`Warning: ${warning}`);
|
|
1134
1975
|
}
|
|
1135
1976
|
var commands = {
|
|
1136
|
-
|
|
1977
|
+
generate,
|
|
1978
|
+
check,
|
|
1137
1979
|
register: registerCommand
|
|
1138
1980
|
};
|
|
1139
1981
|
var [command, ...rest] = process.argv.slice(2);
|