@djangocfg/widget-editor 0.1.1

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/package.json +121 -0
  4. package/src/blocks/BlockError.tsx +46 -0
  5. package/src/blocks/index.ts +34 -0
  6. package/src/blocks/schemas.ts +143 -0
  7. package/src/blocks/types.ts +76 -0
  8. package/src/chips/README.md +67 -0
  9. package/src/chips/UrlChip.tsx +105 -0
  10. package/src/chips/index.ts +20 -0
  11. package/src/index.ts +12 -0
  12. package/src/link-preview/LinkPreviewCard.tsx +178 -0
  13. package/src/link-preview/index.ts +12 -0
  14. package/src/link-preview/types.ts +44 -0
  15. package/src/markdown/MarkdownEditor.tsx +700 -0
  16. package/src/markdown/MentionList.tsx +106 -0
  17. package/src/markdown/README.md +269 -0
  18. package/src/markdown/chip/ChipNode.ts +526 -0
  19. package/src/markdown/chip/syncChips.ts +80 -0
  20. package/src/markdown/createMentionSuggestion.ts +131 -0
  21. package/src/markdown/filePath/FilePathChip.tsx +68 -0
  22. package/src/markdown/filePath/detect.ts +289 -0
  23. package/src/markdown/filePath/index.ts +15 -0
  24. package/src/markdown/index.ts +29 -0
  25. package/src/markdown/lazy.tsx +88 -0
  26. package/src/markdown/markdown-editor.stories.tsx +500 -0
  27. package/src/markdown/mentionPresets.ts +49 -0
  28. package/src/markdown/slash/SlashCommandNode.ts +162 -0
  29. package/src/markdown/slash/index.ts +4 -0
  30. package/src/markdown/slash/syncSlashNode.ts +97 -0
  31. package/src/markdown/slash/types.ts +14 -0
  32. package/src/markdown/styles.css +522 -0
  33. package/src/markdown/submitOnEnter.ts +83 -0
  34. package/src/markdown/types.ts +50 -0
  35. package/src/markdown/url/detect.ts +227 -0
  36. package/src/notion/BookmarkNode.ts +161 -0
  37. package/src/notion/BookmarkView.tsx +45 -0
  38. package/src/notion/CustomKeymap.ts +48 -0
  39. package/src/notion/LinkDialog.tsx +133 -0
  40. package/src/notion/MapEditLayer.tsx +199 -0
  41. package/src/notion/MapNode.ts +220 -0
  42. package/src/notion/MapView.tsx +103 -0
  43. package/src/notion/NotionEditor.tsx +309 -0
  44. package/src/notion/README.md +158 -0
  45. package/src/notion/SlashExtension.ts +32 -0
  46. package/src/notion/SlashList.tsx +136 -0
  47. package/src/notion/TaskItemView.tsx +41 -0
  48. package/src/notion/createSlashSuggestion.ts +121 -0
  49. package/src/notion/extensions.ts +120 -0
  50. package/src/notion/index.ts +1 -0
  51. package/src/notion/lazy.tsx +44 -0
  52. package/src/notion/notion-editor.stories.tsx +293 -0
  53. package/src/notion/slashItems.ts +178 -0
  54. package/src/notion/styles.css +503 -0
  55. package/src/notion/types.ts +37 -0
  56. package/src/tiptap/index.ts +10 -0
  57. package/src/tiptap/useNodeAttrs.ts +128 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @djangocfg/widget-editor
2
+
3
+ Two TipTap editors and the pieces both are built from.
4
+
5
+ | Subpath | What | Own docs |
6
+ |---|---|---|
7
+ | `./markdown` | `MarkdownEditor` — a markdown field with mentions, URL and file-path chips | [`src/markdown/README.md`](src/markdown/README.md) |
8
+ | `./notion` | `NotionEditor` — a block editor: slash menu, tables, tasks, code, bookmarks, maps | [`src/notion/README.md`](src/notion/README.md), [`@docs/`](src/notion/@docs/) |
9
+ | `./blocks` | The zod schemas for the embeddable block payloads | — |
10
+ | `./link-preview` | `LinkPreviewCard` and the `ResolveLinkPreview` seam | — |
11
+ | `./chips` | `UrlChip`, `FilePathChip` — the same pills outside an editor | [`src/chips/README.md`](src/chips/README.md) |
12
+ | `./tiptap` | `useNodeAttrs` and the node-view helpers | — |
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pnpm add @djangocfg/widget-editor @djangocfg/ui-core zod
18
+ ```
19
+
20
+ `zod` is a peer: the block schemas validate payloads a host also validates, and
21
+ two copies would reject each other's instances.
22
+
23
+ ## There is no root barrel worth importing
24
+
25
+ The root exports a type and nothing else, on purpose. TipTap is sixteen
26
+ packages, and the two editors share no consumer — an app wanting the markdown
27
+ field must not resolve the Notion editor's tables, drag handle and syntax
28
+ highlighter behind it. **Import a subpath.**
29
+
30
+ ## The map block is a real dependency
31
+
32
+ `NotionEditor`'s `mapBlock` renders `@djangocfg/widget-map`, so the editor
33
+ depends on the map widget rather than the other way round. It arrives through
34
+ the map's lazy entry, so MapLibre still loads only when a map block mounts.
35
+
36
+ ## Verify
37
+
38
+ ```bash
39
+ pnpm --filter @djangocfg/widget-editor check-types test
40
+ ```
package/package.json ADDED
@@ -0,0 +1,121 @@
1
+ {
2
+ "name": "@djangocfg/widget-editor",
3
+ "version": "0.1.1",
4
+ "description": "TipTap editors: a markdown field with mentions and chips, and a Notion-style block editor with slash commands, tables and embedded blocks",
5
+ "keywords": [
6
+ "editor",
7
+ "tiptap",
8
+ "markdown",
9
+ "wysiwyg",
10
+ "notion",
11
+ "react",
12
+ "typescript"
13
+ ],
14
+ "author": {
15
+ "name": "DjangoCFG",
16
+ "url": "https://djangocfg.com"
17
+ },
18
+ "homepage": "https://djangocfg.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/markolofsen/django-cfg.git",
22
+ "directory": "widgets/editor"
23
+ },
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./src/index.ts",
31
+ "default": "./src/index.ts"
32
+ },
33
+ "./markdown": {
34
+ "types": "./src/markdown/lazy.tsx",
35
+ "default": "./src/markdown/lazy.tsx"
36
+ },
37
+ "./notion": {
38
+ "types": "./src/notion/lazy.tsx",
39
+ "default": "./src/notion/lazy.tsx"
40
+ },
41
+ "./blocks": {
42
+ "types": "./src/blocks/index.ts",
43
+ "default": "./src/blocks/index.ts"
44
+ },
45
+ "./link-preview": {
46
+ "types": "./src/link-preview/index.ts",
47
+ "default": "./src/link-preview/index.ts"
48
+ },
49
+ "./chips": {
50
+ "types": "./src/chips/index.ts",
51
+ "default": "./src/chips/index.ts"
52
+ },
53
+ "./tiptap": {
54
+ "types": "./src/tiptap/index.ts",
55
+ "default": "./src/tiptap/index.ts"
56
+ }
57
+ },
58
+ "files": [
59
+ "src",
60
+ "!src/**/*.test.ts",
61
+ "!src/**/*.test.tsx",
62
+ "!src/**/__tests__",
63
+ "README.md",
64
+ "LICENSE"
65
+ ],
66
+ "scripts": {
67
+ "check": "tsc --noEmit",
68
+ "check-types": "tsc --noEmit",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest"
71
+ },
72
+ "dependencies": {
73
+ "@djangocfg/widget-data": "^0.1.1",
74
+ "@djangocfg/widget-kit": "^0.1.1",
75
+ "@djangocfg/widget-map": "^0.1.1",
76
+ "@floating-ui/dom": "^1.8.0",
77
+ "@tiptap/core": "^3.27.3",
78
+ "@tiptap/extension-code-block-lowlight": "^3.27.3",
79
+ "@tiptap/extension-highlight": "^3.27.3",
80
+ "@tiptap/extension-mention": "^3.27.3",
81
+ "@tiptap/extension-placeholder": "^3.27.3",
82
+ "@tiptap/extension-table": "^3.27.3",
83
+ "@tiptap/extension-table-cell": "^3.27.3",
84
+ "@tiptap/extension-table-header": "^3.27.3",
85
+ "@tiptap/extension-table-row": "^3.27.3",
86
+ "@tiptap/extension-task-item": "^3.27.3",
87
+ "@tiptap/extension-task-list": "^3.27.3",
88
+ "@tiptap/markdown": "^3.27.3",
89
+ "@tiptap/pm": "^3.27.3",
90
+ "@tiptap/react": "^3.27.3",
91
+ "@tiptap/starter-kit": "^3.27.3",
92
+ "@tiptap/suggestion": "^3.27.3",
93
+ "lowlight": "^3.3.0",
94
+ "tiptap-extension-global-drag-handle": "^0.1.18"
95
+ },
96
+ "peerDependencies": {
97
+ "@djangocfg/ui-core": "^2.1.542",
98
+ "lucide-react": "^0.545.0",
99
+ "react": "^19.0.0",
100
+ "react-dom": "^19.0.0",
101
+ "zod": "^4.3.6"
102
+ },
103
+ "devDependencies": {
104
+ "@djangocfg/typescript-config": "^2.1.542",
105
+ "@djangocfg/ui-core": "^2.1.542",
106
+ "@storybook/react-vite": "^10.5.0",
107
+ "@types/node": "^25.9.5",
108
+ "@types/react": "19.2.15",
109
+ "@types/react-dom": "19.2.3",
110
+ "lucide-react": "0.545.0",
111
+ "react": "19.2.7",
112
+ "react-dom": "19.2.7",
113
+ "storybook": "^10.5.0",
114
+ "typescript": "^5.9.3",
115
+ "vitest": "^4.1.10",
116
+ "zod": "^4.4.3"
117
+ },
118
+ "publishConfig": {
119
+ "access": "public"
120
+ }
121
+ }
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `<BlockError>` — the safe presentational fallback a data-driven block renders
5
+ * when its payload fails schema validation (a corrupted ```map JSON, a
6
+ * malformed wire block). Informative, not scary: a compact muted card that says
7
+ * the block couldn't render, with an optional short reason. Never throws, so the
8
+ * surrounding editor / message list stays alive.
9
+ *
10
+ * Semantic tokens only (`bg-muted` / `border-border` / `text-muted-foreground`).
11
+ */
12
+
13
+ import { AlertTriangle } from 'lucide-react';
14
+
15
+ export interface BlockErrorProps {
16
+ /**
17
+ * Short, human-readable reason (e.g. the first Zod issue). Optional — when
18
+ * omitted only the generic headline shows.
19
+ */
20
+ reason?: string;
21
+ /** What couldn't be rendered (e.g. "map", "bookmark"). Defaults to "block". */
22
+ label?: string;
23
+ className?: string;
24
+ }
25
+
26
+ export function BlockError({ reason, label = 'block', className }: BlockErrorProps) {
27
+ return (
28
+ <div
29
+ role="status"
30
+ className={
31
+ 'flex items-start gap-2.5 rounded-lg border border-border bg-muted px-3 py-2.5 text-sm text-muted-foreground' +
32
+ (className ? ` ${className}` : '')
33
+ }
34
+ >
35
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
36
+ <div className="min-w-0">
37
+ <div className="font-medium text-foreground">Couldn&apos;t render this {label}</div>
38
+ {reason ? (
39
+ <div className="mt-0.5 break-words text-xs text-muted-foreground">{reason}</div>
40
+ ) : null}
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ export default BlockError;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared block payload types — the serializable wire shapes that both the
3
+ * chat message blocks and the NotionEditor block NodeViews reference, so the
4
+ * shapes can't drift. The link-preview data shape lives in
5
+ * `common/link-preview`; the map payload lives here. Re-exported below so a
6
+ * single import (`common/blocks`) reaches every shared block payload.
7
+ */
8
+
9
+ export type {
10
+ MapBlockPayload,
11
+ MapMarkerPayload,
12
+ MapRoutePayload,
13
+ MapPolygonPayload,
14
+ } from './types';
15
+
16
+ // Re-export the link-preview data contract from its home so consumers can
17
+ // reach every shared block payload from one place.
18
+ export type { LinkPreviewData, ResolveLinkPreview } from '../link-preview';
19
+
20
+ // Runtime validators + the safe-render fallback. A block NodeView/renderer
21
+ // parses its payload through the matching schema and renders `<BlockError>`
22
+ // on failure instead of throwing.
23
+ export {
24
+ MapMarkerPayloadSchema,
25
+ MapRoutePayloadSchema,
26
+ MapPolygonPayloadSchema,
27
+ MapBlockPayloadSchema,
28
+ LinkPreviewDataSchema,
29
+ safeParseBlock,
30
+ } from './schemas';
31
+ export type { SafeParseResult } from './schemas';
32
+
33
+ export { BlockError } from './BlockError';
34
+ export type { BlockErrorProps } from './BlockError';
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Zod schemas for the shared, serializable block payloads in `./types.ts` and
3
+ * the link-preview data contract. These are the runtime VALIDATORS that let a
4
+ * block NodeView render a safe `<BlockError>` fallback instead of throwing when
5
+ * it's handed a hand-authored / corrupted payload (a bad ```map JSON, a
6
+ * malformed wire block).
7
+ *
8
+ * The TS payload types stay the source of truth in `./types.ts` (and
9
+ * `common/link-preview/types.ts`); these schemas are kept in SYNC with them and
10
+ * `satisfies`-checked below so they can't silently drift. We deliberately do
11
+ * NOT re-export `z.infer` types under the same names — existing imports
12
+ * (`MapBlockPayload`, `LinkPreviewData`) keep coming from their type homes.
13
+ *
14
+ * Schemas are permissive on optional/extra fields (`.passthrough` is implied by
15
+ * Zod object stripping unknowns) but STRICT on the load-bearing shape: numbers
16
+ * are numbers, `center`/`url` are required. Anything that wouldn't render
17
+ * cleanly fails `safeParse`.
18
+ */
19
+
20
+ import { z } from 'zod';
21
+ import type {
22
+ MapMarkerPayload,
23
+ MapRoutePayload,
24
+ MapPolygonPayload,
25
+ MapBlockPayload,
26
+ } from './types';
27
+ import type { LinkPreviewData } from '../link-preview';
28
+
29
+ /** A finite number — rejects NaN/Infinity that would break the map camera. */
30
+ const finiteNumber = z.number().finite();
31
+
32
+ /** `{ lat, lng }` — a geographic point; both finite numbers, required. */
33
+ const LatLngSchema = z.object({
34
+ lat: finiteNumber,
35
+ lng: finiteNumber,
36
+ });
37
+
38
+ /** Marker info-card (pure JSON; `href`-only actions). */
39
+ const MarkerCardSchema = z.object({
40
+ title: z.string(),
41
+ description: z.string().optional(),
42
+ image: z.string().optional(),
43
+ badge: z.string().optional(),
44
+ actions: z
45
+ .array(z.object({ label: z.string(), href: z.string().optional() }))
46
+ .optional(),
47
+ });
48
+
49
+ /** A single map marker — pin + optional label / icon / colour / card. */
50
+ export const MapMarkerPayloadSchema = z.object({
51
+ id: z.string(),
52
+ lat: finiteNumber,
53
+ lng: finiteNumber,
54
+ label: z.string().optional(),
55
+ icon: z.string().optional(),
56
+ color: z.string().optional(),
57
+ card: MarkerCardSchema.optional(),
58
+ });
59
+
60
+ /** An ordered polyline (route / path). */
61
+ export const MapRoutePayloadSchema = z.object({
62
+ id: z.string(),
63
+ points: z.array(LatLngSchema),
64
+ color: z.string().optional(),
65
+ label: z.string().optional(),
66
+ });
67
+
68
+ /** A filled polygon (zone), auto-closed. */
69
+ export const MapPolygonPayloadSchema = z.object({
70
+ id: z.string(),
71
+ points: z.array(LatLngSchema),
72
+ fillColor: z.string().optional(),
73
+ strokeColor: z.string().optional(),
74
+ label: z.string().optional(),
75
+ });
76
+
77
+ /** The serializable map payload — center + optional viewport + overlays. */
78
+ export const MapBlockPayloadSchema = z.object({
79
+ center: LatLngSchema,
80
+ zoom: finiteNumber.optional(),
81
+ basemap: z.string().optional(),
82
+ terrain: z.boolean().optional(),
83
+ markers: z.array(MapMarkerPayloadSchema).optional(),
84
+ routes: z.array(MapRoutePayloadSchema).optional(),
85
+ polygons: z.array(MapPolygonPayloadSchema).optional(),
86
+ });
87
+
88
+ /** Resolved link-preview metadata — `url` required, rest optional. */
89
+ export const LinkPreviewDataSchema = z.object({
90
+ url: z.string(),
91
+ title: z.string().optional(),
92
+ description: z.string().optional(),
93
+ image: z.string().optional(),
94
+ favicon: z.string().optional(),
95
+ siteName: z.string().optional(),
96
+ });
97
+
98
+ // ── Drift guards ──────────────────────────────────────────────────────────
99
+ // A type-level assertion that each schema's inferred output is assignable to
100
+ // the canonical TS payload type. If `./types.ts` and a schema diverge, the
101
+ // corresponding `Extends<…>` resolves to `never` and the assignment fails to
102
+ // compile — keeping the schemas and types in lockstep.
103
+ type Extends<A, B> = A extends B ? true : never;
104
+ const _markerOk: Extends<z.infer<typeof MapMarkerPayloadSchema>, MapMarkerPayload> = true;
105
+ const _routeOk: Extends<z.infer<typeof MapRoutePayloadSchema>, MapRoutePayload> = true;
106
+ const _polygonOk: Extends<z.infer<typeof MapPolygonPayloadSchema>, MapPolygonPayload> = true;
107
+ const _mapOk: Extends<z.infer<typeof MapBlockPayloadSchema>, MapBlockPayload> = true;
108
+ const _linkOk: Extends<z.infer<typeof LinkPreviewDataSchema>, LinkPreviewData> = true;
109
+ // Reference the guards so `noUnusedLocals` stays happy without exporting them.
110
+ void [_markerOk, _routeOk, _polygonOk, _mapOk, _linkOk];
111
+
112
+ /**
113
+ * Discriminated result of a guarded parse — never throws.
114
+ *
115
+ * The mutually-exclusive `?: never` fields make narrowing robust: a plain
116
+ * `if (!r.ok)` flows into the error branch even when `T` is a deep object type
117
+ * (TS's discriminant else-narrowing otherwise mis-fires on very large `data`
118
+ * shapes). Callers can therefore use `!r.ok` / `r.ok` freely.
119
+ */
120
+ export type SafeParseResult<T> =
121
+ | { ok: true; data: T; error?: never }
122
+ | { ok: false; error: string; data?: never };
123
+
124
+ /**
125
+ * Validate `data` against `schema`, returning a discriminated result instead of
126
+ * throwing. On failure `error` is a short, human-readable reason (the first
127
+ * Zod issue) suitable for a `<BlockError reason=…>` card.
128
+ *
129
+ * The output type is taken from the schema (`z.infer`) so callers get the
130
+ * precisely-typed `data` on the success branch with no extra type argument.
131
+ */
132
+ export function safeParseBlock<S extends z.ZodTypeAny>(
133
+ schema: S,
134
+ data: unknown,
135
+ ): SafeParseResult<z.infer<S>> {
136
+ const result = schema.safeParse(data);
137
+ if (result.success) return { ok: true, data: result.data };
138
+ const first = result.error.issues[0];
139
+ const reason = first
140
+ ? `${first.path.length ? first.path.map(String).join('.') + ': ' : ''}${first.message}`
141
+ : 'Invalid payload';
142
+ return { ok: false, error: reason };
143
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared, serializable block payload TYPES — the wire shapes that more than
3
+ * one surface (chat message blocks + the NotionEditor block NodeViews) must
4
+ * agree on. Defining them ONCE here keeps the shapes from drifting: chat's
5
+ * `tools/chat/types/block.ts` and the Notion nodes both reference these.
6
+ *
7
+ * Plain JSON only (no Date / no functions / no React nodes) so payloads
8
+ * survive history persistence, SSE transport, AND TipTap node attrs +
9
+ * markdown round-trips.
10
+ */
11
+
12
+ /** A single map marker — a pin plus optional label / icon / colour / card. */
13
+ export interface MapMarkerPayload {
14
+ id: string;
15
+ lat: number;
16
+ lng: number;
17
+ label?: string;
18
+ /** Image URL rendered as the marker pin (rounded thumbnail). */
19
+ icon?: string;
20
+ /** CSS color for the default pin (ignored when `icon` is set). */
21
+ color?: string;
22
+ /**
23
+ * Info-card opened above the pin on click (Google-Maps style). Pure JSON —
24
+ * strings/URLs only; actions are `href`-only links (no `onClick`/`icon`,
25
+ * which can't cross the wire). Rendered by the map's default `MarkerCard`.
26
+ */
27
+ card?: {
28
+ title: string;
29
+ description?: string;
30
+ image?: string;
31
+ badge?: string;
32
+ actions?: Array<{ label: string; href?: string }>;
33
+ };
34
+ }
35
+
36
+ /** An ordered polyline (route / path). */
37
+ export interface MapRoutePayload {
38
+ id: string;
39
+ points: Array<{ lat: number; lng: number }>;
40
+ color?: string;
41
+ label?: string;
42
+ }
43
+
44
+ /** A filled polygon (zone), auto-closed. */
45
+ export interface MapPolygonPayload {
46
+ id: string;
47
+ points: Array<{ lat: number; lng: number }>;
48
+ fillColor?: string;
49
+ strokeColor?: string;
50
+ label?: string;
51
+ }
52
+
53
+ /**
54
+ * Serializable map payload shared by the chat `MapBlock` and the NotionEditor
55
+ * `mapBlock` node. A center + optional zoom / basemap / terrain / markers /
56
+ * routes / polygons. The chat block extends this with chat-only concerns
57
+ * (a `caption`, `userLocation` opt-in); the Notion node serializes this whole
58
+ * object to/from a fenced ```` ```map ```` JSON block.
59
+ */
60
+ export interface MapBlockPayload {
61
+ center: { lat: number; lng: number };
62
+ zoom?: number;
63
+ /**
64
+ * Basemap style — a built-in key (`'light'`/`'dark'`/`'streets'` ·
65
+ * `'liberty'`/`'bright'`/`'positron'`) or a full style-JSON URL. When
66
+ * omitted the map uses its default basemap.
67
+ */
68
+ basemap?: string;
69
+ /** Enable 3D terrain + hillshade (free AWS Terrarium DEM, no key). */
70
+ terrain?: boolean;
71
+ markers?: MapMarkerPayload[];
72
+ /** Polylines (routes / paths). */
73
+ routes?: MapRoutePayload[];
74
+ /** Filled zones (polygons). */
75
+ polygons?: MapPolygonPayload[];
76
+ }
@@ -0,0 +1,67 @@
1
+ # chips
2
+
3
+ Surface-agnostic presentational "chips" — compact inline pills that render a
4
+ URL or a file path with a favicon/icon + middle-ellipsis label. They match the
5
+ TipTap composer's `editor-chip` look (see
6
+ [`forms/MarkdownEditor/chip/ChipNode.ts`](../../tools/forms/MarkdownEditor/chip/ChipNode.ts))
7
+ but are plain React, so the same visual is reusable on any surface — composer,
8
+ chat bubble, link lists.
9
+
10
+ Internal module (`src/common/chips/`) — consumed by other tools via relative
11
+ import; not a package subpath export:
12
+
13
+ ```tsx
14
+ import { UrlChip, FilePathChip } from '../../common/chips';
15
+ ```
16
+
17
+ ## `UrlChip`
18
+
19
+ A compact, clickable URL chip (Chrome-omnibox style): a favicon + the domain
20
+ with a middle-ellipsised path (`github.com/wailsapp/…/README.md`), monospace,
21
+ with the full href on `title` hover. Renders an `<a target="_blank"
22
+ rel="noopener noreferrer">`, so it opens in a new tab.
23
+
24
+ - **Favicon fallback.** The favicon `<img>` swaps to a lucide `Globe` glyph on
25
+ load error (offline / blocked / unknown host) — and also when the host is
26
+ unparseable (empty favicon URL) — so the chip never shows a broken image.
27
+ - **Presentational + self-contained.** It imports only the pure URL detector
28
+ helpers from [`forms/MarkdownEditor/url/detect`](../../tools/forms/MarkdownEditor/url/detect.ts)
29
+ — no TipTap / ProseMirror — so it can render a known URL anywhere:
30
+ `splitUrl` (host/domain/path split), `truncateUrlLabel` (the
31
+ middle-ellipsis label), and `faviconUrl` (Google S2 favicon).
32
+
33
+ ### Props (`UrlChipProps`)
34
+
35
+ | Prop | Type | Default | Description |
36
+ |---|---|---|---|
37
+ | `href` | `string` | required | The full, navigable href (`https://…`). |
38
+ | `maxChars` | `number` | `40` | Approximate max chars for the truncated label. The domain is always kept in full; only the path's middle collapses. |
39
+ | `className` | `string` | — | Extra classes merged onto the chip anchor. |
40
+
41
+ ```tsx
42
+ <UrlChip href="https://github.com/wailsapp/wails/blob/main/v3/README.md" />
43
+ // → 🟣 github.com/…/README.md
44
+ ```
45
+
46
+ ## `FilePathChip`
47
+
48
+ Re-exported (with its `FilePathChipProps` type) from the editor's standalone
49
+ path chip,
50
+ [`forms/MarkdownEditor/filePath/FilePathChip.tsx`](../../tools/forms/MarkdownEditor/filePath/FilePathChip.tsx).
51
+ It's already presentational and dependency-light, so this module re-exports it
52
+ rather than duplicating it — `UrlChip` and `FilePathChip` then live behind one
53
+ import.
54
+
55
+ ## Where these are reused
56
+
57
+ - **Chat markdown bubble.** `UrlChip` is what
58
+ [`MarkdownMessage`'s `urlChipRule`](../../tools/dev/code/MarkdownMessage/README.md)
59
+ renders for a bare URL when the host opts in via `ChatConfig.linkChips`.
60
+ - **Composer.** The TipTap composer renders its **own** DOM chip via `ChipNode`
61
+ (a single inline atom node, `kind: 'url'`), not this React component — but the
62
+ two are built to match pixel-for-pixel using the same semantic tokens, so a
63
+ URL reads identically whether it's being typed in the composer or sitting in a
64
+ rendered bubble.
65
+
66
+ This shared look is the point: the same URL is one consistent chip across every
67
+ surface.
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `<UrlChip>` — a compact, clickable "URL chip" (Chrome-omnibox style):
5
+ * a favicon + the domain with a middle-ellipsised path
6
+ * (`github.com/wailsapp/…/README.md`), monospace, with the full href on
7
+ * hover. Opens in a new tab.
8
+ *
9
+ * This is the SURFACE-AGNOSTIC, React counterpart of the TipTap composer's
10
+ * `editorChip` (`kind: 'url'`, see `forms/MarkdownEditor/chip/ChipNode.ts`).
11
+ * It matches that chip's look pixel-for-pixel using semantic tokens, so a
12
+ * URL reads identically whether it sits in the composer (being typed) or in
13
+ * a rendered chat bubble (`MarkdownMessage`'s `urlChipRule`).
14
+ *
15
+ * Presentational + self-contained: it imports only the pure URL detector
16
+ * helpers (`url/detect`) — no TipTap / ProseMirror — so it can render a
17
+ * known URL anywhere (chat messages, link lists, etc).
18
+ *
19
+ * Favicon degradation: the favicon `<img>` falls back to a lucide `Globe`
20
+ * glyph on `onError` (offline / blocked / unknown host) so the chip never
21
+ * shows a broken-image marker — mirroring the composer chip's behaviour.
22
+ */
23
+
24
+ import { useState } from 'react';
25
+ import { Globe } from 'lucide-react';
26
+
27
+ import { cn } from '@djangocfg/ui-core/lib';
28
+ import {
29
+ splitUrl,
30
+ truncateUrlLabel,
31
+ faviconUrl,
32
+ } from '../markdown/url/detect';
33
+
34
+ export interface UrlChipProps {
35
+ /** The full, navigable href (`https://…`). */
36
+ href: string;
37
+ /**
38
+ * Approximate max characters for the truncated label. The domain is
39
+ * always preserved; only the path's middle collapses. Default 40.
40
+ */
41
+ maxChars?: number;
42
+ className?: string;
43
+ }
44
+
45
+ /**
46
+ * Render a URL as an inline, clickable chip. Favicon → globe glyph on
47
+ * favicon load error.
48
+ */
49
+ export function UrlChip({ href, maxChars = 40, className }: UrlChipProps) {
50
+ const { host } = splitUrl(href);
51
+ const favicon = faviconUrl(host);
52
+ const label = truncateUrlLabel(href, { maxChars });
53
+
54
+ // Favicon load failure → swap to the globe glyph. Also covers the empty-
55
+ // favicon case (unparseable host → faviconUrl returns '').
56
+ const [imgFailed, setImgFailed] = useState(false);
57
+ const showGlobe = imgFailed || !favicon;
58
+
59
+ return (
60
+ <a
61
+ href={href}
62
+ target="_blank"
63
+ rel="noopener noreferrer"
64
+ title={href}
65
+ className={cn(
66
+ // Inline pill matching the composer's `editor-chip--url`: muted
67
+ // surface + border, monospace, semantic tokens only. `max-w` +
68
+ // `truncate` (on the label) is the CSS safety net under the
69
+ // precomputed middle-ellipsis label. No raw blue link styling —
70
+ // the primary accent only lights up the border on hover + the
71
+ // globe glyph.
72
+ 'inline-flex max-w-[22rem] items-center gap-1 rounded align-baseline',
73
+ 'border border-border bg-muted px-1.5 py-0.5',
74
+ 'font-mono text-[0.8125em] leading-tight text-foreground no-underline',
75
+ 'cursor-pointer transition-colors hover:border-primary',
76
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
77
+ className,
78
+ )}
79
+ >
80
+ <span
81
+ className={cn(
82
+ 'flex size-3.5 shrink-0 items-center justify-center',
83
+ showGlobe && 'text-primary',
84
+ )}
85
+ aria-hidden="true"
86
+ >
87
+ {showGlobe ? (
88
+ <Globe className="size-3.5" />
89
+ ) : (
90
+ <img
91
+ src={favicon}
92
+ alt=""
93
+ width={14}
94
+ height={14}
95
+ className="size-3.5 rounded-[2px]"
96
+ onError={() => setImgFailed(true)}
97
+ />
98
+ )}
99
+ </span>
100
+ <span className="truncate">{label}</span>
101
+ </a>
102
+ );
103
+ }
104
+
105
+ export default UrlChip;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Surface-agnostic presentational "chips" — compact inline pills that
3
+ * render a URL or a file path with a favicon/icon + middle-ellipsis label.
4
+ *
5
+ * These match the TipTap composer's `editorChip` look (see
6
+ * `forms/MarkdownEditor/chip/ChipNode.ts`) but are plain React, so the same
7
+ * visual is reusable across every surface (composer, chat bubble, lists).
8
+ *
9
+ * - `UrlChip` — clickable URL chip (favicon → globe fallback).
10
+ * - `FilePathChip` — local file-path chip (re-exported from the editor's
11
+ * standalone path chip; it's already presentational/dependency-light, so
12
+ * we wrap rather than duplicate).
13
+ */
14
+ export { UrlChip, default as UrlChipDefault } from './UrlChip';
15
+ export type { UrlChipProps } from './UrlChip';
16
+
17
+ export {
18
+ FilePathChip,
19
+ type FilePathChipProps,
20
+ } from '../markdown/filePath/FilePathChip';