@goodfellow-cms/gitlab 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -19,6 +19,20 @@ interface GitLabBackendOptions {
19
19
  branch: string;
20
20
  user: GitUser;
21
21
  onSignOut: () => void;
22
+ /** Who commits name as their author, where that isn't the token's owner, as on a full-stack site's server. */
23
+ author?: {
24
+ name: string;
25
+ email: string;
26
+ };
27
+ /** The commit reads see until `revision()` is next called, as a full-stack site's server reads what a browser loaded. */
28
+ revision?: string;
29
+ /** The project holds only the site's content, with its code in a project of its own (`GitBackend.contentOnly`). */
30
+ contentOnly?: boolean;
31
+ /** The code project, for a project that holds only content, where the site's updates run. */
32
+ code?: {
33
+ project: string;
34
+ webUrl: string;
35
+ };
22
36
  }
23
37
  /**
24
38
  * The site's files on GitLab. Reads come from the commit `revision()` last
@@ -29,8 +43,10 @@ export declare class GitLabBackend implements GitBackend {
29
43
  readonly user: GitUser;
30
44
  readonly pages: PagesDomains;
31
45
  readonly editors: SiteEditors;
32
- readonly updates: SiteUpdates;
46
+ /** Absent where the project holds only content: the update job runs where the code is. */
47
+ readonly updates?: SiteUpdates;
33
48
  readonly drafts: SiteDrafts;
49
+ readonly contentOnly: boolean;
34
50
  private readonly options;
35
51
  private readonly api;
36
52
  private readonly project;
@@ -130,6 +146,17 @@ export interface GitLabOptions {
130
146
  * one-click "Sign in with GitLab". Without it, people sign in with a token.
131
147
  */
132
148
  clientId?: string;
149
+ /**
150
+ * The project holds only the site's content (`content/` and
151
+ * `public/media/`), and its code is in a project of its own. The admin
152
+ * panel then leaves adding blocks, which changes code, to a developer.
153
+ */
154
+ contentOnly?: boolean;
155
+ /**
156
+ * With `contentOnly`, the project the site's code is kept in, where its
157
+ * updates run: the Updates screen follows them, for people who can see it.
158
+ */
159
+ code?: string;
133
160
  /** For tests. */
134
161
  fetch?: typeof fetch;
135
162
  /** For tests. */
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, SetupError, SignInError, UpdatesError, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
1
+ import { ConflictError, DomainError, DraftError, EDITABLE_FOLDERS, EditorsError, GitApiError, REBUILD_JOB, SetupError, SignInError, UpdatesError, assertContentChanges, branchDrafts, buildStepFor, credentialStorage, encodeBase64Bytes, findBuildCause, findUpdateResult } from "@goodfellow-cms/core";
2
2
  //#region src/api.ts
3
3
  /** Calls the GitLab API. A 401 refreshes the token once; after that it means the sign-in has expired. */
4
4
  async function gitlabRequest(api, path, init = {}) {
@@ -343,7 +343,7 @@ function notAllowed(error) {
343
343
  * It publishes with the job's own token, which GitLab only allows once the
344
344
  * project lets job tokens push.
345
345
  */
346
- function gitlabUpdates(api, project, branch, readAt) {
346
+ function gitlabUpdates(api, project, branch, readAt, jobName = "pages") {
347
347
  const base = `/projects/${encodeURIComponent(project)}`;
348
348
  async function schedules() {
349
349
  return gitlabJson(api, `${base}/pipeline_schedules`);
@@ -397,7 +397,7 @@ function gitlabUpdates(api, project, branch, readAt) {
397
397
  state: "running",
398
398
  ...run
399
399
  };
400
- const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === "pages");
400
+ const job = (await gitlabJson(api, `${base}/pipelines/${pipeline.id}/jobs?per_page=100`)).find((candidate) => candidate.name === jobName);
401
401
  if (!job) continue;
402
402
  const response = await gitlabRequest(api, `${base}/jobs/${job.id}/trace`, { allow: [403, 404] });
403
403
  const result = response.ok ? findUpdateResult(await response.text()) : void 0;
@@ -424,6 +424,64 @@ function gitlabUpdates(api, project, branch, readAt) {
424
424
  }
425
425
  };
426
426
  }
427
+ function noAccess(error) {
428
+ return error instanceof GitApiError && (error.status === 403 || error.status === 404);
429
+ }
430
+ /**
431
+ * The nightly update of a site whose content is kept in a project of its own,
432
+ * which runs in the code project's `update` job. The sign-in needs to see the
433
+ * code project too; where it can't, `setup()` says `no-access`.
434
+ */
435
+ function gitlabCodeUpdates(options) {
436
+ const { api, project } = options;
437
+ let branch;
438
+ const liveBranch = () => {
439
+ branch ??= gitlabJson(api, `/projects/${encodeURIComponent(project)}`).then((found) => found.default_branch);
440
+ branch.catch(() => {
441
+ branch = void 0;
442
+ });
443
+ return branch;
444
+ };
445
+ /** The code project at its newest commit, read again each time, since updates change it. */
446
+ const files = async () => options.open(await liveBranch());
447
+ /** A file as it is now: its setup files are outside the folders backends list, so they're read at the newest commit. */
448
+ const readNow = async (path) => {
449
+ const backend = await files();
450
+ return backend.readAt(path, await backend.revision());
451
+ };
452
+ const updates = async () => gitlabUpdates(api, project, await liveBranch(), readNow, "update");
453
+ return {
454
+ code: {
455
+ repo: project,
456
+ webUrl: `${options.webUrl}/${project}`,
457
+ read: readNow,
458
+ history: async (path, history) => (await files()).history(path, history),
459
+ readAt: async (path, revision) => (await files()).readAt(path, revision)
460
+ },
461
+ async setup() {
462
+ try {
463
+ return await (await updates()).setup();
464
+ } catch (error) {
465
+ if (noAccess(error)) return "no-access";
466
+ throw error;
467
+ }
468
+ },
469
+ async enable() {
470
+ await (await updates()).enable?.();
471
+ },
472
+ async lastRun() {
473
+ try {
474
+ return await (await updates()).lastRun();
475
+ } catch (error) {
476
+ if (noAccess(error)) return void 0;
477
+ throw error;
478
+ }
479
+ },
480
+ async start(target) {
481
+ await (await updates()).start(target);
482
+ }
483
+ };
484
+ }
427
485
  //#endregion
428
486
  //#region src/backend.ts
429
487
  const PAGE_SIZE = 100;
@@ -440,8 +498,10 @@ var GitLabBackend = class GitLabBackend {
440
498
  user;
441
499
  pages;
442
500
  editors;
501
+ /** Absent where the project holds only content: the update job runs where the code is. */
443
502
  updates;
444
503
  drafts;
504
+ contentOnly;
445
505
  options;
446
506
  api;
447
507
  project;
@@ -457,9 +517,26 @@ var GitLabBackend = class GitLabBackend {
457
517
  this.branch = options.branch;
458
518
  this.user = options.user;
459
519
  this.onSignOut = options.onSignOut;
520
+ this.pinned = options.revision;
460
521
  this.pages = gitlabPages(this.api, options.project);
461
522
  this.editors = gitlabEditors(this.api, options.project, this.branch, this.user.login);
462
- this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
523
+ this.contentOnly = options.contentOnly === true;
524
+ if (!this.contentOnly) this.updates = gitlabUpdates(this.api, options.project, this.branch, async (path) => this.readAt(path, this.pinned ?? await this.revision()));
525
+ else if (options.code) {
526
+ const code = options.code;
527
+ this.updates = gitlabCodeUpdates({
528
+ api: this.api,
529
+ project: code.project,
530
+ webUrl: code.webUrl,
531
+ open: (branch) => new GitLabBackend({
532
+ api: this.api,
533
+ project: code.project,
534
+ branch,
535
+ user: this.user,
536
+ onSignOut: () => {}
537
+ })
538
+ });
539
+ }
463
540
  this.drafts = branchDrafts(this, this.draftHost());
464
541
  }
465
542
  /** Drafts on branches of the project, with merge requests for review. */
@@ -645,6 +722,7 @@ var GitLabBackend = class GitLabBackend {
645
722
  return (await gitlabJson(this.api, this.repo(`/files/${encodeURIComponent(path)}?ref=${ref}`))).last_commit_id;
646
723
  }
647
724
  async write(changes, { message, expectedRevision }) {
725
+ if (this.contentOnly) assertContentChanges(changes);
648
726
  if (await this.revision() !== expectedRevision) throw new ConflictError();
649
727
  const existing = await this.tree(expectedRevision);
650
728
  const toApply = (await Promise.all(changes.map(async (change) => {
@@ -676,7 +754,11 @@ var GitLabBackend = class GitLabBackend {
676
754
  body: {
677
755
  branch: this.branch,
678
756
  commit_message: message,
679
- actions: toApply
757
+ actions: toApply,
758
+ ...this.options.author && {
759
+ author_name: this.options.author.name,
760
+ author_email: this.options.author.email
761
+ }
680
762
  }
681
763
  });
682
764
  this.pinned = commit.id;
@@ -720,7 +802,9 @@ var GitLabBackend = class GitLabBackend {
720
802
  */
721
803
  async deployStatus(revision) {
722
804
  try {
723
- const statuses = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
805
+ const all = await gitlabJson(this.api, this.repo(`/commits/${revision}/statuses`));
806
+ const statuses = all.filter((status) => status.name !== REBUILD_JOB);
807
+ if (all.length > 0 && statuses.length === 0) return { state: "unknown" };
724
808
  if (statuses.length === 0) return { state: (await gitlabJson(this.api, `/projects/${this.project}/pipelines?per_page=1`)).length > 0 ? "building" : "unknown" };
725
809
  const failed = statuses.find((status) => status.status === "failed" || status.status === "canceled");
726
810
  if (failed) {
@@ -863,6 +947,46 @@ function gitlabSetup(options = {}) {
863
947
  async function isAvailable(owner, name) {
864
948
  return (await gitlabRequest(api, `/projects/${encodeURIComponent(`${owner.path}/${name}`)}`, { allow: [404] })).status === 404;
865
949
  }
950
+ /**
951
+ * Lets each project's pipelines reach the other, as their setup files say:
952
+ * a job token is refused by a project whose job token allowlist doesn't
953
+ * name its project, even a public one. Also lets the code project's job
954
+ * token publish updates, and runs pipelines each night where they need it.
955
+ * Returns whether it worked.
956
+ */
957
+ async function connectProjects(link) {
958
+ const project = (path) => `/projects/${encodeURIComponent(path)}`;
959
+ try {
960
+ const ids = /* @__PURE__ */ new Map();
961
+ for (const which of ["code", "content"]) ids.set(which, (await gitlabJson(api, project(link[which]))).id);
962
+ for (const { reader, read } of link.links.reads) await gitlabRequest(api, `${project(link[read])}/job_token_scope/allowlist`, {
963
+ method: "POST",
964
+ body: { target_project_id: ids.get(reader) }
965
+ });
966
+ if (link.links.updates) await gitlabRequest(api, project(link[link.links.updates]), {
967
+ method: "PUT",
968
+ body: { ci_push_repository_for_job_token_allowed: true }
969
+ });
970
+ for (const which of link.links.nightly) await gitlabRequest(api, `${project(link[which])}/pipeline_schedules`, {
971
+ method: "POST",
972
+ body: {
973
+ description: which === link.links.updates ? "Rebuild the site and install Goodfellow's fixes each night" : "Rebuild the site each night",
974
+ ref: MAIN,
975
+ cron: NIGHTLY,
976
+ cron_timezone: "UTC",
977
+ active: true
978
+ }
979
+ });
980
+ return true;
981
+ } catch (error) {
982
+ if (error instanceof GitApiError && [
983
+ 400,
984
+ 403,
985
+ 404
986
+ ].includes(error.status)) return false;
987
+ throw error;
988
+ }
989
+ }
866
990
  return {
867
991
  user: {
868
992
  login: user.username,
@@ -900,6 +1024,11 @@ function gitlabSetup(options = {}) {
900
1024
  if (error instanceof GitApiError && (error.status === 403 || error.status === 404)) throw new SetupError("not-allowed", `This sign-in can't create projects in ${site.owner.path}.`);
901
1025
  throw error;
902
1026
  }
1027
+ const warnings = [];
1028
+ if (site.link) {
1029
+ onStep?.("connect");
1030
+ if (!await connectProjects(site.link)) warnings.push("connect-unavailable");
1031
+ }
903
1032
  onStep?.("files");
904
1033
  await gitlabRequest(api, `/projects/${project.id}/repository/commits`, {
905
1034
  method: "POST",
@@ -923,7 +1052,7 @@ function gitlabSetup(options = {}) {
923
1052
  webUrl: project.web_url,
924
1053
  buildsUrl: `${project.web_url}/-/pipelines`,
925
1054
  pagesUrl: site.pages ? `${project.web_url}/pages` : void 0,
926
- warnings: []
1055
+ warnings
927
1056
  };
928
1057
  }
929
1058
  };
@@ -1056,6 +1185,11 @@ function gitlab(options) {
1056
1185
  api,
1057
1186
  project: options.project,
1058
1187
  branch: options.branch ?? project.default_branch,
1188
+ ...options.contentOnly && { contentOnly: true },
1189
+ ...options.contentOnly && options.code && { code: {
1190
+ project: options.code,
1191
+ webUrl
1192
+ } },
1059
1193
  user: {
1060
1194
  login: user.username,
1061
1195
  name: user.name,
@@ -1092,6 +1226,43 @@ function gitlab(options) {
1092
1226
  code_verifier: redirect.verifier
1093
1227
  }), redirect.verifier), redirect.remember);
1094
1228
  }
1229
+ /** The project through a token of the site's own, such as a project access token, on a full-stack site's server. */
1230
+ function server({ token }) {
1231
+ const api = {
1232
+ apiUrl,
1233
+ token: async () => typeof token === "string" ? token : token(),
1234
+ canRefresh: false,
1235
+ fetch: fetcher
1236
+ };
1237
+ let branch;
1238
+ const liveBranch = () => {
1239
+ branch ??= options.branch ? Promise.resolve(options.branch) : gitlabJson(api, `/projects/${encodeURIComponent(options.project)}`).then((project) => project.default_branch);
1240
+ branch.catch(() => {
1241
+ branch = void 0;
1242
+ });
1243
+ return branch;
1244
+ };
1245
+ const backend = async (revision, author) => new GitLabBackend({
1246
+ api,
1247
+ project: options.project,
1248
+ branch: await liveBranch(),
1249
+ ...options.contentOnly && { contentOnly: true },
1250
+ user: {
1251
+ login: author?.name ?? options.project,
1252
+ ...author && { name: author.name }
1253
+ },
1254
+ onSignOut: () => {},
1255
+ ...revision && { revision },
1256
+ ...author && { author }
1257
+ });
1258
+ return {
1259
+ ...options.contentOnly && { contentOnly: true },
1260
+ async revision() {
1261
+ return (await backend()).revision();
1262
+ },
1263
+ at: backend
1264
+ };
1265
+ }
1095
1266
  return {
1096
1267
  name: "GitLab",
1097
1268
  canRedirect: Boolean(options.clientId),
@@ -1145,7 +1316,8 @@ function gitlab(options) {
1145
1316
  code_challenge_method: "S256"
1146
1317
  });
1147
1318
  location().assign(`${webUrl}/oauth/authorize?${query}`);
1148
- }
1319
+ },
1320
+ server
1149
1321
  };
1150
1322
  }
1151
1323
  //#endregion
package/dist/testing.d.ts CHANGED
@@ -21,6 +21,10 @@ export interface FakeGitLabOptions {
21
21
  };
22
22
  /** Simulates a GitLab Pages pipeline that succeeds after being checked this many times. Off by default. */
23
23
  deployAfterChecks?: number;
24
+ /** The job each published commit's pipeline runs: `pages` by default. */
25
+ deployJob?: string;
26
+ /** The job the update runs in, which `addUpdateRun()` logs: `pages` by default, as in the starters, or a code project's `update`. */
27
+ updateJob?: string;
24
28
  /** Groups the users belong to, which new projects can be created in. */
25
29
  groups?: string[];
26
30
  /** Publishes the site with GitLab Pages. Off by default. */
@@ -63,6 +67,16 @@ export interface FakeCreatedProject {
63
67
  description?: string;
64
68
  pagesAccessLevel?: string;
65
69
  repo: FakeRepo;
70
+ /** The projects whose job tokens may reach this one (its job token allowlist), by id. */
71
+ jobTokenAllowlist: number[];
72
+ /** Whether its pipelines' job tokens may push to it. */
73
+ jobTokenPush: boolean;
74
+ schedules: Array<{
75
+ description: string;
76
+ ref: string;
77
+ cron: string;
78
+ active: boolean;
79
+ }>;
66
80
  }
67
81
  export declare function fakeGitLab(options: FakeGitLabOptions): {
68
82
  /** How many GraphQL requests read files, as `readFiles()` makes. */
@@ -123,6 +137,11 @@ export declare function fakeGitLab(options: FakeGitLabOptions): {
123
137
  created: Map<string, FakeCreatedProject>;
124
138
  /** The site's GitLab Pages, if it has them, which tests can change. */
125
139
  pages: FakeGitLabPages | undefined;
140
+ /** Who commits that named their author did name, by commit. */
141
+ commitAuthors: Map<string, {
142
+ name: string;
143
+ email: string;
144
+ }>;
126
145
  requests: Request[];
127
146
  commit: (changes: FileChange[], message: string, author?: string, branch?: string) => string;
128
147
  authorize: (authorizeUrl: string) => string;
package/dist/testing.js CHANGED
@@ -20,6 +20,8 @@ async function sha256Base64Url(text) {
20
20
  function fakeGitLab(options) {
21
21
  const webUrl = options.url ?? "https://gitlab.com";
22
22
  const repo = new FakeRepo(options.files);
23
+ /** Who each commit that named its author did name, by commit. */
24
+ const commitAuthors = /* @__PURE__ */ new Map();
23
25
  const tokens = new Map(Object.entries(options.tokens ?? { "test-token": {
24
26
  username: "editor",
25
27
  name: "Test Editor"
@@ -297,7 +299,10 @@ function fakeGitLab(options) {
297
299
  visibility: body.visibility,
298
300
  description: body.description,
299
301
  pagesAccessLevel: body.pages_access_level,
300
- repo: new FakeRepo({})
302
+ repo: new FakeRepo({}),
303
+ jobTokenAllowlist: [],
304
+ jobTokenPush: false,
305
+ schedules: []
301
306
  };
302
307
  created.set(fullPath, project);
303
308
  return json({
@@ -311,11 +316,40 @@ function fakeGitLab(options) {
311
316
  const project = created.get(key) ?? [...created.values()].find((candidate) => String(candidate.id) === key);
312
317
  if (!match || !project) return void 0;
313
318
  const rest = match[2] ?? "";
314
- if (rest === "") return json({
319
+ if (rest === "" && request.method === "GET") return json({
315
320
  id: project.id,
316
321
  path_with_namespace: project.path,
317
- default_branch: "main"
322
+ default_branch: "main",
323
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
318
324
  });
325
+ if (rest === "" && request.method === "PUT") {
326
+ const body = await request.json();
327
+ if (body.ci_push_repository_for_job_token_allowed !== void 0) project.jobTokenPush = body.ci_push_repository_for_job_token_allowed;
328
+ return json({
329
+ id: project.id,
330
+ ci_push_repository_for_job_token_allowed: project.jobTokenPush
331
+ });
332
+ }
333
+ if (rest === "/job_token_scope/allowlist" && request.method === "POST") {
334
+ const body = await request.json();
335
+ if (![...created.values()].some((candidate) => candidate.id === body.target_project_id)) return json({ message: "404 Project Not Found" }, 404);
336
+ if (!project.jobTokenAllowlist.includes(body.target_project_id)) project.jobTokenAllowlist.push(body.target_project_id);
337
+ return json({
338
+ source_project_id: project.id,
339
+ target_project_id: body.target_project_id
340
+ }, 201);
341
+ }
342
+ if (rest === "/pipeline_schedules" && request.method === "POST") {
343
+ const body = await request.json();
344
+ project.schedules.push({
345
+ ...body,
346
+ active: body.active !== false
347
+ });
348
+ return json({
349
+ id: project.schedules.length,
350
+ ...body
351
+ }, 201);
352
+ }
319
353
  if (rest === "/repository/commits" && request.method === "POST") {
320
354
  const body = await request.json();
321
355
  return json({
@@ -549,17 +583,22 @@ function fakeGitLab(options) {
549
583
  if ((action.action === "update" || action.action === "delete") && !files.has(action.file_path)) return json({ message: "A file with this name doesn't exist" }, 400);
550
584
  if (action.last_commit_id && repo.lastCommitFor(action.file_path, body.branch) !== action.last_commit_id) return json({ message: "You are attempting to update a file that has changed since you started editing it." }, 400);
551
585
  }
586
+ const sha = commit(body.actions.map((action) => action.action === "delete" ? {
587
+ path: action.file_path,
588
+ delete: true
589
+ } : action.encoding === "base64" ? {
590
+ path: action.file_path,
591
+ bytes: decodeBase64(action.content ?? "")
592
+ } : {
593
+ path: action.file_path,
594
+ content: action.content ?? ""
595
+ }), body.commit_message, body.author_name ?? user.name ?? user.username, body.branch);
596
+ if (body.author_name && body.author_email) commitAuthors.set(sha, {
597
+ name: body.author_name,
598
+ email: body.author_email
599
+ });
552
600
  return json({
553
- id: commit(body.actions.map((action) => action.action === "delete" ? {
554
- path: action.file_path,
555
- delete: true
556
- } : action.encoding === "base64" ? {
557
- path: action.file_path,
558
- bytes: decodeBase64(action.content ?? "")
559
- } : {
560
- path: action.file_path,
561
- content: action.content ?? ""
562
- }), body.commit_message, user.name ?? user.username, body.branch),
601
+ id: sha,
563
602
  message: body.commit_message
564
603
  }, 201);
565
604
  }
@@ -596,9 +635,10 @@ function fakeGitLab(options) {
596
635
  }]);
597
636
  }
598
637
  pipeline.checks += 1;
638
+ const done = pipeline.checks > (options.deployAfterChecks ?? 0);
599
639
  return json([{
600
- name: "pages",
601
- status: pipeline.checks > (options.deployAfterChecks ?? 0) ? "success" : "running",
640
+ name: options.deployJob ?? "pages",
641
+ status: done ? "success" : "running",
602
642
  target_url: `${webUrl}/${options.project}/-/jobs/1`
603
643
  }]);
604
644
  }
@@ -647,7 +687,7 @@ function fakeGitLab(options) {
647
687
  const run = updateRuns.find((candidate) => candidate.id === Number(updateJobs[1]));
648
688
  return run ? json([{
649
689
  id: run.id,
650
- name: "pages",
690
+ name: options.updateJob ?? "pages",
651
691
  status: run.status
652
692
  }]) : json({ message: "404 Not Found" }, 404);
653
693
  }
@@ -707,6 +747,8 @@ function fakeGitLab(options) {
707
747
  created,
708
748
  /** The site's GitLab Pages, if it has them, which tests can change. */
709
749
  pages,
750
+ /** Who commits that named their author did name, by commit. */
751
+ commitAuthors,
710
752
  requests,
711
753
  commit,
712
754
  authorize,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/gitlab",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Edit a Goodfellow site stored on GitLab, straight from the browser.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -35,7 +35,7 @@
35
35
  "dist"
36
36
  ],
37
37
  "dependencies": {
38
- "@goodfellow-cms/core": "0.3.0"
38
+ "@goodfellow-cms/core": "0.4.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@puckeditor/core": "0.23.0"