PLIP: Markdown Support for AI-readyness
@tisto is already working on this.
Since Jun 14, 2026.
- Dominant language
- Python
- Stars
- 109
- Forks
- 107
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 4
Description
[!IMPORTANT]
If you are not a member of the Developers Team in the Plone GitHub organization, then do not work on or comment on this issue.
PLIP (Plone Improvement Proposal)
Responsible Persons
Proposer: Timo Stollenwerk
Seconder:
Abstract
This PLIP proposes adding bidirectional Markdown support to plone.restapi for blocks-based content. Clients will be able to retrieve a blocks-based page as Markdown via Accept: text/markdown, and to create or update pages by POSTing or PATCHing a Markdown document via Content-Type: text/markdown.
Simple, well-understood blocks (text, headings, images, separators, tables) are represented as native CommonMark/GFM Markdown. Complex blocks that have no natural Markdown equivalent (listings, carousels, sliders, image galleries, teasers) are embedded as block fences — fenced code blocks using four backticks with a plone:<block-type> identifier — preserving their full JSON representation without loss.
No new endpoints are introduced. The feature is opt-in via standard HTTP content negotiation and is fully backwards compatible.
Motivation
1. Markdown is the lingua franca of AI agents
Large language models produce and consume Markdown by default. Every major agentic framework, every AI chat interface, and the growing ecosystem of tools that bridge CMS backends to AI pipelines works natively with Markdown.
The plone-mcp project, which enables AI agents to interact with Plone via the Model Context Protocol, currently implements its own conversion layer: Plone's block JSON is converted to Markdown before being passed to an agent, and the agent's Markdown response is parsed back into blocks before writing. This conversion belongs in plone.restapi. Making Markdown a natively supported representation at the API level means that plone-mcp and any other integration can delegate the conversion to the CMS itself, rather than each client reimplementing it independently. The mapping is then defined once, tested once, and consistent across all consumers.
Markdown as a first-class citizen in plone.restapi positions Plone well as a backend for AI-driven content workflows — a growing use case across the research institutions, universities, and government agencies that form Plone's core user base.
2. The blocks JSON schema is opaque and undocumented
Plone's Volto blocks system is powerful, but the JSON representation of each block type is complex, varies between block types, and is not comprehensively documented. Every project uses a different set of blocks. Technically capable API users — developers who understand REST and know what they want to publish — currently face a steep barrier: they must study the JSON output of the editor, reverse-engineer field names, guess at required versus optional properties, and account for nested structures such as Slate rich text nodes.
Markdown lowers this barrier dramatically. A user who understands # Heading, **bold**, and  can produce valid content for Plone without knowing anything about @type: slate, the value array format, or the distinction between styles.align and align.
3. A precedent already exists in Slate
The Slate editor in Volto already supports a Markdown-to-blocks shortcut input mode. Users can type # to produce a heading block, **text** for bold, and so on. The mapping from Markdown constructs to Plone blocks is therefore already understood within the ecosystem. This PLIP brings the same mapping to the API layer, making it consistent and formally specified.
Assumptions
- The feature applies only to content types that provide
IBlocks(i.e., content types with ablocksfield and ablocks_layoutfield). Non-blocks content types are out of scope. - The canonical Markdown dialect is GFM (GitHub Flavoured Markdown), which is the de-facto standard in AI tooling and developer workflows. GFM is a superset of CommonMark and adds tables, strikethrough, and task lists.
- A GFM-compliant Python parser (
markdown-it-pyor equivalent) is added as an optional dependency ofplone.restapivia an extras group (plone.restapi[markdown]), keeping the base install unaffected for deployments that do not need this feature. - The Slate editor's existing Markdown shortcut mapping is used as the authoritative reference for the block conversion table where applicable.
- Block UUIDs are not required to be preserved across a Markdown
PATCH(new UUIDs are generated). Clients that require UUID stability should use the JSON API. - On
PATCHwithContent-Type: text/markdown, the entireblocksandblocks_layoutof the content item is replaced. Partial block-level updates via Markdown are out of scope.
Proposal & Implementation
Content Negotiation
Markdown support is activated via standard HTTP content negotiation on the existing content endpoints. No new endpoints are introduced.
Retrieving content as Markdown:
GET /Plone/++api++/my-page HTTP/1.1
Accept: text/markdown
Creating content with Markdown:
POST /Plone/++api++ HTTP/1.1
Content-Type: text/markdown
---
@type: Document
title: My Page Title
description: A short description
---
# Introduction
Content starts here.
Updating content with Markdown:
PATCH /Plone/++api++/my-page HTTP/1.1
Content-Type: text/markdown
# Updated Title
New content here.
When Content-Type: text/markdown is used, the request body is a raw Markdown document (not JSON). When Accept: text/markdown is used, the response body is a raw Markdown document with a Content-Type: text/markdown response header.
YAML Front Matter
Metadata that Markdown cannot carry (content type, title, description, publication date) is expressed as YAML front matter at the top of the document:
---
@type: Document
@id: http://localhost:8080/Plone/++api++/my-page
title: My Page Title
description: A short description
language: en
effective: '2026-03-17T00:00:00'
---
## Section One
Body content begins here.
Fields that are null or empty are omitted during serialization. @id and @type are always present in the serialized output.
Block-to-Markdown Mapping
Tier 1 — Native Markdown blocks
These block types have a lossless or near-lossless round-trip representation in standard GFM Markdown:
| Block type | Markdown representation | Notes |
|---|---|---|
slate — paragraph |
Plain paragraph text | Inline formatting mapped per inline table below |
slate — heading h1–h6 |
# through ###### prefix |
|
slate — unordered list |
- item |
Nested lists via indentation |
slate — ordered list |
1. item |
|
slate — blockquote |
> text |
|
slate — code block |
Triple-backtick fence with language | |
image |
 |
alt from image description; URL is the resolved download URL |
hr / divider |
--- |
|
table |
GFM pipe table | Header row always present; synthetic empty header added if none defined |
Inline Slate node formatting:
| Slate inline node | Markdown |
|---|---|
bold: true |
**text** |
italic: true |
_text_ |
code: true |
`text` |
strikethrough: true |
~~text~~ |
| Link element | [text](href) |
| Internal link | [text](../relative/path) |
Tier 2 — Block fences
Block types that have no natural Markdown equivalent are serialized as block fences: fenced code blocks using four backticks (to avoid collision with triple-backtick code blocks inside content) and a plone:<block-type> identifier. The fence body contains the full JSON of the block.
\`\`\`\`plone:listing
{
"@type": "listing",
"query": [],
"sort_on": "effective",
"sort_order": "descending",
"b_size": 10
}
\`\`\`\`
Block types that fall into Tier 2 include (but are not limited to): listing, search, carousel, slider, teaser, gridBlock, maps, video, and any custom block type not covered by Tier 1.
Block fences carry the full JSON of the block verbatim. On deserialization the JSON is validated and inserted into blocks as-is. This provides a transparent passthrough for developers who know the JSON schema, while authors who only work with text and images never need to encounter a block fence.
Deserialization (Markdown → blocks)
When the server receives a Content-Type: text/markdown request:
- YAML front matter (delimited by
---) is parsed and merged into content item metadata fields. - The document body is parsed with a CommonMark/GFM parser.
- Each top-level block element is converted to one Plone block in
blocks_layout.itemsorder, with a generated UUID. - Block fences (
````plone:<type>`) are JSON-parsed and inserted verbatim. - Inline elements are converted to Slate rich text nodes within a
slateblock. - Standard code fences (triple-backtick with a non-
plone:language identifier) becomeslatecode blocks. - Unknown or ambiguous constructs fall back to a
slateparagraph containing the raw text; the server MAY emit aWarningresponse header describing the fallback.
Error Handling
| Condition | HTTP Status |
|---|---|
Accept: text/markdown on non-blocks content |
406 Not Acceptable |
| Malformed YAML front matter | 400 Bad Request |
| Invalid JSON inside a block fence | 400 Bad Request |
Unknown @type in front matter on POST |
400 Bad Request |
Implementation Architecture
The feature is implemented within plone.restapi as:
IMarkdownSerializer— adapter registered for(IBlocks, IMarkdownRequest), converts blocks to a GFM Markdown document.IMarkdownDeserializer— adapter registered for(IBlocks, IMarkdownRequest), parses a Markdown document into blocks.- A content negotiation layer in the existing content
GET/POST/PATCHviews that detectsAccept: text/markdownandContent-Type: text/markdownand dispatches to the appropriate adapters. - An extras group
plone.restapi[markdown]inpyproject.tomlthat installsmarkdown-it-py[linkify](or equivalent), keeping the dependency optional.
The Slate serializer/deserializer is the most complex component and will be implemented as a recursive converter between Slate's node tree format and a CommonMark AST, using the Slate Markdown input mode in Volto as reference.
A proof-of-concept exists in the plone-mcp project's Markdown conversion utilities and will be formalized and moved into plone.restapi as part of this PLIP.
Deliverables
plone.restapi— newIMarkdownSerializerandIMarkdownDeserializeradapters; content negotiation layer;[markdown]extras group; test suite covering Tier 1 round-trip fidelity, Tier 2 block fence passthrough, front matter parsing, error conditions, and content negotiation.plone.restapidocumentation — new chapter "Markdown Support" with worked examples: creating a page from Markdown, reading a page as Markdown, using block fences for complex blocks, and a front matter field reference.
Risks
- Partial representation of complex blocks: Tier 2 blocks are passed through as raw JSON fences. A non-technical author who edits the Markdown outside Plone and corrupts a block fence will receive a
400on the nextPATCH. Schema validation of block fence JSON at write time and clear documentation mitigate this. - Slate round-trip edge cases: Slate supports a richer set of inline nodes than CommonMark (e.g., custom marks, text alignment, custom list styles). Nodes that have no Markdown equivalent are silently dropped during serialization. This is acceptable for the AI agent and developer use cases that motivate this PLIP but must be documented explicitly so that users do not treat the Markdown API as a generic backup format.
- Dependency footprint:
markdown-it-pyis well-maintained, has no compiled extensions, and is already a transitive dependency in many Python environments. Making it optional via extras ensures zero impact on deployments that do not opt in. - No impact on existing JSON API consumers. The JSON response format is entirely unchanged. The Markdown path is only activated by explicit
AcceptorContent-Typenegotiation.
Participants
| Name | Role |
|---|---|
| Timo Stollenwerk | Proposer |
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.