Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,29 +93,24 @@ silently discarded.
node's attributes behind, so the envelope has to be re-attached. See
`src/editor.ts`.
- The language attribute: an imported `{lang="fr"}` span keeps its value on the
span mark and serializes back as the `{:fr}` sugar, a `<span lang>` in pasted
HTML parses onto the same mark, and a value that is not a language tag keeps
the `{lang="..."}` spelling. Asserted in `tests/language-attribute.test.ts`.
span mark and serializes back as the `{:fr}` sugar, the `{:fr}` shorthand
typed straight into the source pane parses onto that mark, a `<span lang>` in
pasted HTML parses onto the same mark, and a value that is not a language tag
keeps the `{lang="..."}` spelling. Asserted in
`tests/language-attribute.test.ts`.
- **Composite figures** (`::: figure` with no title and no label, Carve PART 9
section 4c): the group is one editable `carveFigureGroup`, its direct figure
and table children are the panels in source order, and the `^ ` caption below
the closing fence is the group's. Everything else in the body stays where it
was written. An opener carrying a quoted title or a `[label]` is a different
production and remains the generic container it always was. The mapping is
the CarveKit schema in `@markup-carve/carve-grammars`, not this app; both
halves of it - an engine that parses the construct and a schema entry that
models it - arrived with markup-carve/carve-grammars#225. Asserted in
`tests/composite-figure.test.ts`.

**Lossy / normalized (documented, not hidden):**

- **`{:TAG}` written directly in the Carve source pane** does not become a span
on import. The Tiptap layer carries the attribute in both directions, but the
parse happens in the carve-js build that carve-grammars pins for its own
loader (an exact commit inside carve-grammars, so this repository's pins
cannot move it), and that build predates the production: the run stays
literal text and comes back with the bracket escaped. Authoring the same span
as `{lang="fr"}` works today and serializes as `{:fr}`.
- **Composite figures** (`::: figure` with no title and no label, Carve PART 9
section 4c) are not modelled as figures. The mapping is the CarveKit schema in
`@markup-carve/carve-grammars`, not this app, and the engine that parses the
editor's input is the one carve-grammars pins for its own loader - which
predates the construct. So today a composite figure is a generic container
and round-trips as one; when that engine moves, the group arrives as a single
read-only source atom until carve-grammars gives it a schema entry.
`tests/composite-figure.test.ts` holds both states and fails when the second
one changes, which is the signal to model it here. Tracked as
markup-carve/carve-wysiwyg#15.
- **CriticMarkup containing its own closing delimiter** (`+}` / `-}` inside
`{+...+}` / `{-...-}`) cannot round-trip - Carve provides no escape for it.
This is an upstream serializer limitation noted in carve-grammars.
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@markup-carve/carve": "^0.1.2",
"@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#c0e72914c6b76c81374b48d7a225cc66549934e4",
"@markup-carve/carve-grammars": "github:markup-carve/carve-grammars#8a5d4c85f7d51daaa258bb10c9f1338113a02ed3",
"@tiptap/core": "^2.11.5",
"@tiptap/extension-code-block": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
Expand Down
113 changes: 85 additions & 28 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* exposes the serializer so the live Carve source pane can update on every
* change.
*/
import { Editor } from '@tiptap/core';
import { Editor, getSchema } from '@tiptap/core';
import type { JSONContent } from '@tiptap/core';
import { CarveKit, serializeToCarve } from '@markup-carve/carve-grammars/tiptap';

Expand Down Expand Up @@ -56,7 +56,13 @@ type Envelope = Record<string, unknown>;
* own: the fingerprint is that check, and the serializer falls through to
* ordinary serialization the moment the document is edited.
*/
const envelopes = new WeakMap<Editor, Envelope>();
const loaded = new WeakMap<Editor, { doc: JSONContent; envelope: Envelope }>();

/**
* The CarveKit schema, read once, so the attribute values Tiptap materializes
* from a node's declared defaults can be told from values the bridge set.
*/
const schema = getSchema([CarveKit]);

/** The envelope attrs of a bridge document, or null when it carries none. */
function envelopeOf(doc: JSONContent): Envelope | null {
Expand All @@ -69,42 +75,93 @@ function envelopeOf(doc: JSONContent): Envelope | null {
return Object.keys(kept).length ? kept : null;
}

/** Every attribute the schema declares for a node or mark, with its default. */
function schemaDefaults(type: unknown): Record<string, unknown> | null {
if (typeof type !== 'string') return null;
const spec = schema.nodes[type] ?? schema.marks[type];
if (!spec) return null;
const out: Record<string, unknown> = {};
for (const [name, attr] of Object.entries(spec.spec.attrs ?? {})) {
out[name] = (attr as { default?: unknown }).default;
}
return out;
}

/**
* Drop attributes the editor materialized from schema defaults.
* A document reduced to what the AUTHOR wrote, with everything the schema would
* put back stripped out.
*
* The point is to recognize an unedited document after a mount. Tiptap hands
* back every attribute a node's schema declares, so `{"class":"figure"}` comes
* back as `{"id":null,"keyValues":null,"label":null,"class":"figure",
* "title":null}` and the mounted document never compares equal to the one that
* was loaded.
*
* The envelope is guarded by a FINGERPRINT of the document it was taken from,
* and the fingerprint is over the bridge's JSON - which carries only the attrs
* that were actually set. Tiptap hands back every attribute the schema
* declares, so `{"class":"figure"}` returns as
* `{"id":null,"keyValues":null,"label":null,"class":"figure","title":null}` and
* the two never compare equal. Re-attaching the envelope without this is
* therefore inert: the fingerprint check fails every time and the verbatim
* source is never used.
* DROPPING NULLS IS NOT ENOUGH, and that is what this used to do. A default
* does not have to be null: `carveCaption` declares `short` with a default of
* `false`, so a caption came back carrying `{"short":false}` - not null, not
* pruned, never equal. The moment carve-grammars added that attribute, every
* document holding a caption stopped being recognized, the source envelope was
* discarded, and a block-attribute line above a construct the rich model does
* not carry was written back out without it. Nothing failed: the round trip
* simply became lossy, which is the failure mode the envelope exists to
* prevent.
*
* Dropping nulls is not a reinterpretation of the document. The serializer
* reads every one of these with optional chaining, so an absent attribute and a
* null one already mean the same thing to it - what changes is only whether the
* fingerprint can recognize its own document.
* SYMMETRY IS THE OTHER HALF. This runs over BOTH documents, never over the
* mounted one alone. The bridge does set some attributes to a value that is
* also the schema default - `carveComment` writes `block: false` for a `%%`
* line - so pruning only the mounted side would break exactly the documents
* pruning is meant to keep.
*/
function pruneDefaults(value: unknown): unknown {
if (Array.isArray(value)) return value.map(pruneDefaults);
function authored(value: unknown): unknown {
if (Array.isArray(value)) return value.map(authored);
if (!value || typeof value !== 'object') return value;
const node = value as Record<string, unknown>;
const defaults = schemaDefaults(node['type']);
const out: Record<string, unknown> = {};
for (const [key, inner] of Object.entries(value)) {
for (const [key, inner] of Object.entries(node)) {
if (inner === null) continue;
out[key] = pruneDefaults(inner);
if (key !== 'attrs' || !inner || typeof inner !== 'object') {
out[key] = authored(inner);
continue;
}
const attrs: Record<string, unknown> = {};
for (const [name, attrValue] of Object.entries(inner as Record<string, unknown>)) {
if (attrValue === null) continue;
if (defaults && name in defaults && stable(attrValue) === stable(defaults[name])) continue;
attrs[name] = authored(attrValue);
}
if (Object.keys(attrs).length) out['attrs'] = attrs;
}
const attrs = out['attrs'];
if (attrs && typeof attrs === 'object' && !Object.keys(attrs).length) delete out['attrs'];
return out;
}

/** `editor.getJSON()` with the loaded document's envelope put back on it. */
/** Key-order-independent string form, so two equal documents compare equal. */
function stable(value: unknown): string {
if (Array.isArray(value)) return '[' + value.map(stable).join(',') + ']';
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
return '{' + Object.keys(value as object).sort()
.map((k) => JSON.stringify(k) + ':' + stable((value as Record<string, unknown>)[k]))
.join(',') + '}';
}

/**
* What to serialize: the document as loaded when the editor still holds it, and
* the editor's own JSON once it has been edited.
*
* Handing the serializer the ORIGINAL document rather than a reconstruction of
* it is what makes the envelope usable. Its fingerprint was taken over that
* exact JSON, and the envelope is only honored while the fingerprint matches -
* so anything less than the original is a guess at what the fingerprint will
* accept. After an edit there is nothing to preserve: the editor's document IS
* the document, and ordinary serialization is correct.
*/
function withEnvelope(editor: Editor, json: JSONContent): JSONContent {
const envelope = envelopes.get(editor);
if (!envelope) return json;
const pruned = pruneDefaults(json) as JSONContent;
return { ...pruned, attrs: { ...(pruned.attrs ?? {}), ...envelope } };
const entry = loaded.get(editor);
if (!entry) return json;
const unedited = stable(authored(json.content ?? []))
=== stable(authored(entry.doc.content ?? []));
return unedited ? entry.doc : json;
}

export function createCarveEditor(opts: CarveEditorOptions): Editor {
Expand All @@ -127,8 +184,8 @@ export function createCarveEditor(opts: CarveEditorOptions): Editor {
*/
export function setCarveDocument(editor: Editor, doc: JSONContent): void {
const envelope = envelopeOf(doc);
if (envelope) envelopes.set(editor, envelope);
else envelopes.delete(editor);
if (envelope) loaded.set(editor, { doc, envelope });
else loaded.delete(editor);
editor.commands.setContent(doc);
}

Expand Down
43 changes: 43 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,46 @@ body {
.hard-break { color: var(--muted); }
.carve-insert { text-decoration: underline; text-decoration-color: #9ece6a; }
.carve-delete { text-decoration: line-through; text-decoration-color: #f7768e; }

/*
* Composite figures (Carve PART 9 section 4c).
*
* TWO SHAPES, ONE LOOK. The preview pane holds the engine's HTML - a
* `figure.carve-figure-group` wrapping a `div.carve-figure-panels` - while the
* editor holds CarveKit's node, which renders the panels directly inside the
* group with no panels row. Both are styled here so the group reads as one
* figure on either side of the split.
*/
.carve-figure-group,
figure[data-carve-figure-group] {
margin: 0.75rem 0;
padding: 0.5rem;
border: 1px solid var(--accent);
border-radius: 4px;
}
.carve-figure-panels,
figure[data-carve-figure-group] {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-start;
}
/* The group's own caption spans the row rather than sitting beside a panel. */
figure[data-carve-figure-group] > figcaption {
flex-basis: 100%;
}
.carve-figure-panel,
figure[data-carve-figure-group] > figure[data-carve-figure] {
flex: 1 1 12rem;
margin: 0;
}
.carve-figure-panel img,
figure[data-carve-figure-group] img {
max-width: 100%;
}
.carve-figure-group > figcaption,
figure[data-carve-figure-group] > figcaption {
margin-top: 0.5rem;
font-style: italic;
color: var(--muted);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
/**
* A quote's `^ …` attribution, through the editor's own import and serialize
* path.
* A caption on a quote, through the editor's own import and serialize path.
*
* The engine carries a caption on a quote as an `attribution` field on
* `block_quote` rather than a `figure`/`figcaption` pair, and the pinned
* carve-grammars loader has to project that field onto an editable node. When
* it does not, the line survives only inside the whole-document source
* envelope, which is keyed to a fingerprint of the untouched document - so the
* FIRST EDIT drops it.
* WHICH SHAPE THE ENGINE USES HAS CHANGED TWICE, and this file deliberately
* does not care. PART 9 section 4a briefly made the caption an `attribution`
* field on `block_quote`; that clause is withdrawn (markup-carve/carve#1213),
* and section 4b now says a quote is not a special host - a captioned quote is
* a `figure` whose target is the quote, which the loader projects onto a
* `carveFigure`/`carveCaption` pair like any other captioned host. The
* assertions below are about the line reaching the user's document, not about
* the node it arrives in, so they held across both pins.
*
* What they DO depend on is the line reaching an editable node rather than
* only the whole-document source envelope, which is keyed to a fingerprint of
* the untouched document - so the FIRST EDIT drops anything that lives only
* there.
*
* That is why every case here EDITS before serializing. Loading and serializing
* an untouched document returns the envelope verbatim and passes at any pin,
Expand Down Expand Up @@ -62,7 +68,7 @@ function editableText(): string {
return ((doc.content ?? []) as Array<Parameters<typeof walk>[0]>).map(walk).join(' ');
}

describe("a quote's attribution survives an edit", () => {
describe("a quote's caption survives an edit", () => {
it('keeps the attribution when something else is edited', () => {
const out = editElsewhereAndSerialize('> Stay hungry, stay foolish.\n^ Steve Jobs\n');
expect(out).toContain('EDITED');
Expand Down
Loading
Loading