@codepress/design-kit 0.7.0 → 0.9.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/src/init.mjs CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  import { dirname, join, relative, resolve, sep } from "node:path";
23
23
 
24
24
  import { DEFAULT_MAX_AGE_DAYS } from "./ci.mjs";
25
- import { CONFIG_NAME } from "./config.mjs";
25
+ import { CONFIG_NAME, ConfigError } from "./config.mjs";
26
+ import { STORAGE_TYPES } from "./storage/index.mjs";
26
27
 
27
28
  const PACKAGE = JSON.parse(
28
29
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
@@ -661,12 +662,19 @@ export function watchDefaults(found) {
661
662
  );
662
663
  }
663
664
 
664
- /** design-kit.config.json, as an object. */
665
- export function buildConfig(found) {
665
+ /** The config's $schema URL for this version of the package. */
666
+ const SCHEMA_URL = `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`;
667
+
668
+ /**
669
+ * design-kit.config.json, as an object. `storage` (a type) adds the
670
+ * storage key; without it the kit is a GitHub Actions artifact.
671
+ */
672
+ export function buildConfig(found, storage) {
666
673
  const config = {
667
- $schema: `https://unpkg.com/${PACKAGE.name}@${PACKAGE.version}/schema/design-kit.config.schema.json`,
674
+ $schema: SCHEMA_URL,
668
675
  version: 1,
669
676
  out: "design-kit",
677
+ ...(storage ? { storage: storageConfig(storage) } : {}),
670
678
  watch: watchDefaults(found),
671
679
  theme: {
672
680
  attribute: found.theme.attribute,
@@ -706,8 +714,115 @@ export function buildConfig(found) {
706
714
  return config;
707
715
  }
708
716
 
709
- /** .github/workflows/design-kit.yml, as text. */
710
- export function buildWorkflow(found) {
717
+ /** The upload-artifact step and the cleanup job of `github` storage. */
718
+ const ARTIFACT_STEPS = (kitDir) => ` - id: upload
719
+ if: hashFiles('${kitDir}/manifest.json') != ''
720
+ uses: actions/upload-artifact@v5
721
+ with:
722
+ name: design-kit
723
+ path: ${kitDir}
724
+ # The most a public repository allows. A private repository may
725
+ # raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
726
+ retention-days: ${RETENTION_DAYS}
727
+ if-no-files-found: error
728
+
729
+ # Deletes this branch's older kits once the new one is uploaded. The only
730
+ # job that may delete artifacts; a failed delete is a warning.
731
+ cleanup:
732
+ name: Delete older kits
733
+ needs: build
734
+ if: needs.build.outputs.artifact-id != ''
735
+ runs-on: ubuntu-latest
736
+ timeout-minutes: 5
737
+ permissions:
738
+ actions: write
739
+ steps:
740
+ - uses: actions/setup-node@v5
741
+ with:
742
+ node-version: "22"
743
+
744
+ - continue-on-error: true
745
+ env:
746
+ GH_TOKEN: \${{ github.token }}
747
+ KEEP: \${{ needs.build.outputs.artifact-id }}
748
+ run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
749
+ `;
750
+
751
+ /** The storage objects `init --storage <type>` writes. */
752
+ export function storageConfig(type) {
753
+ if (type === "s3")
754
+ return {
755
+ type,
756
+ bucket: `${TODO}: your S3 bucket's name`,
757
+ region: `${TODO}: the bucket's AWS region, such as us-east-1`,
758
+ };
759
+ if (type === "r2")
760
+ return {
761
+ type,
762
+ accountId: `${TODO}: your Cloudflare account id`,
763
+ bucket: `${TODO}: your R2 bucket's name`,
764
+ };
765
+ return { type };
766
+ }
767
+
768
+ /**
769
+ * What differs between the storage types in the workflow: where the kit
770
+ * goes, the secrets the check and upload steps read, and the upload step.
771
+ */
772
+ function workflowStorage(type, kitDir) {
773
+ const uploadStep = (name, env) => ` - name: ${name}
774
+ if: hashFiles('${kitDir}/manifest.json') != ''
775
+ working-directory: \${{ runner.temp }}/design-kit-cli${env}
776
+ run: design-kit upload --config "$CONFIG" --dir "$GITHUB_WORKSPACE/${kitDir}"
777
+ `;
778
+ const awsEnv = (id, secret) => `
779
+ AWS_ACCESS_KEY_ID: \${{ secrets.${id} }}
780
+ AWS_SECRET_ACCESS_KEY: \${{ secrets.${secret} }}`;
781
+ if (type === "codepress")
782
+ return {
783
+ where:
784
+ "to CodePress, signed in with this workflow's GitHub OIDC token (no\n# secret)",
785
+ checkEnv: `
786
+ # Optional. Without a CodePress API key the check reads the newest
787
+ # kit with this job's GitHub OIDC token; if the read fails, it builds.
788
+ CODEPRESS_API_KEY: \${{ secrets.CODEPRESS_API_KEY }}`,
789
+ checkPermissions: "\n id-token: write",
790
+ buildPermissions: `
791
+ # id-token: write lets design-kit upload sign in to CodePress with
792
+ # GitHub's OIDC token. Only this job and the check get it.
793
+ permissions:
794
+ contents: read
795
+ id-token: write`,
796
+ upload: uploadStep("Upload the kit to CodePress", ""),
797
+ };
798
+ if (type === "s3")
799
+ return {
800
+ where:
801
+ "to your S3 bucket (the AWS_ACCESS_KEY_ID and\n# AWS_SECRET_ACCESS_KEY repository secrets)",
802
+ checkEnv: awsEnv("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"),
803
+ buildPermissions: "",
804
+ upload: uploadStep(
805
+ "Upload the kit to S3",
806
+ `\n env:${awsEnv("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY")}`,
807
+ ),
808
+ };
809
+ return {
810
+ where:
811
+ "to your Cloudflare R2 bucket (an R2 API token's\n# R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY repository secrets)",
812
+ checkEnv: awsEnv("R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY"),
813
+ buildPermissions: "",
814
+ upload: uploadStep(
815
+ "Upload the kit to R2",
816
+ `\n env:${awsEnv("R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY")}`,
817
+ ),
818
+ };
819
+ }
820
+
821
+ /**
822
+ * .github/workflows/design-kit.yml, as text. `storage` is the config's
823
+ * storage object; without one (or with github) the kit is an artifact.
824
+ */
825
+ export function buildWorkflow(found, storage = { type: "github" }) {
711
826
  const pm = found.packageManager;
712
827
  const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
713
828
  const projectDir = rel(found.project);
@@ -737,6 +852,10 @@ export function buildWorkflow(found) {
737
852
  ` - uses: actions/setup-node@v5\n with:\n ${node}${cache}${cachePath}`,
738
853
  );
739
854
 
855
+ const artifact = (storage?.type ?? "github") === "github";
856
+ const store = artifact
857
+ ? null
858
+ : workflowStorage(storage.type, at("design-kit"));
740
859
  const storiesStep = found.storybook
741
860
  ? `
742
861
  - name: Build Storybook and capture stories
@@ -749,13 +868,18 @@ export function buildWorkflow(found) {
749
868
 
750
869
  # Captures this repository's design kit: ${found.storybook ? "every Storybook story and " : ""}the pages
751
870
  # listed in ${at(CONFIG_NAME)}, as static HTML with the CSS and fonts they
752
- # load. It uploads the result as the \`design-kit\` artifact, which agents
753
- # download to draw mockups from the app's real markup.
871
+ # load. ${
872
+ artifact
873
+ ? `It uploads the result as the \`design-kit\` artifact, which agents
874
+ # download to draw mockups from the app's real markup.`
875
+ : `It uploads the result ${store.where} with \`design-kit upload\`,
876
+ # and agents fetch it with \`design-kit fetch\` to draw mockups from the
877
+ # app's real markup.`
878
+ }
754
879
  #
755
880
  # It runs daily, but builds only when a path in the config's "watch" list
756
881
  # changed since the newest kit's commit, when there is no kit yet, or when
757
- # the newest kit is DESIGN_KIT_MAX_AGE_DAYS old, so a kit never expires
758
- # unreplaced. After an upload it deletes the branch's older kits.
882
+ # the newest kit is DESIGN_KIT_MAX_AGE_DAYS old${artifact ? ", so a kit never expires\n# unreplaced. After an upload it deletes the branch's older kits." : "."}
759
883
  #
760
884
  # Written by \`design-kit init\` (${PACKAGE.name}); \`init --workflow-only
761
885
  # --force\` rewrites just this file. Edit it freely.
@@ -785,9 +909,13 @@ env:
785
909
  DESIGN_KIT_PACKAGE: "${PACKAGE.name}@${PACKAGE.version}"
786
910
  PLAYWRIGHT_PACKAGE: "@playwright/test@${playwrightPin()}"
787
911
  CONFIG: \${{ github.workspace }}/${at(CONFIG_NAME)}
788
- # Rebuild a kit this many days old even with no UI change. Kits are kept
912
+ # Rebuild a kit this many days old even with no UI change.${
913
+ artifact
914
+ ? ` Kits are kept
789
915
  # ${RETENTION_DAYS} days (retention-days below), so this refreshes the kit before it
790
- # expires.
916
+ # expires.`
917
+ : ""
918
+ }
791
919
  DESIGN_KIT_MAX_AGE_DAYS: "${DEFAULT_MAX_AGE_DAYS}"
792
920
 
793
921
  jobs:
@@ -796,8 +924,7 @@ jobs:
796
924
  runs-on: ubuntu-latest
797
925
  timeout-minutes: 10
798
926
  permissions:
799
- contents: read
800
- actions: read
927
+ contents: read${artifact ? "\n actions: read" : (store.checkPermissions ?? "")}
801
928
  outputs:
802
929
  build: \${{ steps.check.outputs.build }}
803
930
  steps:
@@ -816,8 +943,7 @@ jobs:
816
943
  node-version: "22"
817
944
 
818
945
  - id: check
819
- env:
820
- GH_TOKEN: \${{ github.token }}
946
+ env:${artifact ? "\n GH_TOKEN: ${{ github.token }}" : store.checkEnv}
821
947
  FORCE: \${{ inputs.force || 'false' }}
822
948
  PACKAGE_INPUT: \${{ inputs.package || '' }}
823
949
  run: |
@@ -831,9 +957,13 @@ jobs:
831
957
  needs: check
832
958
  if: needs.check.outputs.build == 'true'
833
959
  runs-on: ubuntu-latest
834
- timeout-minutes: 60
960
+ timeout-minutes: 60${
961
+ artifact
962
+ ? `
835
963
  outputs:
836
- artifact-id: \${{ steps.upload.outputs.artifact-id }}
964
+ artifact-id: \${{ steps.upload.outputs.artifact-id }}`
965
+ : store.buildPermissions
966
+ }
837
967
  steps:
838
968
  - uses: actions/checkout@v5
839
969
  with:
@@ -881,42 +1011,34 @@ ${storiesStep}
881
1011
  run: |
882
1012
  echo "::warning title=Design kit::Nothing to capture yet, so no kit was uploaded: ${at(CONFIG_NAME)} has no stories and its pages still have ${TODO} settings. Fill in the pages (or add Storybook) and run this workflow again."
883
1013
 
884
- - id: upload
885
- if: hashFiles('${at("design-kit")}/manifest.json') != ''
886
- uses: actions/upload-artifact@v5
887
- with:
888
- name: design-kit
889
- path: ${at("design-kit")}
890
- # The most a public repository allows. A private repository may
891
- # raise it to 400 (and DESIGN_KIT_MAX_AGE_DAYS with it).
892
- retention-days: ${RETENTION_DAYS}
893
- if-no-files-found: error
894
-
895
- # Deletes this branch's older kits once the new one is uploaded. The only
896
- # job that may delete artifacts; a failed delete is a warning.
897
- cleanup:
898
- name: Delete older kits
899
- needs: build
900
- if: needs.build.outputs.artifact-id != ''
901
- runs-on: ubuntu-latest
902
- timeout-minutes: 5
903
- permissions:
904
- actions: write
905
- steps:
906
- - uses: actions/setup-node@v5
907
- with:
908
- node-version: "22"
909
-
910
- - continue-on-error: true
911
- env:
912
- GH_TOKEN: \${{ github.token }}
913
- KEEP: \${{ needs.build.outputs.artifact-id }}
914
- run: npx --yes -p "$DESIGN_KIT_PACKAGE" design-kit ci-cleanup --keep "$KEEP"
915
- `;
1014
+ ${artifact ? ARTIFACT_STEPS(at("design-kit")) : store.upload}`;
916
1015
  }
917
1016
 
1017
+ /** Where the skill says the kit is, and how a reader signs in, per type. */
1018
+ const SKILL_STORAGE = {
1019
+ github: {
1020
+ where:
1021
+ "uploads the `design-kit` artifact (kept 90 days; older kits are\ndeleted).",
1022
+ auth: "The kit is a GitHub Actions artifact, so `fetch` uses the `gh` CLI, signed\nin with read access to this repository.",
1023
+ },
1024
+ codepress: {
1025
+ where:
1026
+ "uploads the kit to CodePress, which keeps the newest kit of each\nbranch.",
1027
+ auth: "The kit is stored in CodePress, so `fetch` needs a CodePress API key of an\norganization that connected this repository: set `CODEPRESS_API_KEY`, or\npass `--token-file <path>` naming a file that holds the key (the key alone,\nor a `CODEPRESS_API_KEY=` line). Without either it exits 1 and says so; a\nkey of another organization gets `forbidden`.",
1028
+ },
1029
+ s3: {
1030
+ where: "uploads the kit to this repository's own S3 bucket.",
1031
+ auth: "The kit is in this repository's S3 bucket, so `fetch` runs the `aws` CLI\nwith credentials that may read it (`AWS_ACCESS_KEY_ID` and\n`AWS_SECRET_ACCESS_KEY`, or an AWS profile).",
1032
+ },
1033
+ r2: {
1034
+ where: "uploads the kit to this repository's own Cloudflare R2 bucket.",
1035
+ auth: "The kit is in this repository's Cloudflare R2 bucket, so `fetch` runs the\n`aws` CLI with an R2 API token's keys as `AWS_ACCESS_KEY_ID` and\n`AWS_SECRET_ACCESS_KEY`.",
1036
+ },
1037
+ };
1038
+
918
1039
  /** .claude/skills/design-kit/SKILL.md, as text. */
919
- export function buildSkill(found) {
1040
+ export function buildSkill(found, storage = { type: "github" }) {
1041
+ const where = SKILL_STORAGE[storage?.type] ?? SKILL_STORAGE.github;
920
1042
  const pm = found.packageManager;
921
1043
  const projectDir = posix(relative(found.repoRoot, found.project)) || ".";
922
1044
  const cli = `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} -p @playwright/test@${playwrightPin()} design-kit`;
@@ -928,6 +1050,9 @@ export function buildSkill(found) {
928
1050
  ? `$DK capture stories --config "$CONFIG" --out "$KIT" # builds Storybook first`
929
1051
  : `# (no Storybook: this kit has pages only)`,
930
1052
  PACKAGE: PACKAGE.name,
1053
+ FETCH_CLI: `npx --yes -p ${PACKAGE.name}@${PACKAGE.version} design-kit`,
1054
+ STORAGE_WHERE: where.where,
1055
+ STORAGE_AUTH: where.auth,
931
1056
  INSTALL: installCommand(pm),
932
1057
  // The capture drives the project's own Playwright when it has one.
933
1058
  PLAYWRIGHT_INSTALL: found.playwright
@@ -961,18 +1086,23 @@ function instructionsFile(repoRoot) {
961
1086
  * The plan: every file init writes, with its content, and the notes for the
962
1087
  * summary. Nothing is written here.
963
1088
  */
964
- export function planInit(found) {
1089
+ export function planInit(found, storage) {
965
1090
  const at = (path) => join(found.repoRoot, path);
966
- const skill = buildSkill(found);
1091
+ const config = buildConfig(found, storage);
1092
+ const skill = buildSkill(found, config.storage);
967
1093
  const instructions = instructionsFile(found.repoRoot);
968
1094
  const instructionsText = readText(at(instructions));
969
1095
  const writes = [
970
1096
  {
971
1097
  path: join(found.project, CONFIG_NAME),
972
1098
  kind: "file",
973
- content: `${JSON.stringify(buildConfig(found), null, 2)}\n`,
1099
+ content: `${JSON.stringify(config, null, 2)}\n`,
1100
+ },
1101
+ {
1102
+ path: at(WORKFLOW_PATH),
1103
+ kind: "file",
1104
+ content: buildWorkflow(found, config.storage),
974
1105
  },
975
- { path: at(WORKFLOW_PATH), kind: "file", content: buildWorkflow(found) },
976
1106
  { path: at(`${SKILL_DIR}/SKILL.md`), kind: "file", content: skill },
977
1107
  agentsSkillMode(found.repoRoot) === "symlink"
978
1108
  ? {
@@ -1064,8 +1194,26 @@ function describe(found) {
1064
1194
  ];
1065
1195
  }
1066
1196
 
1067
- function todo(found) {
1068
- const items = [];
1197
+ /** What is left to do for a storage type, for the summary. */
1198
+ function storageTodo(storage) {
1199
+ const type = storage?.type;
1200
+ if (type === "codepress")
1201
+ return [
1202
+ `Storage: the kit uploads to CodePress with this workflow's GitHub OIDC token, so no secret is needed. Connect this repository in CodePress (https://codepress.dev) first. The daily check reads the newest kit with the same OIDC token (or a CODEPRESS_API_KEY repository secret, if you add one) and builds when it cannot; agents and scripts read with CODEPRESS_API_KEY or --token-file.`,
1203
+ ];
1204
+ if (type === "s3")
1205
+ return [
1206
+ `Storage: fill in storage.bucket and storage.region in ${CONFIG_NAME}, and add the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY repository secrets for a user that may read and write the bucket.`,
1207
+ ];
1208
+ if (type === "r2")
1209
+ return [
1210
+ `Storage: fill in storage.accountId and storage.bucket in ${CONFIG_NAME}, create an R2 API token with Object Read & Write on the bucket, and add its keys as the R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY repository secrets.`,
1211
+ ];
1212
+ return [];
1213
+ }
1214
+
1215
+ function todo(found, storage) {
1216
+ const items = [...storageTodo(storage)];
1069
1217
  const sb = found.storybook;
1070
1218
  if (!sb)
1071
1219
  items.push(
@@ -1095,7 +1243,7 @@ function todo(found) {
1095
1243
  return items;
1096
1244
  }
1097
1245
 
1098
- function summary(found, plan, { dryRun, workflowOnly, notes }) {
1246
+ function summary(found, plan, { dryRun, workflowOnly, notes, storage }) {
1099
1247
  const rel = (path) => posix(relative(found.repoRoot, path)) || ".";
1100
1248
  const lines = [
1101
1249
  `design-kit init${workflowOnly ? " --workflow-only" : ""}${dryRun ? " --dry-run" : ""}: ${found.project}`,
@@ -1121,7 +1269,7 @@ function summary(found, plan, { dryRun, workflowOnly, notes }) {
1121
1269
  lines.push(` ${rel(write.path)}${note}`);
1122
1270
  }
1123
1271
  lines.push("", "Still to decide");
1124
- for (const item of notes ?? todo(found)) lines.push(` - ${item}`);
1272
+ for (const item of notes ?? todo(found, storage)) lines.push(` - ${item}`);
1125
1273
  return lines.join("\n");
1126
1274
  }
1127
1275
 
@@ -1129,10 +1277,13 @@ function summary(found, plan, { dryRun, workflowOnly, notes }) {
1129
1277
 
1130
1278
  /**
1131
1279
  * `init --workflow-only`: rewrites .github/workflows/design-kit.yml from
1132
- * this version and nothing else. The config is left alone; when it has no
1133
- * `watch` list, the summary prints the one init would write.
1280
+ * this version, for the config's storage. The config is left alone, unless
1281
+ * `storage` (a type) is given: then its storage key is set and its $schema
1282
+ * moved to this version, in the same run as the workflow's new pin, since
1283
+ * older versions reject the key. When the config has no `watch` list, the
1284
+ * summary prints the one init would write.
1134
1285
  */
1135
- function initWorkflowOnly(found, { dryRun, force, log }) {
1286
+ function initWorkflowOnly(found, { dryRun, force, log, storage }) {
1136
1287
  const configFile = join(found.project, CONFIG_NAME);
1137
1288
  if (!exists(configFile))
1138
1289
  throw new InitError(
@@ -1143,12 +1294,39 @@ function initWorkflowOnly(found, { dryRun, force, log }) {
1143
1294
  throw new InitError(
1144
1295
  `${WORKFLOW_PATH} already exists. Nothing was written; pass --force with --workflow-only to replace it.`,
1145
1296
  );
1146
- const plan = {
1147
- writes: [{ path, kind: "file", content: buildWorkflow(found) }],
1148
- };
1149
- if (!dryRun) apply(plan.writes[0]);
1150
- const notes = [];
1151
1297
  const raw = readJson(configFile);
1298
+ if (storage && !raw)
1299
+ throw new InitError(
1300
+ `${configFile} is not valid JSON, so its storage key cannot be set. Fix it first.`,
1301
+ );
1302
+ const writes = [];
1303
+ let configStorage = raw?.storage;
1304
+ if (storage) {
1305
+ configStorage =
1306
+ raw.storage?.type === storage ? raw.storage : storageConfig(storage);
1307
+ // The key goes after "out", where init writes it; every other key and
1308
+ // its order stay as they are.
1309
+ const updated = {};
1310
+ for (const [key, value] of Object.entries(raw)) {
1311
+ if (key === "storage") continue;
1312
+ updated[key] = key === "$schema" ? SCHEMA_URL : value;
1313
+ if (key === "out") updated.storage = configStorage;
1314
+ }
1315
+ if (!("storage" in updated)) updated.storage = configStorage;
1316
+ writes.push({
1317
+ path: configFile,
1318
+ kind: "file",
1319
+ content: `${JSON.stringify(updated, null, 2)}\n`,
1320
+ });
1321
+ }
1322
+ writes.push({
1323
+ path,
1324
+ kind: "file",
1325
+ content: buildWorkflow(found, configStorage),
1326
+ });
1327
+ const plan = { writes };
1328
+ if (!dryRun) for (const write of writes) apply(write);
1329
+ const notes = [...(storage ? storageTodo(configStorage) : [])];
1152
1330
  if (!Array.isArray(raw?.watch) || !raw.watch.length)
1153
1331
  notes.push(
1154
1332
  `${CONFIG_NAME} has no "watch" list, so the kit rebuilds after any commit. To rebuild only when the UI changes, add:\n "watch": ${JSON.stringify(watchDefaults(found))}`,
@@ -1161,24 +1339,31 @@ function initWorkflowOnly(found, { dryRun, force, log }) {
1161
1339
  /**
1162
1340
  * Runs init. Options: `root` (the project folder, default the working
1163
1341
  * directory), `dryRun`, `force`, `workflowOnly` (write just the workflow, for
1164
- * a repository set up by an older version), `log`. Returns the exit code: 0
1165
- * done, 1 already set up or another file in the way.
1342
+ * a repository set up by an older version), `storage` (github, codepress,
1343
+ * s3 or r2: where the kit is stored), `log`. Returns the exit code: 0 done,
1344
+ * 1 already set up or another file in the way.
1166
1345
  */
1167
1346
  export function init({
1168
1347
  root = ".",
1169
1348
  dryRun = false,
1170
1349
  force = false,
1171
1350
  workflowOnly = false,
1351
+ storage,
1172
1352
  log = console.log,
1173
1353
  } = {}) {
1354
+ if (storage !== undefined && !STORAGE_TYPES.includes(storage))
1355
+ throw new ConfigError(
1356
+ `--storage takes ${STORAGE_TYPES.join(", ")}, got ${storage}`,
1357
+ );
1174
1358
  const found = detectProject(root);
1175
- if (workflowOnly) return initWorkflowOnly(found, { dryRun, force, log });
1176
- const plan = planInit(found);
1359
+ if (workflowOnly)
1360
+ return initWorkflowOnly(found, { dryRun, force, log, storage });
1361
+ const plan = planInit(found, storage);
1177
1362
  if (!force) {
1178
1363
  const config = join(found.project, CONFIG_NAME);
1179
1364
  if (exists(config))
1180
1365
  throw new InitError(
1181
- `already set up: ${config} exists. Nothing was written; pass --force to replace the files init writes.`,
1366
+ `already set up: ${config} exists. Nothing was written; pass --force to replace the files init writes${storage ? `, or run init --workflow-only --force --storage ${storage} to set where the kit is stored and rewrite only the workflow` : ""}.`,
1182
1367
  );
1183
1368
  const inTheWay = plan.writes
1184
1369
  .filter(
@@ -1199,7 +1384,12 @@ export function init({
1199
1384
  for (const write of plan.writes)
1200
1385
  if (write.kind !== "unchanged") apply(write);
1201
1386
  }
1202
- log(summary(found, plan, { dryRun }));
1387
+ log(
1388
+ summary(found, plan, {
1389
+ dryRun,
1390
+ storage: storage && storageConfig(storage),
1391
+ }),
1392
+ );
1203
1393
  if (!dryRun) log(`\n${CALL_TO_ACTION}`);
1204
1394
  return 0;
1205
1395
  }
package/src/kit.mjs CHANGED
@@ -711,7 +711,30 @@ export function inlineAssets(kit, css, cssDir = join(kit.dir, "css")) {
711
711
 
712
712
  // ── compose ─────────────────────────────────────────────────────────────────
713
713
 
714
- function expandSnippets(kit, html, used, budget, depth = 0) {
714
+ /**
715
+ * `data-kit-snippet="<id>"` on each top-level element of a snippet's markup,
716
+ * so the composed page records which real piece every part came from. A
717
+ * nested `<kit-snippet>` at the top is left alone: its own expansion stamps
718
+ * the elements that replace it.
719
+ */
720
+ function stampSnippetRoots(html, id) {
721
+ const roots = elements(html).filter(
722
+ (e) =>
723
+ e.depth === 0 &&
724
+ e.name !== "kit-snippet" &&
725
+ !("data-kit-snippet" in e.attrs),
726
+ );
727
+ let out = "";
728
+ let i = 0;
729
+ for (const root of roots) {
730
+ const at = root.start + 1 + root.name.length;
731
+ out += `${html.slice(i, at)} data-kit-snippet="${escapeAttr(id)}"`;
732
+ i = at;
733
+ }
734
+ return out + html.slice(i);
735
+ }
736
+
737
+ function expandSnippets(kit, html, used, budget, depth = 0, ids = []) {
715
738
  const found = elements(html).filter((e) => e.name === "kit-snippet");
716
739
  if (!found.length) return html;
717
740
  if (depth >= SNIPPET_DEPTH) {
@@ -735,9 +758,17 @@ function expandSnippets(kit, html, used, budget, depth = 0) {
735
758
  );
736
759
  }
737
760
  if (rowSource(row) === "stories") markup = unwrapStoryRoot(markup);
761
+ if (!ids.includes(row.id)) ids.push(row.id);
738
762
  out +=
739
763
  html.slice(i, element.start) +
740
- expandSnippets(kit, markup, used, budget, depth + 1);
764
+ expandSnippets(
765
+ kit,
766
+ stampSnippetRoots(markup, row.id),
767
+ used,
768
+ budget,
769
+ depth + 1,
770
+ ids,
771
+ );
741
772
  i = element.end;
742
773
  }
743
774
  return out + html.slice(i);
@@ -874,10 +905,17 @@ export function compose(
874
905
  }
875
906
  const used = new Set([rowCssFile(kit, row)]);
876
907
  const filled = fillSlots(kit, row, readSnippet(kit, row), slots);
877
- const body = expandSnippets(kit, filled, used, {
878
- left: maxBytes - filled.length,
879
- max: maxBytes,
880
- });
908
+ const snippetIds = [];
909
+ const body = sanitizeMarkup(
910
+ expandSnippets(
911
+ kit,
912
+ filled,
913
+ used,
914
+ { left: maxBytes - filled.length, max: maxBytes },
915
+ 0,
916
+ snippetIds,
917
+ ),
918
+ );
881
919
 
882
920
  // Stories' sheet first, then the route sheets, as before page sources.
883
921
  const storiesCss = cssFile(kit, "stories");
@@ -912,18 +950,27 @@ export function compose(
912
950
  "body",
913
951
  {},
914
952
  );
953
+ // What this page used, for whoever wants to count it later. Recorded in
954
+ // the page only; kit.mjs never sends it anywhere.
955
+ const usage = {
956
+ kit_sha: sha,
957
+ frame,
958
+ snippets: snippetIds,
959
+ new_parts: elements(body).filter((e) => "data-kit-new" in e.attrs).length,
960
+ };
915
961
  const page = `<!doctype html>
916
962
  <html${htmlAttrs}>
917
963
  <head>
918
964
  <meta charset="utf-8">
919
965
  <meta name="viewport" content="width=device-width, initial-scale=1">
966
+ <meta name="design-kit-usage" content="${escapeAttr(JSON.stringify(usage))}">
920
967
  <title>${escapeAttr(row.title ?? frame)}</title>
921
968
  <style>
922
969
  ${sanitizeCss(css)}${showNewCss}
923
970
  </style>
924
971
  </head>
925
972
  <body${bodyAttrs}>
926
- ${sanitizeMarkup(body)}
973
+ ${body}
927
974
  </body>
928
975
  </html>
929
976
  `;