@preprio/toolkit 0.5.0-rc.5 → 0.5.0-rc.7

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.
Files changed (38) hide show
  1. package/README.md +133 -26
  2. package/dist/astro/index.cjs +126 -30
  3. package/dist/astro/index.js +130 -30
  4. package/dist/{chunk-3X55JL52.js → chunk-HVS5QLWG.js} +1 -1
  5. package/dist/{chunk-TG6N3C22.js → chunk-IPMB32AZ.js} +1 -1
  6. package/dist/{chunk-CMUFTCG3.js → chunk-JUXP5KRX.js} +1 -1
  7. package/dist/{chunk-74KY5EAM.js → chunk-KD3GK6TO.js} +2 -0
  8. package/dist/{chunk-SBLZANL5.js → chunk-SUAFASBB.js} +11 -3
  9. package/dist/codegen/index.cjs +82 -0
  10. package/dist/codegen/index.d.cts +70 -0
  11. package/dist/codegen/index.d.ts +70 -0
  12. package/dist/codegen/index.js +64 -0
  13. package/dist/index.cjs +133 -28
  14. package/dist/index.d.cts +14 -2
  15. package/dist/index.d.ts +14 -2
  16. package/dist/index.js +108 -6
  17. package/dist/{live-preview-H4643QYF.js → live-preview-DLXGXRE5.js} +2 -2
  18. package/dist/nextjs/components.cjs +11 -3
  19. package/dist/nextjs/components.js +2 -2
  20. package/dist/nextjs/index.cjs +124 -28
  21. package/dist/nextjs/index.js +128 -28
  22. package/dist/nextjs/live-action.js +3 -3
  23. package/dist/nextjs/live.js +1 -1
  24. package/dist/nuxt/chunk-BWPGO25E.js +340 -0
  25. package/dist/nuxt/index.cjs +1 -1
  26. package/dist/nuxt/index.js +1 -1
  27. package/dist/nuxt/{live-preview-O2ZTJNW4.js → live-preview-2RCU35W5.js} +6 -337
  28. package/dist/nuxt/live.cjs +1007 -911
  29. package/dist/nuxt/live.js +109 -5
  30. package/dist/react/components.cjs +11 -3
  31. package/dist/react/components.js +11 -3
  32. package/dist/sveltekit/chunk-BWPGO25E.js +340 -0
  33. package/dist/sveltekit/index.cjs +1 -1
  34. package/dist/sveltekit/index.js +1 -1
  35. package/dist/sveltekit/{live-preview-O2ZTJNW4.js → live-preview-2RCU35W5.js} +6 -337
  36. package/dist/sveltekit/live.cjs +1008 -909
  37. package/dist/sveltekit/live.js +118 -11
  38. package/package.json +11 -1
package/README.md CHANGED
@@ -127,6 +127,7 @@ Per-framework peer dependencies — all optional; installing without them is fin
127
127
  | `@preprio/toolkit/astro` | none (`.astro` components compile in your own pipeline) |
128
128
  | `@preprio/toolkit/sveltekit` | `svelte` (only for the `.svelte` components) |
129
129
  | `@preprio/toolkit/sveltekit/live` | none (returns a plain Svelte store) |
130
+ | `@preprio/toolkit/codegen` | `graphql` >= 16 (build time, from your Codegen setup) |
130
131
  | `@preprio/toolkit/nuxt` | `vue` (only for the `.vue` components) |
131
132
  | `@preprio/toolkit/nuxt/live` | `vue` >= 3.3 |
132
133
 
@@ -621,23 +622,22 @@ const location = useLocation();
621
622
 
622
623
  ### Tracking pageviews
623
624
 
624
- `loadTrackingPixel` queues one `pageload` event when it installs. Client-side route changes emit nothing on their own, so fire them yourself:
625
+ The pixel tracks pageviews by itself, client-side route changes included. It counts a view for the Prepr content item named by a `prepr:id` meta tag in `<head>`, and watches that tag: when a route change points it at another item, the pixel records a new view. Render the tag on every page that shows a Prepr item:
625
626
 
626
627
  ```tsx
627
- import { useEffect } from 'react';
628
- import { useLocation } from 'react-router';
629
- import { trackEvent } from '@preprio/toolkit';
630
-
631
- function usePreprPageviews() {
632
- const { pathname } = useLocation();
633
-
634
- useEffect(() => {
635
- trackEvent('pageload');
636
- }, [pathname]);
628
+ function Page({ page }: { page: { _id: string } }) {
629
+ return (
630
+ <>
631
+ <meta property="prepr:id" content={page._id} />
632
+ {/* … */}
633
+ </>
634
+ );
637
635
  }
638
636
  ```
639
637
 
640
- This double-counts the first pageview, since the pixel's own install event fires too. Skip the initial run with a ref if the exact count matters.
638
+ React 19 hoists a `<meta>` rendered anywhere in the tree into `<head>`. On React 18, set it with your head manager (`react-helmet-async` or similar) instead.
639
+
640
+ A page without the tag records no view. Calling `trackEvent('pageload')` yourself does not help: the pixel takes one `pageload` per page load and ignores the rest. Use `trackEvent` for your own events, such as `trackEvent('add_to_cart', { productId })`.
641
641
 
642
642
  The `id` is your access token, and in a client-rendered app it is public — bundled into JS and visible in devtools. Use a tracking-only token; do not derive it from a browser-exposed GraphQL URL, which would also expose content read access.
643
643
 
@@ -729,22 +729,129 @@ exactly these.
729
729
 
730
730
  Live updates and click-to-edit find content by id, never by position, so
731
731
  they stay correct while an editor adds, removes or reorders blocks before
732
- saving:
732
+ saving. Every model, component and dynamic content entry that should
733
+ live-update needs `_id` (and ideally `__typename`) in the query result; items
734
+ without `_id` stay static.
735
+
736
+ There are three ways to get them there. Pick the first that fits your setup.
737
+
738
+ #### Option 1: GraphQL Codegen (recommended)
739
+
740
+ Add the `preprIds` document transform to your Codegen output. `_id` is
741
+ added wherever your schema has one: on models and components, and inside
742
+ each member of a dynamic content field. Collections like `Pages` and unions
743
+ themselves never get an `_id`, so the queries stay valid. Your `.graphql`
744
+ files stay as they are; the generated documents and types include the field,
745
+ at no runtime cost. Adding a field never breaks code that compiles today, so
746
+ this is safe to turn on in an existing project.
747
+
748
+ ```ts
749
+ // codegen.ts
750
+ import type { CodegenConfig } from '@graphql-codegen/cli';
751
+ import { preprIds } from '@preprio/toolkit/codegen';
752
+
753
+ const config: CodegenConfig = {
754
+ schema: process.env.PREPR_GRAPHQL_URL,
755
+ documents: ['src/queries/**/*.graphql'],
756
+ generates: {
757
+ 'src/gql/': {
758
+ preset: 'client',
759
+ documentTransforms: [preprIds],
760
+ },
761
+ },
762
+ };
763
+
764
+ export default config;
765
+ ```
766
+
767
+ Run Codegen again after adding it. Needs `graphql` 16 or later, which a
768
+ Codegen setup already has.
769
+
770
+ To add `__typename` everywhere too, use `createPreprIds({ typename: true })`
771
+ instead. It is off by default because a selected `__typename` changes the
772
+ generated types: each member of a union is typed on its own, so code that
773
+ reads a union's fields without checking `__typename` first stops compiling.
774
+ Live preview only needs it on dynamic content entries, where you select it
775
+ already to render them.
776
+
777
+ #### Option 2: at runtime, without Codegen
778
+
779
+ `addPreprIds(document, schema)` does the same to a parsed query. It needs
780
+ your schema, which Prepr serves through introspection on the same GraphQL
781
+ URL. Load it once on the server, and transform each query once:
782
+
783
+ ```ts
784
+ import { addPreprIds } from '@preprio/toolkit/codegen';
785
+ import {
786
+ buildClientSchema,
787
+ getIntrospectionQuery,
788
+ parse,
789
+ print,
790
+ type GraphQLSchema,
791
+ } from 'graphql';
792
+
793
+ const url = process.env.PREPR_GRAPHQL_URL!;
794
+
795
+ let schema: Promise<GraphQLSchema> | undefined;
796
+ function preprSchema(): Promise<GraphQLSchema> {
797
+ schema ??= fetch(url, {
798
+ method: 'POST',
799
+ headers: { 'Content-Type': 'application/json' },
800
+ body: JSON.stringify({ query: getIntrospectionQuery() }),
801
+ })
802
+ .then((response) => response.json())
803
+ .then(({ data }) => buildClientSchema(data));
804
+ return schema;
805
+ }
806
+
807
+ const prepared = new Map<string, string>();
808
+ export async function withPreprIds(query: string): Promise<string> {
809
+ let result = prepared.get(query);
810
+ if (!result) {
811
+ result = print(addPreprIds(parse(query), await preprSchema()));
812
+ prepared.set(query, result);
813
+ }
814
+ return result;
815
+ }
816
+ ```
817
+
818
+ Then send `await withPreprIds(query)` instead of `query` in your fetch
819
+ helper. With a client that takes a `DocumentNode`, pass
820
+ `addPreprIds(document, schema)` instead. Keep this on the server: the
821
+ GraphQL URL carries your access token.
822
+
823
+ #### Option 3: by hand
824
+
825
+ Select `_id` (and `__typename`) on every model and component level, and on
826
+ every entry type in dynamic content fields (`Text`, `Quote`, …). `Assets`
827
+ entries have no `_id` of their own: select `items { _id }` instead.
828
+
829
+ The rest is about rendering:
733
830
 
734
- - Select `_id` (and ideally `__typename`) on every model and component level
735
- that should live-update. Items without `_id` stay static.
736
- - In dynamic content fields, select `_id` on every entry type (`Text`,
737
- `Quote`, …). `Assets` entries have no `_id` of their own: select
738
- `items { _id }` instead.
739
831
  - A `Text` entry's content updates whether the query selects `body`,
740
832
  `html` or `text` (the latter with tags stripped).
741
- - Render each dynamic content entry inside an element marked with
742
- `data-prepr-edit-target="<field>.<index>"` and
743
- `data-prepr-entry-id="<entry _id>"` (for `Assets`, the first asset's
744
- `_id`). The index is the fallback when no id matches.
745
- - Values without a hidden edit link (images, videos, other URLs) are made
746
- click-to-edit with `previewAttrs({ id, field }, { enabled: preview })`
747
- on the element that renders them.
833
+ - **Dynamic content entries are found automatically.** In preview,
834
+ `normalizeQuery`, `normalizedQuery` and `useNormalizedQuery` add each
835
+ entry's `_id` (and, for `Text` entries, the field path) to the hidden edit
836
+ link of every text inside it. Click-to-edit and the editor's "jump to
837
+ field" then find the entry by id, also after an editor reorders entries.
838
+ Render a `Text` entry's HTML straight into one wrapper element, so its
839
+ edit link lands on that wrapper and the whole entry is outlined.
840
+ - Prepr images are click-to-edit inside the editor without any markup: the
841
+ toolkit matches image URLs (also through image optimizers like
842
+ `/_next/image`) to the open item's assets.
843
+ - **Set the attributes yourself only where that does not reach:**
844
+ - Data you fetch in the browser without those helpers: run it through
845
+ `annotateEditLinks(result, query)` from `@preprio/toolkit`, or mark the
846
+ element as below.
847
+ - An entry with no text and no Prepr image (an embed, a video URL): wrap
848
+ it in an element with `data-prepr-edit-target="<field>.<index>"` and
849
+ `data-prepr-entry-id="<entry _id>"` (for `Assets`, the first asset's
850
+ `_id`). Attributes you set always win over the automatic ones.
851
+ - Other values without a hidden edit link (video and other URLs): make
852
+ them click-to-edit with
853
+ `previewAttrs({ id, field }, { enabled: preview })` on the element that
854
+ renders them.
748
855
  - Where the page renders a linked item whose content comes from items nested
749
856
  inside it (for example a personalization or a shared section referenced
750
857
  from a stack), wrap it in an element with
@@ -1105,7 +1212,7 @@ createPreprPreview({ props, options: { locale: 'nl' } });
1105
1212
 
1106
1213
  - **Check `enabled`**: every live preview entry point needs your preview flag, and the middleware must run with `preview: true` for that page.
1107
1214
  - **Check the preview URL in Prepr**: it must point at a deployment that uses the GraphQL Preview token. A production build with preview off shows saved content only.
1108
- - **Check `_id`**: an item or component whose query does not select `_id` stays static. See [Preparing your queries](#preparing-your-queries).
1215
+ - **Check `_id`**: an item or component whose query does not select `_id` stays static. Add the `preprIds` Codegen transform, or see [Preparing your queries](#preparing-your-queries).
1109
1216
  - **Check the frame**: a `Refused to frame` error in the browser console means your `Content-Security-Policy` `frame-ancestors` does not list your Prepr environment.
1110
1217
  - **Next.js only shows changes on save**: `normalizeQuery` with `PreprRefresh` needs Next.js 16 or later.
1111
1218
 
@@ -31,7 +31,7 @@ __export(astro_exports, {
31
31
  module.exports = __toCommonJS(astro_exports);
32
32
 
33
33
  // src/version.ts
34
- var VERSION = "0.5.0-rc.5";
34
+ var VERSION = "0.5.0-rc.7";
35
35
 
36
36
  // src/core/features.ts
37
37
  function isEnabled(config) {
@@ -430,14 +430,14 @@ function indexSnapshot(data) {
430
430
  const index = /* @__PURE__ */ new Map();
431
431
  const visited = /* @__PURE__ */ new WeakSet();
432
432
  const path = [];
433
- function walk(node) {
433
+ function walk2(node) {
434
434
  if (node === null || typeof node !== "object") return;
435
435
  if (visited.has(node)) return;
436
436
  visited.add(node);
437
437
  if (Array.isArray(node)) {
438
438
  for (let i = 0; i < node.length; i++) {
439
439
  path.push(i);
440
- walk(node[i]);
440
+ walk2(node[i]);
441
441
  path.pop();
442
442
  }
443
443
  return;
@@ -455,11 +455,11 @@ function indexSnapshot(data) {
455
455
  for (const key in record) {
456
456
  if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
457
457
  path.push(key);
458
- walk(record[key]);
458
+ walk2(record[key]);
459
459
  path.pop();
460
460
  }
461
461
  }
462
- walk(data);
462
+ walk2(data);
463
463
  return index;
464
464
  }
465
465
 
@@ -916,12 +916,12 @@ function resolveFields(selections, typename, doc, options) {
916
916
  }
917
917
  };
918
918
  const applies = (on, list) => conditionMatches(on, typename) || typename !== void 0 && typeConditions(list, doc).includes(typename);
919
- const walk = (list, matched) => {
919
+ const walk2 = (list, matched) => {
920
920
  for (const s of list) {
921
921
  if (s.kind === "field") {
922
922
  add(matched ? exact : loose, s);
923
923
  } else if (s.kind === "inline") {
924
- walk(s.selections, matched && applies(s.on, s.selections));
924
+ walk2(s.selections, matched && applies(s.on, s.selections));
925
925
  } else {
926
926
  const fragment = doc.fragments.get(s.name);
927
927
  if (!fragment) continue;
@@ -929,11 +929,11 @@ function resolveFields(selections, typename, doc, options) {
929
929
  if (walkedMatched.has(fragment)) continue;
930
930
  if (!asMatched && walkedLoose.has(fragment)) continue;
931
931
  (asMatched ? walkedMatched : walkedLoose).add(fragment);
932
- walk(fragment.selections, asMatched);
932
+ walk2(fragment.selections, asMatched);
933
933
  }
934
934
  }
935
935
  };
936
- walk(selections, true);
936
+ walk2(selections, true);
937
937
  if (!options.strict) {
938
938
  for (const [key, field] of loose) {
939
939
  if (!exact.has(key)) exact.set(key, field);
@@ -950,21 +950,21 @@ function typeConditions(selections, doc) {
950
950
  if (hit) return hit;
951
951
  const names = /* @__PURE__ */ new Set();
952
952
  const seen = /* @__PURE__ */ new Set();
953
- const walk = (list) => {
953
+ const walk2 = (list) => {
954
954
  for (const s of list) {
955
955
  if (s.kind === "inline") {
956
956
  if (s.on) names.add(s.on);
957
- walk(s.selections);
957
+ walk2(s.selections);
958
958
  } else if (s.kind === "spread") {
959
959
  const fragment = doc.fragments.get(s.name);
960
960
  if (!fragment || seen.has(fragment)) continue;
961
961
  seen.add(fragment);
962
962
  names.add(fragment.on);
963
- walk(fragment.selections);
963
+ walk2(fragment.selections);
964
964
  }
965
965
  }
966
966
  };
967
- walk(selections);
967
+ walk2(selections);
968
968
  const conditions = [...names];
969
969
  conditionsCache.set(selections, conditions);
970
970
  return conditions;
@@ -1259,7 +1259,7 @@ function resolveTargets(root, doc, ownerPath, segments) {
1259
1259
  let current = root;
1260
1260
  const out = [];
1261
1261
  const plain = /* @__PURE__ */ new Set();
1262
- const walk = (node, sel, i, path) => {
1262
+ const walk2 = (node, sel, i, path) => {
1263
1263
  if (i === segments.length) {
1264
1264
  out.push(path);
1265
1265
  return;
@@ -1269,10 +1269,10 @@ function resolveTargets(root, doc, ownerPath, segments) {
1269
1269
  if (INDEX_SEGMENT.test(segment)) {
1270
1270
  const position = Number(segment);
1271
1271
  if (position < node.length) {
1272
- walk(node[position], sel, i + 1, [...path, position]);
1272
+ walk2(node[position], sel, i + 1, [...path, position]);
1273
1273
  }
1274
1274
  } else if (segment === "items") {
1275
- walk(node, sel, i + 1, path);
1275
+ walk2(node, sel, i + 1, path);
1276
1276
  }
1277
1277
  return;
1278
1278
  }
@@ -1305,11 +1305,11 @@ function resolveTargets(root, doc, ownerPath, segments) {
1305
1305
  current = writeAtPath(current, fieldPath, value);
1306
1306
  }
1307
1307
  }
1308
- walk(value, field.selections, i + 1, fieldPath);
1308
+ walk2(value, field.selections, i + 1, fieldPath);
1309
1309
  }
1310
- if (!matched && segment === "items") walk(node, sel, i + 1, path);
1310
+ if (!matched && segment === "items") walk2(node, sel, i + 1, path);
1311
1311
  };
1312
- walk(owner, selections, 0, ownerPath);
1312
+ walk2(owner, selections, 0, ownerPath);
1313
1313
  return { root: current, targets: out, plain };
1314
1314
  }
1315
1315
  var NON_ALPHANUMERIC = /[^a-z0-9]/gi;
@@ -1432,11 +1432,11 @@ function listKeysIn(result) {
1432
1432
  if (!byType) {
1433
1433
  byType = /* @__PURE__ */ new Map();
1434
1434
  const seen = /* @__PURE__ */ new WeakSet();
1435
- const walk = (node) => {
1435
+ const walk2 = (node) => {
1436
1436
  if (node === null || typeof node !== "object" || seen.has(node)) return;
1437
1437
  seen.add(node);
1438
1438
  if (Array.isArray(node)) {
1439
- node.forEach(walk);
1439
+ node.forEach(walk2);
1440
1440
  return;
1441
1441
  }
1442
1442
  const record = node;
@@ -1447,10 +1447,10 @@ function listKeysIn(result) {
1447
1447
  if (!keys) byType.set(type, keys = /* @__PURE__ */ new Set());
1448
1448
  keys.add(key);
1449
1449
  }
1450
- walk(value);
1450
+ walk2(value);
1451
1451
  }
1452
1452
  };
1453
- walk(result);
1453
+ walk2(result);
1454
1454
  listKeysCache.set(result, byType);
1455
1455
  }
1456
1456
  return byType.get(typename) ?? NO_KEYS;
@@ -1492,7 +1492,7 @@ function findEditLink(root) {
1492
1492
  if (linkCache.has(root)) return linkCache.get(root) ?? null;
1493
1493
  let found = null;
1494
1494
  const seen = /* @__PURE__ */ new WeakSet();
1495
- const walk = (node) => {
1495
+ const walk2 = (node) => {
1496
1496
  if (found) return;
1497
1497
  if (typeof node === "string") {
1498
1498
  if (!(0, import_stega2.vercelStegaSplit)(node).encoded) return;
@@ -1507,9 +1507,9 @@ function findEditLink(root) {
1507
1507
  }
1508
1508
  if (node === null || typeof node !== "object" || seen.has(node)) return;
1509
1509
  seen.add(node);
1510
- for (const value of Object.values(node)) walk(value);
1510
+ for (const value of Object.values(node)) walk2(value);
1511
1511
  };
1512
- walk(root);
1512
+ walk2(root);
1513
1513
  linkCache.set(root, found);
1514
1514
  return found;
1515
1515
  }
@@ -1535,6 +1535,101 @@ async function readLiveLogFromRequest(request) {
1535
1535
  }
1536
1536
  }
1537
1537
 
1538
+ // src/core/live-preview/edit-links.ts
1539
+ var import_stega3 = require("@vercel/stega");
1540
+ var ENTRY_PARAM = "entry";
1541
+ var TOP_LEVEL_ENTRY = /^[A-Za-z0-9_]+\.\d+$/;
1542
+ function entryIdOf(element) {
1543
+ if (!isRecord(element)) return void 0;
1544
+ if (typeof element._id === "string") return element._id;
1545
+ if (element.__typename !== "Assets" || !Array.isArray(element.items)) {
1546
+ return void 0;
1547
+ }
1548
+ const first = element.items[0];
1549
+ return isRecord(first) && typeof first._id === "string" ? first._id : void 0;
1550
+ }
1551
+ function isRecord(value) {
1552
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1553
+ }
1554
+ function annotate(value, ctx) {
1555
+ if (!ctx.entryId) return value;
1556
+ const { cleaned, encoded } = (0, import_stega3.vercelStegaSplit)(value);
1557
+ if (!encoded) return value;
1558
+ let payload;
1559
+ try {
1560
+ payload = (0, import_stega3.vercelStegaDecode)(encoded);
1561
+ } catch {
1562
+ return value;
1563
+ }
1564
+ if (typeof payload?.href !== "string") return value;
1565
+ let url;
1566
+ try {
1567
+ url = new URL(payload.href);
1568
+ } catch {
1569
+ return value;
1570
+ }
1571
+ if (url.searchParams.has(ENTRY_PARAM)) return value;
1572
+ url.searchParams.set(ENTRY_PARAM, ctx.entryId);
1573
+ const itemId = decodeURIComponent(url.pathname.split("/").pop() ?? "");
1574
+ if (!url.searchParams.has("field") && ctx.entryField && TOP_LEVEL_ENTRY.test(ctx.entryField) && itemId === ctx.entryOwnerId) {
1575
+ url.searchParams.set("field", ctx.entryField);
1576
+ }
1577
+ return (0, import_stega3.vercelStegaCombine)(
1578
+ cleaned,
1579
+ { ...payload, href: url.toString() },
1580
+ false
1581
+ );
1582
+ }
1583
+ function walk(value, selections, ctx) {
1584
+ if (typeof value === "string") return annotate(value, ctx);
1585
+ if (Array.isArray(value)) {
1586
+ let changed2 = false;
1587
+ const next2 = value.map((element, index) => {
1588
+ const path = [...ctx.path, String(index)];
1589
+ const entryId = ctx.inAssets ? void 0 : entryIdOf(element);
1590
+ const entry = entryId ? {
1591
+ entryId,
1592
+ entryField: ctx.ownerId ? path.join(".") : void 0,
1593
+ entryOwnerId: ctx.ownerId,
1594
+ inAssets: isRecord(element) && element._id === void 0
1595
+ } : {};
1596
+ const out = walk(element, selections, { ...ctx, ...entry, path });
1597
+ if (out !== element) changed2 = true;
1598
+ return out;
1599
+ });
1600
+ return changed2 ? next2 : value;
1601
+ }
1602
+ if (!isRecord(value)) return value;
1603
+ const id = typeof value._id === "string" ? value._id : void 0;
1604
+ const typename = typeof value.__typename === "string" ? value.__typename : void 0;
1605
+ const fields = ctx.doc && selections ? collectFields(selections, typename, ctx.doc) : null;
1606
+ let changed = false;
1607
+ const next = {};
1608
+ for (const [key, child] of Object.entries(value)) {
1609
+ const field = fields?.get(key);
1610
+ const name = field?.name ?? key;
1611
+ const out = walk(child, field?.selections ?? null, {
1612
+ ...ctx,
1613
+ ownerId: id ?? ctx.ownerId,
1614
+ path: id ? [name] : [...ctx.path, name]
1615
+ });
1616
+ if (out !== child) changed = true;
1617
+ next[key] = out;
1618
+ }
1619
+ return changed ? next : value;
1620
+ }
1621
+ function annotateEditLinks(result, query, operationName) {
1622
+ let doc = null;
1623
+ if (query !== void 0) {
1624
+ try {
1625
+ doc = readQueryDocument(query, operationName);
1626
+ } catch {
1627
+ doc = null;
1628
+ }
1629
+ }
1630
+ return walk(result, doc?.operation ?? null, { doc, path: [] });
1631
+ }
1632
+
1538
1633
  // src/astro/index.ts
1539
1634
  var LIVE_LOG = /* @__PURE__ */ Symbol.for("@preprio/toolkit/astro-live-log");
1540
1635
  async function onPreprRequest(context, next, options) {
@@ -1575,10 +1670,11 @@ async function getToolbarProps(headers, token, features) {
1575
1670
  return getToolbarPropsFromHeaders(headers, token, features);
1576
1671
  }
1577
1672
  function normalizeQuery2(query, result, options = {}) {
1578
- if (!options.enabled || !options.locals) return result;
1579
- const log = options.locals[LIVE_LOG];
1580
- if (!log?.length) return result;
1581
- return normalizeQuery(query, result, {
1673
+ if (!options.enabled) return result;
1674
+ const annotated = annotateEditLinks(result, query, options.operationName);
1675
+ const log = options.locals?.[LIVE_LOG];
1676
+ if (!log?.length) return annotated;
1677
+ return normalizeQuery(query, annotated, {
1582
1678
  log,
1583
1679
  operationName: options.operationName
1584
1680
  });