@pixelmatters/markup 1.27.3 → 1.28.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 CHANGED
@@ -37,9 +37,9 @@ CDN drop-in, no build step. Paste this just before `</body>`:
37
37
  ```html
38
38
  <script type="module">
39
39
  // Pin the exact version; esm.sh resolves it from npm
40
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.27.3'
40
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.28.0'
41
41
  // or
42
- // import { init } from 'https://esm.run/@pixelmatters/markup@1.27.3'
42
+ // import { init } from 'https://esm.run/@pixelmatters/markup@1.28.0'
43
43
 
44
44
  init({
45
45
  apiUrl: 'https://your-deployment.convex.site',
@@ -50,14 +50,14 @@ CDN drop-in, no build step. Paste this just before `</body>`:
50
50
  </script>
51
51
  ```
52
52
 
53
- > **Why pin the version?** CDN URLs without a version (`@pixelmatters/markup`) resolve to whatever's `latest` on npm, so a future major release will break your page with no warning. Always pin (`@pixelmatters/markup@1.27.3`).
53
+ > **Why pin the version?** CDN URLs without a version (`@pixelmatters/markup`) resolve to whatever's `latest` on npm, so a future major release will break your page with no warning. Always pin (`@pixelmatters/markup@1.28.0`).
54
54
 
55
55
  If your platform doesn't allow inline JS (some CMS / page-builder editors), use the auto-init form instead. Point a `<script src=…>` at the bundle and pass config via `data-*` attributes:
56
56
 
57
57
  ```html
58
58
  <script
59
59
  type="module"
60
- src="https://esm.sh/@pixelmatters/markup@1.27.3"
60
+ src="https://esm.sh/@pixelmatters/markup@1.28.0"
61
61
  data-markup-widget="true"
62
62
  data-api-url="https://your-deployment.convex.site"
63
63
  data-api-key="markup_..."
@@ -88,44 +88,53 @@ stop() // tears down this instance; the exported destroy() tears down whichever
88
88
 
89
89
  ### React
90
90
 
91
- ```tsx
92
- import { useEffect } from 'react'
93
- import { init } from '@pixelmatters/markup'
91
+ React 18 or later. Render the component once near your app root:
94
92
 
95
- export default function App() {
96
- useEffect(() => {
97
- return init({
98
- apiUrl: import.meta.env.VITE_MARKUP_API_URL,
99
- apiKey: import.meta.env.VITE_MARKUP_API_KEY,
100
- position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
101
- theme: 'auto', // optional: 'auto' | 'light' | 'dark'
102
- })
103
- }, [])
93
+ ```tsx
94
+ 'use client' // Next.js App Router only
95
+ import { useMarkup } from '@pixelmatters/markup/react'
104
96
 
105
- return <>{/* your app */}</>
97
+ export function Markup() {
98
+ useMarkup({
99
+ apiUrl: import.meta.env.VITE_MARKUP_API_URL,
100
+ apiKey: import.meta.env.VITE_MARKUP_API_KEY,
101
+ position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
102
+ theme: 'auto', // optional: 'auto' | 'light' | 'dark'
103
+ enabled: true, // optional: false unmounts it, e.g. from your build environment
104
+ })
105
+ return null
106
106
  }
107
107
  ```
108
108
 
109
+ The hook takes the same options as `init()`, plus `enabled`. An inline object
110
+ is fine: the widget remounts only when a value changes. StrictMode's double
111
+ mount leaves one widget, two components with the same options share it until
112
+ both unmount, and server rendering mounts nothing. Don't also call `init()` on
113
+ the same page.
114
+
109
115
  ### Vue 3
110
116
 
117
+ Vue 3.3 or later. Call it once from your root component:
118
+
111
119
  ```vue
112
120
  <script setup lang="ts">
113
- import { onMounted, onBeforeUnmount } from 'vue'
114
- import { init } from '@pixelmatters/markup'
121
+ import { useMarkup } from '@pixelmatters/markup/vue'
115
122
 
116
- let stop: (() => void) | undefined
117
- onMounted(() => {
118
- stop = init({
119
- apiUrl: import.meta.env.VITE_MARKUP_API_URL,
120
- apiKey: import.meta.env.VITE_MARKUP_API_KEY,
121
- position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
122
- theme: 'auto', // optional: 'auto' | 'light' | 'dark'
123
- })
123
+ useMarkup({
124
+ apiUrl: import.meta.env.VITE_MARKUP_API_URL,
125
+ apiKey: import.meta.env.VITE_MARKUP_API_KEY,
126
+ position: 'bottom-right', // optional: 'bottom-right' | 'bottom-left' | 'bottom-center'
127
+ theme: 'auto', // optional: 'auto' | 'light' | 'dark'
124
128
  })
125
- onBeforeUnmount(() => stop?.())
126
129
  </script>
127
130
  ```
128
131
 
132
+ The composable takes the same options as `init()`, plus `enabled`, as a plain
133
+ object, a ref or a getter (`() => ({ …, theme: theme.value })`), and remounts
134
+ the widget only when a value changes. Two components with the same options
135
+ share one widget until both unmount, and nothing mounts during server
136
+ rendering.
137
+
129
138
  ### SolidJS
130
139
 
131
140
  ```tsx
@@ -231,6 +240,12 @@ have on. A pin's tooltip names the URL it was left on either way.
231
240
 
232
241
  **Show resolved threads** is off by default. Turned on, the widget also renders the route's most recently resolved threads (up to 100) as muted check-mark pins. Opening one shows the thread read-only: no replies, edits, reactions or resolve button. Reopening still happens from the dashboard.
233
242
 
243
+ A thread's own **⋯** menu has **Copy link**, and for signed-in users
244
+ **Copy for agent**: the thread as a Markdown prompt for a coding agent, with
245
+ the page, element, declared source path and every comment. Comment text is
246
+ quoted line by line and every other value is a quoted string, so what a
247
+ visitor typed can't pass itself off as part of the instructions.
248
+
234
249
  The last two menu rows open inside the menu itself, replacing the rows with a
235
250
  reading panel and a **Back** button. `Esc` steps back to the rows, and again to
236
251
  close.
@@ -361,6 +376,29 @@ share a route, each pin's tooltip names the URL it was left on, and the
361
376
  overflow menu's **Only this URL's pins** narrows the page to one view without
362
377
  changing how anything is stored.
363
378
 
379
+ ## Anchors and source paths
380
+
381
+ Two optional attributes make pins sturdier and give agents a place to start.
382
+ Neither is required; without them the widget anchors by structure and text.
383
+
384
+ ```html
385
+ <section data-markup-anchor="pricing" data-markup-source="src/components/Pricing.tsx">…</section>
386
+ ```
387
+
388
+ | Attribute | What it does |
389
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
390
+ | `data-markup-anchor` | A stable name for the element, used ahead of ids and test attributes when a pin's selector is built. Treat it like an id: a name unique on the page addresses the element on its own, and a pin placed on it stays attached when its text changes. A name several elements share (`edit` on every row's button) is kept only as a hint alongside the element's position. In a list that can be reordered, name each item (`data-markup-anchor="todo-42"`) so a pin follows its item rather than its position. |
391
+ | `data-markup-source` | A repository-relative path. The nearest one above the pinned element is stored with the thread, shown in the dashboard, and returned to agents by the MCP server's `get_thread` as `anchorSource`. Values over 512 characters are ignored. |
392
+
393
+ Without a name, a pin in a list that was reordered or filtered still finds
394
+ its item when the pinned text is unique in the list, such as a title. It can't
395
+ tell identical controls apart: a pin on one of several **Edit** buttons stays
396
+ with whichever row now holds its old position. Name the items to cover that
397
+ case.
398
+
399
+ Both are read from your page as it is, so keep them accurate: an agent is told
400
+ to start from `anchorSource` and confirm it, not to trust it blindly.
401
+
364
402
  ## How it works
365
403
 
366
404
  - The widget mounts `<div id="markup-widget">` on `document.body` and attaches an open shadow root.
@@ -448,16 +486,16 @@ init({
448
486
 
449
487
  There is no `fab` option any more. It's accepted, ignored, and warns once. Drop it if you find one in my config.
450
488
 
451
- For React/Vue/Solid hosts, call `init()` from a mount lifecycle hook
452
- (`useEffect`, `onMounted`, `onMount`) and call the returned `destroy` on
453
- cleanup. There's no framework-specific entrypoint; `init` is the whole
454
- public API.
489
+ For React or Vue, use `useMarkup` from `@pixelmatters/markup/react` or
490
+ `@pixelmatters/markup/vue` once near the app root. For Solid and others, call
491
+ `init()` from a mount lifecycle hook (`onMount`) and call the returned
492
+ `destroy` on cleanup.
455
493
 
456
494
  For a `<script>` tag drop-in (no bundler), use the inline ESM form and **pin the version**:
457
495
 
458
496
  ```html
459
497
  <script type="module">
460
- import { init } from 'https://esm.sh/@pixelmatters/markup@1.27.3'
498
+ import { init } from 'https://esm.sh/@pixelmatters/markup@1.28.0'
461
499
 
462
500
  init({
463
501
  apiUrl: '...',
@@ -473,7 +511,7 @@ If inline JS is disallowed (some CMS / page-builder editors), use the auto-init
473
511
  ```html
474
512
  <script
475
513
  type="module"
476
- src="https://esm.sh/@pixelmatters/markup@1.27.3"
514
+ src="https://esm.sh/@pixelmatters/markup@1.28.0"
477
515
  data-markup-widget="true"
478
516
  data-api-url="..."
479
517
  data-api-key="..."
@@ -0,0 +1,21 @@
1
+ import { WidgetConfig } from './widget';
2
+ export type { WidgetConfig } from './widget';
3
+ export type UseMarkupOptions = WidgetConfig & {
4
+ /**
5
+ * `false` unmounts the widget, or never mounts it. Drive it from your build
6
+ * environment to keep the widget off production.
7
+ *
8
+ * @default true
9
+ */
10
+ enabled?: boolean;
11
+ };
12
+ /**
13
+ * Mounts the widget for as long as the calling component is mounted. Call it
14
+ * once near your app root.
15
+ *
16
+ * An inline options object is fine: the widget remounts only when a value
17
+ * changes, not when the object is recreated. Two components using the same
18
+ * options share one widget, and it stays until both unmount. Server rendering
19
+ * mounts nothing.
20
+ */
21
+ export declare function useMarkup(options: UseMarkupOptions): void;
package/dist/react.js ADDED
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { init as e, t } from "./widget.js";
3
+ import { t as n } from "./shared-mount-DABK4arh.js";
4
+ import { useEffect as r } from "react";
5
+ //#region src/react.ts
6
+ function i(i) {
7
+ let { enabled: a = !0, ...o } = i, s = a ? t(o) : null;
8
+ r(() => {
9
+ if (s !== null) return n(s, e);
10
+ }, [s]);
11
+ }
12
+ //#endregion
13
+ export { i as useMarkup };
14
+
15
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","names":[],"sources":["../src/react.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\n\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once near your app root.\n *\n * An inline options object is fine: the widget remounts only when a value\n * changes, not when the object is recreated. Two components using the same\n * options share one widget, and it stays until both unmount. Server rendering\n * mounts nothing.\n */\nexport function useMarkup(options: UseMarkupOptions): void {\n const { enabled = true, ...config } = options\n const key = enabled ? configKey(config) : null\n\n useEffect(() => {\n if (key === null) return\n return acquireMount(key, init)\n }, [key])\n}\n"],"mappings":";;;;;AA6BA,SAAgB,EAAU,GAAiC;CACzD,IAAM,EAAE,aAAU,IAAM,GAAG,MAAW,GAChC,IAAM,IAAU,EAAU,CAAM,IAAI;CAE1C,QAAgB;EACV,UAAQ,MACZ,OAAO,EAAa,GAAK,CAAI;CAC/B,GAAG,CAAC,CAAG,CAAC;AACV"}
@@ -0,0 +1,27 @@
1
+ //#region src/runtime/shared-mount.ts
2
+ var e = /* @__PURE__ */ new Map();
3
+ function t(t, n) {
4
+ let r = n(JSON.parse(t)), i = (e.get(t)?.count ?? 0) + 1;
5
+ e.delete(t), e.set(t, {
6
+ count: i,
7
+ dispose: r
8
+ });
9
+ let a = !1;
10
+ return () => {
11
+ if (a) return;
12
+ a = !0;
13
+ let r = e.get(t);
14
+ if (!r || (--r.count, r.count > 0)) return;
15
+ e.delete(t), r.dispose();
16
+ let i = [...e.entries()].at(-1);
17
+ if (i) try {
18
+ i[1].dispose = n(JSON.parse(i[0]));
19
+ } catch (e) {
20
+ i[1].dispose = () => {}, console.warn("[markup] could not remount the widget", e);
21
+ }
22
+ };
23
+ }
24
+ //#endregion
25
+ export { t };
26
+
27
+ //# sourceMappingURL=shared-mount-DABK4arh.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shared-mount-DABK4arh.js","names":[],"sources":["../src/runtime/shared-mount.ts"],"sourcesContent":["import type { WidgetConfig } from '../widget'\n\ntype Holder = { count: number; dispose: () => void }\n\nconst holders = new Map<string, Holder>()\n\n/**\n * Counts hook holders per config, so the first of two components to unmount\n * doesn't take the widget away from the other. `init` still runs on every\n * acquire: it is a no-op for the live config, and it remounts one that a\n * different config has since replaced.\n *\n * Holders of different configs contend for the one widget, and the latest to\n * acquire wins. When it lets go, the most recent remaining config is mounted\n * again, so no holder is left without a widget while it still wants one.\n */\nexport function acquireMount(key: string, init: (config: WidgetConfig) => () => void): () => void {\n const dispose = init(JSON.parse(key) as WidgetConfig)\n const count = (holders.get(key)?.count ?? 0) + 1\n // Re-inserted so the map's order is recency, which the release path reads.\n holders.delete(key)\n holders.set(key, { count, dispose })\n\n let isReleased = false\n return () => {\n if (isReleased) return\n isReleased = true\n const current = holders.get(key)\n if (!current) return\n current.count -= 1\n if (current.count > 0) return\n holders.delete(key)\n current.dispose()\n const survivor = [...holders.entries()].at(-1)\n if (!survivor) return\n // This runs inside some other component's cleanup; a failure to bring\n // the survivor back must not surface as that component's error.\n try {\n survivor[1].dispose = init(JSON.parse(survivor[0]) as WidgetConfig)\n } catch (error) {\n survivor[1].dispose = () => {}\n console.warn('[markup] could not remount the widget', error)\n }\n }\n}\n"],"mappings":";AAIA,IAAM,oBAAU,IAAI,IAAoB;AAYxC,SAAgB,EAAa,GAAa,GAAwD;CAChG,IAAM,IAAU,EAAK,KAAK,MAAM,CAAG,CAAiB,GAC9C,KAAS,EAAQ,IAAI,CAAG,CAAC,EAAE,SAAS,KAAK;CAG/C,AADA,EAAQ,OAAO,CAAG,GAClB,EAAQ,IAAI,GAAK;EAAE;EAAO;CAAQ,CAAC;CAEnC,IAAI,IAAa;CACjB,aAAa;EACX,IAAI,GAAY;EAChB,IAAa;EACb,IAAM,IAAU,EAAQ,IAAI,CAAG;EAG/B,IAFI,CAAC,MACL,IAAQ,OACJ,EAAQ,QAAQ,IAAG;EAEvB,AADA,EAAQ,OAAO,CAAG,GAClB,EAAQ,QAAQ;EAChB,IAAM,IAAW,CAAC,GAAG,EAAQ,QAAQ,CAAC,CAAC,CAAC,GAAG,EAAE;EACxC,OAGL,IAAI;GACF,EAAS,EAAE,CAAC,UAAU,EAAK,KAAK,MAAM,EAAS,EAAE,CAAiB;EACpE,SAAS,GAAO;GAEd,AADA,EAAS,EAAE,CAAC,gBAAgB,CAAC,GAC7B,QAAQ,KAAK,yCAAyC,CAAK;EAC7D;CACF;AACF"}
package/dist/vue.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { MaybeRefOrGetter } from 'vue';
2
+ import { WidgetConfig } from './widget';
3
+ export type { WidgetConfig } from './widget';
4
+ export type UseMarkupOptions = WidgetConfig & {
5
+ /**
6
+ * `false` unmounts the widget, or never mounts it. Drive it from your build
7
+ * environment to keep the widget off production.
8
+ *
9
+ * @default true
10
+ */
11
+ enabled?: boolean;
12
+ };
13
+ /**
14
+ * Mounts the widget for as long as the calling component is mounted. Call it
15
+ * once from your root component's `setup`.
16
+ *
17
+ * Pass a plain object, a ref, or a getter. The widget remounts only when a
18
+ * value changes, not when the object is replaced by an equal one. Two
19
+ * components using the same options share one widget, and it stays until
20
+ * both unmount. Nothing mounts during server rendering, since mounting waits
21
+ * for `onMounted`.
22
+ */
23
+ export declare function useMarkup(options: MaybeRefOrGetter<UseMarkupOptions>): void;
package/dist/vue.js ADDED
@@ -0,0 +1,21 @@
1
+ import { init as e, t } from "./widget.js";
2
+ import { t as n } from "./shared-mount-DABK4arh.js";
3
+ import { computed as r, onBeforeUnmount as i, onMounted as a, toValue as o, watch as s } from "vue";
4
+ //#region src/vue.ts
5
+ function c(c) {
6
+ let l = r(() => {
7
+ let { enabled: e = !0, ...n } = o(c);
8
+ return e ? t(n) : null;
9
+ }), u = null, d = (t) => {
10
+ u?.(), u = t === null ? null : n(t, e);
11
+ }, f = null;
12
+ a(() => {
13
+ d(l.value), f = s(l, d);
14
+ }), i(() => {
15
+ f?.(), d(null);
16
+ });
17
+ }
18
+ //#endregion
19
+ export { c as useMarkup };
20
+
21
+ //# sourceMappingURL=vue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vue.js","names":[],"sources":["../src/vue.ts"],"sourcesContent":["import { computed, onBeforeUnmount, onMounted, toValue, watch, type MaybeRefOrGetter } from 'vue'\n\nimport { configKey } from './runtime/config-key'\nimport { acquireMount } from './runtime/shared-mount'\nimport { init, type WidgetConfig } from './widget'\n\nexport type { WidgetConfig } from './widget'\n\nexport type UseMarkupOptions = WidgetConfig & {\n /**\n * `false` unmounts the widget, or never mounts it. Drive it from your build\n * environment to keep the widget off production.\n *\n * @default true\n */\n enabled?: boolean\n}\n\n/**\n * Mounts the widget for as long as the calling component is mounted. Call it\n * once from your root component's `setup`.\n *\n * Pass a plain object, a ref, or a getter. The widget remounts only when a\n * value changes, not when the object is replaced by an equal one. Two\n * components using the same options share one widget, and it stays until\n * both unmount. Nothing mounts during server rendering, since mounting waits\n * for `onMounted`.\n */\nexport function useMarkup(options: MaybeRefOrGetter<UseMarkupOptions>): void {\n const key = computed(() => {\n const { enabled = true, ...config } = toValue(options)\n return enabled ? configKey(config) : null\n })\n\n let release: (() => void) | null = null\n const apply = (next: string | null) => {\n release?.()\n release = next === null ? null : acquireMount(next, init)\n }\n\n let stopWatching: (() => void) | null = null\n onMounted(() => {\n apply(key.value)\n stopWatching = watch(key, apply)\n })\n onBeforeUnmount(() => {\n stopWatching?.()\n apply(null)\n })\n}\n"],"mappings":";;;;AA4BA,SAAgB,EAAU,GAAmD;CAC3E,IAAM,IAAM,QAAe;EACzB,IAAM,EAAE,aAAU,IAAM,GAAG,MAAW,EAAQ,CAAO;EACrD,OAAO,IAAU,EAAU,CAAM,IAAI;CACvC,CAAC,GAEG,IAA+B,MAC7B,KAAS,MAAwB;EAErC,AADA,IAAU,GACV,IAAU,MAAS,OAAO,OAAO,EAAa,GAAM,CAAI;CAC1D,GAEI,IAAoC;CAKxC,AAJA,QAAgB;EAEd,AADA,EAAM,EAAI,KAAK,GACf,IAAe,EAAM,GAAK,CAAK;CACjC,CAAC,GACD,QAAsB;EAEpB,AADA,IAAe,GACf,EAAM,IAAI;CACZ,CAAC;AACH"}