@dogsbay/editor 0.0.0-stage → 0.2.0-beta.173

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/diff.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Change review — the DiffTree rendered as an editor view
3
+ * (plans/ide-change-review.md; plans/tree-diff-rendering.md names "the IDE's
4
+ * change review" as one of the views over ONE transform).
5
+ *
6
+ * `@dogsbay/tree-diff`'s `diffTrees(old, new)` yields a DiffTree: a TreeNode
7
+ * union tree carrying `diff` / `diffInline` / `diffPeer` / `diffMovedFrom`
8
+ * marks. Here it becomes a plain TreeNode tree the existing bridge already
9
+ * understands — block marks move to `meta.diff` (carried as `dogsbayProps`),
10
+ * changed prose becomes text runs flagged `diffOp: 'ins'|'del'` (two marks),
11
+ * a changed code/table-row pair renders peer (removed) then node — and the
12
+ * `DiffMarks` extension decorates the resulting ProseMirror nodes with
13
+ * `data-diff="…"`, exactly the vocabulary the comparison site's CSS uses. A
14
+ * tree with no marks renders identically to `treeToPmDoc`.
15
+ *
16
+ * Read-only by contract: the review pane never serializes back.
17
+ */
18
+ import { Extension, Mark } from "@tiptap/core";
19
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
20
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
21
+ import { treeToPmDoc } from "./bridge.js";
22
+ import { extensions } from "./schema.js";
23
+ const segmentsToInline = (segments) => segments
24
+ .filter((s) => s.text !== "")
25
+ .map((s) => ({ type: "text", text: s.text, ...(s.op === "eq" ? {} : { diffOp: s.op }) }));
26
+ const withMeta = (node, meta) => ({ ...node, meta: { ...(node.meta ?? {}), ...meta } });
27
+ /** The inline run shapes with their text removed: differ ⇒ an attribute changed (href, src, code/link structure). */
28
+ const inlineShape = (inline) => JSON.stringify(inline, (key, value) => (key === "text" ? undefined : value));
29
+ /** DiffTree → plain TreeNode tree with diff meta (the bridge carries meta verbatim). */
30
+ export function diffTreeToTree(nodes) {
31
+ const out = [];
32
+ for (const raw of nodes) {
33
+ const { diff, diffInline, diffPeer, diffMovedFrom, ...rest } = raw;
34
+ let node = rest;
35
+ const meta = {};
36
+ if (diff)
37
+ meta.diff = diff;
38
+ if (diffMovedFrom)
39
+ meta.diffMovedFrom = diffMovedFrom;
40
+ // word-level runs only when the visible text actually differs; a prose node
41
+ // whose text is identical but whose ATTRIBUTES changed (a link retargeted by
42
+ // rename-file, an image src) would otherwise look untouched — for those the
43
+ // old side is rendered as a peer instead, like a changed code block
44
+ const visibleChange = Boolean(diffInline?.some((s) => s.op !== "eq"));
45
+ if (visibleChange && Array.isArray(node.inline)) {
46
+ // tree-diff word-diffs nodeText(node), which INCLUDES nested children (a list
47
+ // item's sub-list) — so the runs already carry the children's text; keep the
48
+ // children too and the review shows it twice. Site parity: the runs replace
49
+ // the whole item (format-astro/src/diff-decoration.ts renders one <p>).
50
+ node = { ...node, inline: segmentsToInline(diffInline), children: undefined };
51
+ }
52
+ else if (node.children) {
53
+ node = { ...node, children: diffTreeToTree(node.children) };
54
+ }
55
+ if (Object.keys(meta).length)
56
+ node = withMeta(node, meta);
57
+ // Old side rendered as a "before" peer when the reader could not otherwise see
58
+ // what changed: a changed code/table-row pair, prose whose visible text is
59
+ // identical (attribute-only), or prose whose text AND an attribute changed
60
+ // (the runs show the words but not the retargeted link/image).
61
+ const attrChange = diffPeer !== undefined &&
62
+ Array.isArray(node.inline) &&
63
+ inlineShape(diffPeer.inline) !== inlineShape(raw.inline);
64
+ if (diffPeer && diff === "changed" && (!visibleChange || attrChange))
65
+ out.push(withMeta(diffPeer, { diff: "removed", diffPeerOf: "changed" }));
66
+ out.push(node);
67
+ }
68
+ return out;
69
+ }
70
+ export function diffTreeToPmDoc(nodes) {
71
+ return treeToPmDoc(diffTreeToTree(nodes));
72
+ }
73
+ /* ─────────────────────────── extension ─────────────────────────── */
74
+ export const DiffIns = Mark.create({
75
+ name: "diffIns",
76
+ parseHTML: () => [{ tag: "ins" }],
77
+ renderHTML: () => ["ins", { class: "dogsbay-diff-ins", "data-diff-inline": "ins" }, 0],
78
+ });
79
+ export const DiffDel = Mark.create({
80
+ name: "diffDel",
81
+ parseHTML: () => [{ tag: "del" }],
82
+ renderHTML: () => ["del", { class: "dogsbay-diff-del", "data-diff-inline": "del" }, 0],
83
+ });
84
+ const diffMetaOf = (node) => {
85
+ const carried = node.attrs?.dogsbayProps;
86
+ if (carried?.meta && (carried.meta.diff || carried.meta.diffMovedFrom))
87
+ return carried.meta;
88
+ // unmodelled blocks ride as verbatim JSON atoms; their meta is inside
89
+ if (node.type.name === "dogsbayBlock" && typeof node.attrs?.json === "string") {
90
+ try {
91
+ const parsed = JSON.parse(node.attrs.json);
92
+ if (parsed.meta?.diff)
93
+ return parsed.meta;
94
+ }
95
+ catch {
96
+ /* not ours */
97
+ }
98
+ }
99
+ return undefined;
100
+ };
101
+ const diffDecorations = (doc) => {
102
+ const decorations = [];
103
+ doc.descendants((node, pos) => {
104
+ const meta = diffMetaOf(node);
105
+ if (!meta?.diff)
106
+ return true;
107
+ const attrs = { "data-diff": meta.diff };
108
+ if (meta.diffPeerOf)
109
+ attrs["data-diff-peer"] = meta.diffPeerOf;
110
+ if (meta.diffMovedFrom)
111
+ attrs.title = `moved from ${meta.diffMovedFrom.join(" › ")}`;
112
+ decorations.push(Decoration.node(pos, pos + node.nodeSize, attrs));
113
+ // children of an added/removed subtree are implied by the root mark
114
+ return meta.diff === "changed" || meta.diff === "moved";
115
+ });
116
+ return DecorationSet.create(doc, decorations);
117
+ };
118
+ const diffMarksKey = new PluginKey("dogsbayDiffMarks");
119
+ /** Node decorations from `meta.diff` (data-diff="added|removed|changed|moved"). */
120
+ export const DiffMarks = Extension.create({
121
+ name: "dogsbayDiffMarks",
122
+ addProseMirrorPlugins() {
123
+ return [
124
+ new Plugin({
125
+ key: diffMarksKey,
126
+ state: {
127
+ init: (_config, state) => diffDecorations(state.doc),
128
+ apply: (tr, old) => (tr.docChanged ? diffDecorations(tr.doc) : old),
129
+ },
130
+ props: { decorations: (state) => diffMarksKey.getState(state) },
131
+ }),
132
+ ];
133
+ },
134
+ });
135
+ /** The read-only review schema: the editing extensions + the two diff marks + the decorator. */
136
+ export const diffExtensions = [...extensions, DiffIns, DiffDel, DiffMarks];
@@ -0,0 +1,16 @@
1
+ import type { PMNode } from "./bridge.js";
2
+ export type PreviewVars = Record<string, unknown>;
3
+ /** Evaluate a conditional's `test` under `vars`; `undefined` when it cannot be evaluated. */
4
+ export declare function evaluateTest(test: string, vars: PreviewVars): boolean | undefined;
5
+ /** Resolve a `{{ expr }}` under `vars`; `undefined` when unset or unevaluable. */
6
+ export declare function resolveExpr(expr: string, vars: PreviewVars): unknown;
7
+ /** Format a resolved value the way minja prints it (`String(value)`; never throws). */
8
+ export declare function formatValue(value: unknown): string;
9
+ /**
10
+ * The document's own `{% set name = value %}` definitions, in document
11
+ * order, evaluated as minja literals/expressions over the vars defined so
12
+ * far (a later `set` may reference an earlier one). Only TOP-LEVEL
13
+ * varDefs count — a `set` inside a conditional region is itself
14
+ * conditional and the preview must not pretend otherwise.
15
+ */
16
+ export declare function collectDocVarDefs(doc: PMNode, base?: PreviewVars): PreviewVars;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Directive preview evaluation (directive-model Phase 5b).
3
+ *
4
+ * The editor never RESOLVES directives — preserve mode keeps them as
5
+ * nodes and the publish pipeline evaluates them. It may PREVIEW them:
6
+ * given a variable context (a deliverable's attributes + the document's
7
+ * own `{% set %}` lines), which conditional regions are live and what a
8
+ * `{{ expr }}` chip would print. Evaluation goes through minja's own
9
+ * expression evaluator so the preview cannot disagree with the build.
10
+ *
11
+ * Every helper is total: a malformed expression yields `undefined`
12
+ * (shown as "unknown"), never a throw into a node view.
13
+ */
14
+ import { Context, evaluateExpression, parseExpression } from "@dogsbay/minja/browser";
15
+ function contextFor(vars) {
16
+ // hyphenToUnderscore normalizes the LOOKUP, not the stored keys, so a
17
+ // supplied `product-title` would never match — same alias rule as the
18
+ // build's preprocess (cli/src/site-build/preprocess.ts)
19
+ // build's preprocess); an EXPLICIT underscored key wins over the alias
20
+ const ctx = new Context(undefined, { hyphenToUnderscore: true });
21
+ for (const [k, v] of Object.entries(vars))
22
+ ctx.set(k, v);
23
+ for (const [k, v] of Object.entries(vars)) {
24
+ if (!k.includes("-"))
25
+ continue;
26
+ const u = k.replace(/-/g, "_");
27
+ if (!(u in vars))
28
+ ctx.set(u, v);
29
+ }
30
+ return ctx;
31
+ }
32
+ /** Evaluate a conditional's `test` under `vars`; `undefined` when it cannot be evaluated. */
33
+ export function evaluateTest(test, vars) {
34
+ try {
35
+ const value = evaluateExpression(parseExpression(test), contextFor(vars));
36
+ return isTruthy(value);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Resolve a `{{ expr }}` under `vars`; `undefined` when unset or unevaluable. */
43
+ export function resolveExpr(expr, vars) {
44
+ try {
45
+ return evaluateExpression(parseExpression(expr), contextFor(vars));
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /** Format a resolved value the way minja prints it (`String(value)`; never throws). */
52
+ export function formatValue(value) {
53
+ if (value === undefined || value === null)
54
+ return "";
55
+ try {
56
+ return String(value);
57
+ }
58
+ catch {
59
+ return "";
60
+ }
61
+ }
62
+ /** minja's rule (renderer.ts isTruthy): only undefined/null/false/0/'' are falsy — `[]` and `{}` are truthy. */
63
+ function isTruthy(v) {
64
+ return !(v === undefined || v === null || v === false || v === 0 || v === "");
65
+ }
66
+ /**
67
+ * The document's own `{% set name = value %}` definitions, in document
68
+ * order, evaluated as minja literals/expressions over the vars defined so
69
+ * far (a later `set` may reference an earlier one). Only TOP-LEVEL
70
+ * varDefs count — a `set` inside a conditional region is itself
71
+ * conditional and the preview must not pretend otherwise.
72
+ */
73
+ export function collectDocVarDefs(doc, base = {}) {
74
+ const vars = { ...base };
75
+ for (const node of doc.content ?? []) {
76
+ if (node.type !== "varDef")
77
+ continue;
78
+ const name = String(node.attrs?.name ?? "").trim();
79
+ if (!name)
80
+ continue;
81
+ // minja's `set` stores whatever the expression evaluates to — including
82
+ // undefined for an unset name; inventing the raw text here would make a
83
+ // later `{% if name %}` preview LIVE where the build renders nothing
84
+ vars[name] = resolveExpr(String(node.attrs?.value ?? ""), vars);
85
+ }
86
+ return vars;
87
+ }
@@ -0,0 +1,8 @@
1
+ export type DirectiveField = "expr" | "test" | "src" | "name" | "value";
2
+ /** Delimiters that would open/close a tag inside a tag. */
3
+ export declare const DIRECTIVE_MARKERS: readonly ["{%", "%}", "{{", "}}", "{#", "#}"];
4
+ /**
5
+ * Returns an error message for a value the chip must refuse, or null when
6
+ * the (trimmed) value is acceptable. Callers trim before storing.
7
+ */
8
+ export declare function validateDirectiveField(field: DirectiveField, raw: string): string | null;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Validation for directive chip fields (directive-model 5b).
3
+ *
4
+ * The serializer interpolates a directive node's attributes VERBATIM into
5
+ * `{{ expr }}` / `{% if test %}` / `{% include "src" %}` / `{% set name =
6
+ * value %}` — minja has no escape syntax for these, so a value that
7
+ * contains a tag delimiter cannot be emitted safely: `a %} b` as a test
8
+ * produces `{% if a %} b %}` and the region's body is no longer guarded
9
+ * (and a `{% raw %}` typed anywhere swallows the page to the next
10
+ * `{% endraw %}` on reparse). The chips therefore REFUSE such values, and
11
+ * the serializer throws on them as a second line (a programmatic
12
+ * updateAttributes cannot bypass the editor form).
13
+ */
14
+ import { parseExpression } from "@dogsbay/minja/browser";
15
+ /** Delimiters that would open/close a tag inside a tag. */
16
+ export const DIRECTIVE_MARKERS = ["{%", "%}", "{{", "}}", "{#", "#}"];
17
+ /** minja `set` names (and what directive-preserve recognises as a set). */
18
+ const SET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
19
+ /**
20
+ * Returns an error message for a value the chip must refuse, or null when
21
+ * the (trimmed) value is acceptable. Callers trim before storing.
22
+ */
23
+ export function validateDirectiveField(field, raw) {
24
+ const v = raw.trim();
25
+ const marker = DIRECTIVE_MARKERS.find((m) => v.includes(m));
26
+ if (marker)
27
+ return `must not contain "${marker}"`;
28
+ if (/[\r\n]/.test(v))
29
+ return "must be a single line";
30
+ switch (field) {
31
+ case "expr":
32
+ if (!v)
33
+ return "expression is required";
34
+ return null;
35
+ case "test":
36
+ if (!v)
37
+ return "condition is required";
38
+ return null;
39
+ case "src":
40
+ if (!v)
41
+ return "include path is required";
42
+ if (/["']/.test(v))
43
+ return "path must not contain quotes";
44
+ return null;
45
+ case "name":
46
+ if (!SET_NAME.test(v))
47
+ return "name must be a valid identifier (letters, digits, _)";
48
+ return null;
49
+ case "value":
50
+ if (!v)
51
+ return "value is required";
52
+ try {
53
+ parseExpression(v);
54
+ }
55
+ catch {
56
+ return 'not a valid expression — quote strings ("…")';
57
+ }
58
+ return null;
59
+ }
60
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @dogsbay/editor — TipTap/ProseMirror editor layer for the TreeNode
3
+ * vocabulary.
4
+ *
5
+ * Two React-free surfaces:
6
+ * - schema: TipTap extensions (StarterKit + Callout + Variable +
7
+ * dogsbayBlock/dogsbayInline verbatim-passthrough atoms)
8
+ * - bridge: pure-JSON TreeNode[] ⇄ ProseMirror-doc conversion with the
9
+ * "never drop what you can't model" escape hatch
10
+ *
11
+ * Consumers bring their own view layer (@tiptap/react etc.). The dev
12
+ * harness in main.tsx / index.html is not part of the published surface.
13
+ */
14
+ export { extensions, Callout, Variable, Conditional, Include, VarDef, DirectiveRaw, ConditionalSpan, DirectiveRawInline, DogsbayBlock, DogsbayInline, DogsbayProps, } from "./schema.js";
15
+ export { treeToPmDoc, pmDocToTree, treeToPmBlocks } from "./bridge.js";
16
+ export type { PMNode, PMMark } from "./bridge.js";
17
+ export { diffTreeToTree, diffTreeToPmDoc, diffExtensions, DiffMarks, DiffIns, DiffDel, type DiffMeta, type DiffKind, } from "./diff.js";
18
+ export { evaluateTest, resolveExpr, formatValue, collectDocVarDefs, type PreviewVars, } from "./directive-context.js";
19
+ export { validateDirectiveField, DIRECTIVE_MARKERS, type DirectiveField } from "./directive-fields.js";
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @dogsbay/editor — TipTap/ProseMirror editor layer for the TreeNode
3
+ * vocabulary.
4
+ *
5
+ * Two React-free surfaces:
6
+ * - schema: TipTap extensions (StarterKit + Callout + Variable +
7
+ * dogsbayBlock/dogsbayInline verbatim-passthrough atoms)
8
+ * - bridge: pure-JSON TreeNode[] ⇄ ProseMirror-doc conversion with the
9
+ * "never drop what you can't model" escape hatch
10
+ *
11
+ * Consumers bring their own view layer (@tiptap/react etc.). The dev
12
+ * harness in main.tsx / index.html is not part of the published surface.
13
+ */
14
+ export { extensions, Callout, Variable, Conditional, Include, VarDef, DirectiveRaw, ConditionalSpan, DirectiveRawInline, DogsbayBlock, DogsbayInline, DogsbayProps, } from "./schema.js";
15
+ export { treeToPmDoc, pmDocToTree, treeToPmBlocks } from "./bridge.js";
16
+ export { diffTreeToTree, diffTreeToPmDoc, diffExtensions, DiffMarks, DiffIns, DiffDel, } from "./diff.js";
17
+ export { evaluateTest, resolveExpr, formatValue, collectDocVarDefs, } from "./directive-context.js";
18
+ export { validateDirectiveField, DIRECTIVE_MARKERS } from "./directive-fields.js";
@@ -0,0 +1,54 @@
1
+ /**
2
+ * TipTap schema for the TreeNode vocabulary: StarterKit (paragraph,
3
+ * heading, lists, blockquote, codeBlock, horizontalRule, hardBreak,
4
+ * bold/italic/strike/code/link marks) plus the custom pieces:
5
+ * - callout — a block container
6
+ * - variable — an inline atom (the directive-chip mechanism)
7
+ * - dogsbayBlock — a block atom carrying verbatim TreeNode JSON (lossless
8
+ * passthrough for tables and anything unmodeled)
9
+ * - dogsbayInline — the inline equivalent
10
+ * - DogsbayProps — a hidden global attribute on every modeled block that
11
+ * carries the original TreeNode props/meta verbatim, so
12
+ * fields the editor doesn't model (code title/highlights,
13
+ * heading {#id .class}, ordered-list start, …) survive
14
+ * the round trip. ProseMirror drops undeclared attrs, so
15
+ * this MUST be a declared attribute.
16
+ *
17
+ * Underline is disabled: InlineText has no underline flag (markdown cannot
18
+ * express it), so anything underlined in the editor would be silently
19
+ * stripped on serialize — better not to offer it.
20
+ */
21
+ import { Extension, Node } from "@tiptap/core";
22
+ export declare const DogsbayProps: Extension<any, any>;
23
+ /**
24
+ * `tableRole` is a prosemirror-tables NodeSpec field, not a TipTap one, so
25
+ * it is declared on NodeConfig here and copied into the schema spec by
26
+ * `TableEditing.extendNodeSchema` below (the same mechanism TipTap's own
27
+ * table extension uses).
28
+ */
29
+ declare module "@tiptap/core" {
30
+ interface NodeConfig<Options = any, Storage = any> {
31
+ tableRole?: string | ((this: {
32
+ name: string;
33
+ options: Options;
34
+ storage: Storage;
35
+ }) => string);
36
+ }
37
+ }
38
+ export declare const Table: Node<any, any>;
39
+ export declare const TableRow: Node<any, any>;
40
+ export declare const TableHeader: Node<any, any>;
41
+ export declare const TableCell: Node<any, any>;
42
+ /** prosemirror-tables' editing plugin + cell navigation (Tab / Shift-Tab). */
43
+ export declare const TableEditing: Extension<any, any>;
44
+ export declare const Callout: Node<any, any>;
45
+ export declare const Variable: Node<any, any>;
46
+ export declare const Conditional: Node<any, any>;
47
+ export declare const Include: Node<any, any>;
48
+ export declare const VarDef: Node<any, any>;
49
+ export declare const DirectiveRaw: Node<any, any>;
50
+ export declare const ConditionalSpan: Node<any, any>;
51
+ export declare const DirectiveRawInline: Node<any, any>;
52
+ export declare const DogsbayBlock: Node<any, any>;
53
+ export declare const DogsbayInline: Node<any, any>;
54
+ export declare const extensions: (Node<any, any> | Extension<any, any> | Extension<import("@tiptap/starter-kit").StarterKitOptions, any>)[];