FileLoveNote / build packageKit revision · 28 October 2026

13 / 15 · Markdown Compatibility

Markdown without silent content loss

Separate readable Markdown exchange from the app-preserving Drive file format. State which structures survive each path and stop a failed conversion before it overwrites good writing.

Mandatory core compatibility.

This chapter makes the Markdown and Drive acceptance contract concrete for FR-03, FR-16 and FR-17, build phases P03/P04 and V16. These are core requirements. AI, canvas and other extensions in FR-18–FR-21 remain optional, but existing supported reference data must not be silently discarded when an extension is not rendered.

Current source evidence below records observed behavior, including gaps. Required rebuild rules define what a generated implementation must satisfy; existing lossy behavior is not an instruction to copy a bug.

1 · Two different formats

PathPurposeRetention contract
Plain Markdown import/exportReadable exchange through parseMarkdown/serializeMarkdown and the notes-markdown-service helpers.Preserve supported Markdown semantics. Report rich-format losses before a lossy export. Do not promise exact JSON, layout, whitespace or metadata retention.
App-preserving Drive MarkdownA readable .md file with a filesandnotes: JSON metadata line and encoded per-block node payloads.Unchanged supported blocks retain their structured JSON and metadata. A changed readable block is reparsed. The required rebuild must retain all supported nodes/marks/attributes, not only their visible text.

The record envelope and privacy boundary are in Data Structure; request/response behavior is in API Contracts. This format does not upload GB-sized source bytes again. Notes contain source references. Base64 encodes JSON; it is not encryption.

2 · Supported node and attribute matrix

These are the actual schema names and defaults. An omitted supported attribute receives its schema default when parsed into a ProseMirror node. The Drive decoder can return the original raw JSON with omitted defaults still omitted; compare schema-normalized meaning where appropriate. Do not invent a second image node named image: the schema uses imageEmbed.

Node(s)Structure and defaultsPlain Markdown retention / fallback
doc, paragraph, textdoc: one or more blocks; paragraph: zero or more inline children; text: nonempty text string. No attributes.Paragraph text and ordering are supported. Markdown whitespace/list formatting may normalize; compare document semantics, not source bytes.
headinglevel:1, uid:null, slug:""; inline children. The UI exposes H1–H4. The Markdown parser also accepts H5/H6; DOM rendering clamps to H4.# through ###### import/export. uid and slug do not survive plain Markdown. Do not promise visual H5/H6 styles that the UI does not have.
blockquote, horizontal_rule, hard_breakBlockquote: one or more blocks; rule: block atom; hard break: inline node. No attributes.Quote, thematic break and hard-line-break syntax are supported. A soft source newline is not necessarily a hard_break.
code_blockText children; no marks or attributes. In particular, the schema has no language or params field.Fenced/indented code imports; code text exports in a fence. A fence language such as js is discarded by the current parser. Keep this limitation explicit or add a tested schema migration before retaining language.
ordered_list, bullet_list, list_itemOrdered list order:1; lists contain list_item+. Items contain paragraph block*. Bullet lists/items have no attributes.Starting order and nested list content are supported. Bullet character, source indentation and tight/loose spacing may normalize.
task_list, task_itemList contains task_item+; item contains paragraph block*, with status:null, checked:false. Effective status: todo/done/cancelled; explicit valid status wins, otherwise checked maps to done/todo.- [ ], - [x] (also X) and app extension - [-] map to todo/done/cancelled. Plain import normalizes both fields. Mixed task/ordinary bullet runs become separate lists. These remain note text, not a new task workflow.
table, table_rowTable numberedRows:false, children table_row+. Row contains zero or more table_cell/table_header. No row attributes.GFM-style pipe tables are supported. First exported row becomes the Markdown header regardless of its original cell type. Numbered-row display is lost in plain Markdown.
table_cell, table_headercolspan:1, rowspan:1, colwidth:null, background:null, color:null, textAlign:null; one or more blocks per cell.Pipe-table import supports left/center/right alignment. Export chooses a column alignment from the first available aligned cell, flattens each cell’s blocks/newlines to one line and escapes pipes. Merges, widths, colours, per-cell alignment differences and block structure are not losslessly portable.
imageEmbedBlock atom: src:"", caption:"", width:null. Width is a pixel number or null, not a byte payload.Exports ![caption](src); plain Markdown has no width field. Current gap: importing an image token throws. Required safe import behavior is defined below; do not reproduce that failure.
audioEmbed, videoEmbedBlock atoms, each with src:"". Drive URLs refer to existing source files.Export as [Audio](src) / [Video](src). Plain import creates ordinary links, not players. A plain export must disclose loss of player-node identity.
inlineMediaReferenceInline atom: src:"", kind:"audio", name:""; rendered kind is audio/video.Exports [Audio: name](src) or [Video: name](src). Plain import retains label/URL as a link, losing the atom and its distinct kind/name attributes.
documentReferenceInline atom: docId:"", title:"Untitled". ID is a logical note ID, not a Drive file ID.Exports [title](/notes?id=docId). Plain import retains a link but not the reference atom or automatic reference-title behavior.
canvasBlock atom: canvasId:"", summary:null. Known summary fields: title, elementCount, refTitles. The display fallback is “Untitled canvas”, 0, [].Exports [Canvas: title](#canvas-id). Plain import keeps a link only. Even the app-preserving note format stores a canvas reference/summary, not the separate canvas JSON, geometry or connectors.
youtubeReferenceBlock atom: src:"", title:"YouTube video", channelTitle:"", thumbnailUrl:"", videoId:"".Exports [YouTube: title](src). Plain import makes an ordinary link; channel, thumbnail and video ID attributes are lost.
youtubeSearchBlock atom with query:"". This is an optional insertion control, not an external result archive.Plain serializer emits no readable body for this node. The Drive block payload preserves it when untouched. A plain exporter must report this omission; never silently erase it during sync.

Supported structured blocks and their supported attributes must survive an untouched app-preserving Drive round trip. Schema validity still matters: a JSON object with a type string is not proof of valid child nesting, valid cell geometry or a safe URL. Check the document and table invariants before accepting it.

3 · Supported mark matrix

MarkAttributes and defaultsPlain Markdown behavior in current source
strongNo attributes.Exports **text**; ordinary bold Markdown imports as strong.
emNo attributes.Exports *text*; emphasis imports as em.
codeNo attributes.Inline code delimiters are supported. Fenced block code uses the separate code_block node.
linkRequired href; target:"_blank", rel:"noopener noreferrer". No schema title attribute.Exports a Markdown link. Import recreates default target/rel; custom values and Markdown link-title text are not retained. Validate URLs separately; a schema field alone is not a URL policy.
underlineNo attributes.Current export uses __text__, but the Markdown parser interprets it as strong. This is a known lossy mismatch, not an underline round trip.
strikeNo attributes.Current export uses ~~text~~. Although an s token mapping exists, the current CommonMark tokenizer does not enable strikethrough; import leaves the delimiters as literal text.
highlightNo colour attribute.Current export uses ==text==; import leaves it as literal text. Input rules in the live editor are a different path from Markdown-file import.

Strong, em, underline, strike, code, link and highlight are all supported editor marks. Only the plain-file importer has the noted gaps. The Drive block payload preserves these marks in untouched supported body blocks. Do not call this a complete GFM implementation: pipe tables and task syntax are enabled, but not every GFM extension.

4 · Required rebuild: safe plain Markdown exchange

  1. Basic Markdown: import/export paragraphs, headings, quotes, ordinary lists, ordered starts, rules, hard breaks, code, links, pipe tables and tasks with tested semantic round trips. Preserve the distinctions between task states; do not turn note tasks into account workflow states.
  2. Images: a standalone Markdown image becomes an imageEmbed with its safe src and alt text as caption. The schema has no inline image node; an image mixed into a sentence falls back to a normal link with visible alt text. The importer must not throw merely because it sees an image token.
  3. Safe URLs: allow validated http/https URLs and the documented app-relative source/note routes. Reject or render inert javascript:, data: HTML, executable/unknown schemes and unsafe protocol-relative destinations. Preserve the visible label/alt text when a URL is rejected. Never fetch a source just to import a note and never move or duplicate the original Drive file.
  4. Lossy features: plain Markdown cannot represent the current schema’s image width, cell merges/colours/widths, heading IDs, player/reference atoms, canvas summary or custom link target/rel. Make that loss visible before export. Use readable link fallbacks for source/note/canvas references; do not claim that importing those links recreates the original typed atoms.
  5. Formatting gaps: keep underline, highlight and strike intact in the app-preserving path. For plain exchange, define and test an explicit supported syntax or a warned text-only fallback for the unsupported marks. Do not silently convert underline to bold or advertise literal delimiters as restored formatting. Escape Markdown labels, cell separators and other delimiters so user text cannot change the intended link/block structure. Retaining code language requires a declared schema change; the current code_block schema has no such field.
  6. Rich tables: the plain fallback flattens cells to text and column alignment. Warn about omitted numbering, per-cell layout and rich block structure. Keep the original table unchanged in the app-preserving path, including colours, widths and merge attributes.
  7. Failed import: preserve the existing note, confirmed base and pending operation. Return a per-note validation/read error with the existing retry behavior. An error must never be replaced by an empty paragraph and then treated as a successful import.

The rebuild may support more plain syntax only with a declared testable contract. This chapter does not require a new editor, a new file format version, a new workflow or automatic conversion of old notes.

5 · App-preserving Drive block encoding

  1. The current encoder writes exactly one filesandnotes: JSON line between opening and closing frontmatter delimiters. It is not a general YAML parser. Metadata excludes content, driveRevision, driveFileId and driveReadError; other supplied record metadata is retained.
  2. Each top-level block has an opening comment containing Base64 JSON for that raw node, a readable Markdown projection, and a closing comment. Nested blocks, marks and attributes are inside the top-level payload.
  3. On read, the current decoder serializes the decoded node again. If the readable block body matches that projection after trimming its outer whitespace, it keeps the original raw node. Internal formatting changes can trigger reparsing even if the text looks equivalent.
  4. If the readable body changed, it is parsed as Markdown and replaces only that block. Markdown before, between or after framed blocks is also imported. Untouched media and other rich blocks stay structured.
  5. The first H1 found in decoded content supplies the title; otherwise the metadata/fallback title is used. The service’s authenticated mapping, not an arbitrary metadata id, determines which existing Drive note is being read.

Valid synthetic image block

The following is a complete small Drive note example. Its unchanged readable image body is not reparsed, so width 320 survives in the encoded node. Editing the image body requires the safe Markdown-image import rule above.

---
filesandnotes: {"id":"doc-image-example","title":"Field image","folderId":"folder-notes","createdAt":1793000000000,"updatedAt":1793000001000,"trashedAt":null}
---

<!-- filesandnotes:block eyJ0eXBlIjoiaW1hZ2VFbWJlZCIsImF0dHJzIjp7InNyYyI6Ii9nb29nbGUtZHJpdmUvcmF3L2ZpbGUtaW1hZ2UtZXhhbXBsZSIsImNhcHRpb24iOiJGaWVsZCBpbWFnZSIsIndpZHRoIjozMjB9fQ== -->
![Field image](/google-drive/raw/file-image-example)
<!-- /filesandnotes:block -->

Current normalization caveat: the encoder replaces the first H1’s inline content with doc.title, preserving heading attributes but dropping title marks. It also filters unknown top-level types and may synthesize a title when nothing remains. These are limitations of the current implementation, not acceptable silent-loss behavior for a rebuild. Supported title marks must survive when the title is unchanged; any intentional title rewrite must be explicit and tested.

Parsing structured JSON also upgrades a paragraph containing only a Drive audio/video reference into an audio/video embed, and a standalone YouTube inline reference into a youtubeReference. These are known compatibility normalizations. Keep source URLs and IDs unchanged; do not apply such upgrades to a reference embedded in an ordinary sentence.

6 · External edits, malformed metadata and unknown content

Input/changeCurrent evidenceRequired rebuild outcome
Edit one ordinary paragraph outside the appThat readable body is reparsed. Untouched framed blocks preserve their raw nodes.Keep all unaffected blocks/metadata and retain a newer local edit through the normal merge/version checks.
Edit H1 outside the appThe first decoded H1 becomes the title.Use the revised title consistently; preserve the note ID and unrelated content. Do not create another note because the title changed.
Append ordinary Markdown outside framed blocksAppends parsed blocks after the existing framed content.Keep earlier rich source nodes and append the new content; do not replace the whole note with only the added text.
Invalid JSON frontmatter or unmatched app block markersThe decoder throws. A decoded block payload that is not JSON throws; Base64 decoding alone is not strict schema validation.Report a per-note error. Do not overwrite a valid local note or its base; leave the remote original unchanged.
Valid JSON with the wrong metadata/node shapeThe current decoder does not enforce a complete record schema. parseDocument sanitizes some unknown nodes by wrapping children or removing atoms; the encoder filters unknown top-level types.Validate the metadata and full supported node/mark/attribute schema. Preserve unsupported original content and block destructive replacement/sync until it is supported or recovered. Do not quietly discard unknown blocks or accept a blank result as success.
Empty, truncated or metadata-only content over a known nonempty noteThe bare decoder can return a doc containing one empty paragraph.An empty parse is not proof of an intentional empty edit or deletion. Retain the good local/base/pending data and report the ambiguity through the existing per-note failure path. Actual empty edits require the explicit confirmed write/version contract.
HTML error response instead of MarkdownDrive refresh rejects a leading HTML/doctype error page; the bare Markdown decoder alone treats it as text.Apply transport/content validation at every remote read boundary, then the codec/schema validation. Do not import an error page as note text.

Recovery must obey Failure Recovery. Retaining an original for recovery means the user’s browser/Drive and request-scoped processing; it does not authorize a persistent readable server archive. Do not delete the last good local copy, merge base, queue entry or the remote original to make validation pass.

7 · Synthetic before/after expectations

Untouched rich source

Before (editor JSON):
{"type":"imageEmbed","attrs":{"src":"/google-drive/raw/file-image-example","caption":"Field image","width":320}}

App-preserving Drive encode → decode:
The same node and width:320 return. The source bytes remain in Drive.

Plain export:
![Field image](/google-drive/raw/file-image-example)

Required safe plain import:
imageEmbed with the same src and caption, width:null;
warn that plain export did not carry width.
Current source instead throws on the image token: a known gap to fix.

Changed paragraph, untouched audio

Before: framed paragraph "Original observation." followed by audioEmbed.
External edit: replace only the paragraph’s readable body with:
**Changed outside the app**

Expected:
paragraph → text "Changed outside the app" marked strong;
following audioEmbed → original JSON unchanged;
note ID, folder and optional metadata → unchanged.
A local edit newer than the confirmed base is still merged/handled by the normal sync contract.

Do not confuse live input rules with Markdown import

Editor underline mark → current plain export: __Underlined__
Current plain import: strong mark, not underline.
Editor highlight mark → current plain export: ==Highlighted==
Current plain import: literal delimiter text, not highlight.
Editor strike mark → current plain export: ~~Struck~~
Current plain import: literal delimiter text, not strike.

Required: preserve these marks in app-preserving Drive storage.
Plain export/import must state its tested syntax or warned fallback;
none of the current outcomes above is a lossless-formatting claim.

8 · Executable fixture expectations

The synthetic fixture set has a valid rich note with every supported body mark, lists, tasks, aligned rich table cells, image width, audio/video, note/media references, a canvas summary and optional YouTube nodes. All identifiers are fictional and no fixture performs live API calls. The app-preserving fixture uses an ordinary unmarked title H1 so the current title-mark limitation is not hidden inside a passing assertion.

  1. MD-DRIVE-RICH: validate input with parseDocument(input.content).check(); deep-compare decodeDriveNote(encodeDriveNote(input)) with the expected complete record. Compare title, IDs, timestamps, optional metadata, block order, marks and attributes, not only textContent.
  2. MD-DRIVE-EXTERNAL-HEADING: change only the readable H1 projection; require the new title and exact equality of every untouched block.
  3. MD-PLAIN-TABLE / MD-PLAIN-TASKS: compare parsed node JSON, alignment and task status/checked pairs. JSON-normalize ProseMirror output before deepStrictEqual because its attrs objects can have a null prototype. Check the documented normalization separately from source-byte equality.
  4. MD-PLAIN-FENCE / LINK / UNDERLINE / HIGHLIGHT-STRIKE / REFERENCES: the current-outcome fixtures expose losses. Required rebuild acceptance must not count an observed current gap as a successfully retained feature.
  5. MD-CURRENT-IMAGE-GAP: current source error is recorded separately from the required safe image/link result. The rebuild must pass the latter.
  6. MD-BAD-METADATA / MD-BAD-BLOCK: decoding rejects; integration checks retain existing local/base/pending data and do not send an empty replacement.
  7. MD-METADATA-ONLY: the current decoder’s empty fallback is evidence, not the acceptance result. Verify the required protection around a known nonempty note.

The JSON fixture bundle separates 13 current-source evidence cases in fixtures from 7 mandatory target cases in requiredRebuildCases. Run the latter against the generated implementation; do not silently skip them to claim acceptance. Run these assertions with the fixture runner described in Acceptance. Record current-source observations separately from required rebuild checks. Add mandatory checks for supported title marks, unknown node/mark payloads, unsafe URLs, image import and nonempty-note protection; the current codec does not already satisfy all of them.

9 · Implementation prompt

Read Markdown Compatibility, Data Structure, API Contracts and Failure Recovery before implementing conversion.
Keep plain Markdown exchange separate from app-preserving Drive Markdown. Preserve the documented schema names, attributes, IDs and reference URLs.
Implement a lossless supported-node/mark/metadata Drive round trip, including supported title marks; keep external paragraph edits local to their changed blocks.
Make plain-format losses explicit. Import standalone Markdown images as safe imageEmbed nodes and inline images as safe labelled links; reject unsafe URLs without losing visible text.
Validate metadata and complete node shapes. Unknown or malformed content, HTML error pages and ambiguous empty results must not overwrite good notes, confirmed bases or pending work.
Keep failed/unsupported content recoverable in the user browser/Drive, with the existing per-note error/retry path. Do not add a persistent server note archive, a second sync protocol or a new workflow.
Run the synthetic compatibility fixtures plus required rebuild tests. Report known current-source gaps separately; do not copy them into the rebuilt implementation.

Source-checked against client/paper/editor-core.ts, client/paper/canvas-node.ts, src/services/drive-notes-markdown-service.js, src/services/notes-markdown-service.js and the Drive refresh validation in src/services/drive-notes-service.js. This chapter adds documentation and fixture evidence, not a runtime fix or a claim about production.