This is the multi-page printable view of this section. Click here to print.
Specifications
1 - Markdown to draftjs
1.1 - markdown-to-draftjs Specification
Purpose
Compile a Markdown post into the DraftJS content_state document that the X
Articles API accepts, so the Markdown that renders the blog also produces the
article body without hand-editing.
Requirements
Requirement: Source post input model
The converter SHALL treat a blog source post as YAML front matter plus a
Markdown body, and MUST NOT emit front matter as article text. It SHALL expose
the parsed metadata separately from the content_state document, and SHALL
report the parsed title as document metadata because draft creation requires
a title that content_state does not carry. A source whose front matter has no
non-empty title SHALL fail conversion with a diagnostic naming the source,
rather than producing a document that cannot be uploaded.
Scenario: Convert a post with front matter
- WHEN the converter is given a post whose front matter contains a title
- THEN the emitted
content_statecontains no front matter text - AND the parsed title is reported as document metadata alongside it
Scenario: Convert a post without a title
- WHEN the source front matter has no non-empty title
- THEN conversion fails with a diagnostic naming the source
- AND it does not emit a document that lacks a usable title
Requirement: Deterministic offline conversion
The converter SHALL transform a Markdown source file into a X-compatible
DraftJS content_state document containing a blocks array and an entities
array of {key, value} entries. Conversion MUST be deterministic and MUST NOT perform network access.
Scenario: Convert a post offline
- WHEN the converter is given a Markdown file
- THEN it emits a
content_statedocument withblocksandentities - AND it performs no network request during conversion
Scenario: Identical input yields identical output
- WHEN the same Markdown input and configuration are converted twice
- THEN both outputs are byte-identical
Requirement: Articles wire shape
The emitted document SHALL use the exact shape the Articles draft endpoint
accepts, not the canonical DraftJS spelling. Each block SHALL name its ranges
inline_style_ranges and entity_ranges, and MUST NOT carry a depth field,
because the endpoint’s schema sets additionalProperties: false and rejects
both the camelCase range names and any depth. Each entry of entities SHALL
be {"key": "<index>", "value": {"type", "mutability", "data"}}, matching the
endpoint’s schema rather than DraftJS’s inline entityMap.
Scenario: Emit the Articles field names
- WHEN a document with inline styles, links, and a list is converted
- THEN every block names its ranges
inline_style_rangesandentity_rangesand carries nodepth - AND each entity is a
{key, value}entry whosevaluecarriestype,mutability, anddata - AND no block or entity field falls outside the schema’s allowed names
Requirement: Heading level mapping
Markdown headings SHALL map onto the block types X exposes, and no heading MAY
produce a type outside that set. Because X exposes only three heading levels,
depth SHALL be clamped: # and ## map to header-one, ### maps to
header-two, and #### and deeper map to header-three.
Scenario: Map a second-level heading
- WHEN a post contains a
##heading - THEN it becomes a
header-oneblock
Scenario: Map a third-level heading
- WHEN a post contains a
###heading - THEN it becomes a
header-twoblock
Scenario: Clamp a heading deeper than X supports
- WHEN a post contains a
####,#####, or######heading - THEN it becomes a
header-threeblock - AND no block uses a heading type outside
header-one,header-two, orheader-three
Requirement: Block-level Markdown mapping
The converter SHALL map supported block constructs to the block type X expects:
paragraphs to unstyled, unordered list items to unordered-list-item,
ordered list items to ordered-list-item, and block quotes to blockquote.
Nested list items SHALL be emitted as list items of their own kind rather than
being merged into the parent item.
Scenario: Convert paragraphs and lists
- WHEN a post contains paragraphs and both list kinds
- THEN paragraphs become
unstyledblocks and each item becomes anunordered-list-itemorordered-list-itemblock
Scenario: Convert a nested list
- WHEN a list item contains a nested list
- THEN the nested items are emitted as list-item blocks
- AND no item’s text contains the nested list’s raw Markdown
Scenario: Convert a list item with several paragraphs
- WHEN a list item contains more than one paragraph, as a loose list does, whether or not a block construct such as a nested list or a code block lies between them
- THEN the item becomes a single list-item block whose text carries all of the item’s paragraphs
- AND the item’s block constructs follow that block in source order
- AND it does not become one block per paragraph, which DraftJS would read as separate items and which would renumber an ordered list
Scenario: Convert a block construct nested in a list item
- WHEN a list item contains a block construct such as a fenced code block, an indented code block, a table, or a thematic break
- THEN the construct is emitted through the same block mapping it uses at the top level, with its source preserved
- AND its source is not silently dropped by walking only inline children
Scenario: Convert a block quote
- WHEN a post contains a block quote
- THEN it becomes a
blockquoteblock containing the quoted text
Scenario: Convert a heading inside a block quote
- WHEN a block quote contains a heading, as in
> # Warning, whether directly or through a nested list as in> - # Warning - THEN the heading text is emitted as a
blockquoteblock, not an ordinary heading outside the quote and not a list-item block that drops the quote - AND the lost heading level is reported, because X exposes no quoted-heading block type
Scenario: Preserve a line break
- WHEN a paragraph contains a soft line break or a hard line break written with two trailing spaces or a trailing backslash
- THEN the block’s text contains a newline at that break
- AND the two lines are not joined into one word
Requirement: Inline styles and link entities
Inline emphasis SHALL be expressed as inline_style_ranges on the enclosing
block using bold, italic, or strikethrough, with offsets computed against
the block’s final text. Links SHALL become link entities referenced by
entity_ranges on the enclosing block. Ranges MUST be correct when a styled or
linked span is adjacent to other text in the same block.
Offsets SHALL be counted in the code units the DraftJS format uses — UTF-16 code units of the block’s final text — rather than bytes, runes, or grapheme clusters. A block containing a supplementary character before a styled or linked span MUST still select exactly that span, because a byte or rune offset would select the wrong characters once the document is interpreted as a JavaScript string.
Scenario: Convert emphasis
- WHEN a paragraph contains bold, italic, or strikethrough text
- THEN the enclosing block carries
inline_style_rangesfor that span - AND each range’s offset and length select exactly the styled text
Scenario: Convert a link
- WHEN a paragraph contains a Markdown link
- THEN a
linkentity carrying the destination URL is added toentities - AND the enclosing block references it with an
entity_rangeselecting the link text
Scenario: Convert a link carrying a title
- WHEN a Markdown link carries an optional title, as in
[docs](https://example.test "Reference") - THEN the entity cannot carry the title, so its loss is reported as a continuing diagnostic naming the title and positioned at the link
- AND the link’s text and destination are still preserved
Scenario: Convert an image carrying a title
- WHEN a Markdown image carries an optional title
- THEN its loss is reported as a continuing diagnostic naming the title and positioned at the image
- AND the image is still emitted as an
atomicblock
Scenario: A titled construct has no text of its own
- WHEN a titled link or image has an empty label or alt text, so the construct has no text node to locate it
- THEN the lost-title diagnostic is positioned at the construct’s own opening delimiter rather than at the start of the enclosing block
- AND the reported source position still names the construct that produced the loss
Scenario: Convert an email autolink
- WHEN a paragraph contains an email autolink such as
<user@example.com> - THEN the
linkentity records it asmailto:user@example.com - AND the recorded URL is an email address rather than a relative link
Scenario: A destination carries Markdown escapes or character references
- WHEN a link or image destination escapes a punctuation character or uses a
character reference, as in
[x](https://example.test/a\(1\)?x=1&y=2) - THEN the recorded destination resolves those encodings to the URL the author wrote
- AND the recorded link or image target does not contain the literal backslashes or unresolved entity
Scenario: Prose carries Markdown escapes or character references
- WHEN ordinary prose escapes a punctuation character or uses a character
reference, as in
\*literal\*orAT&T - THEN the emitted block text resolves those encodings to the characters the author wrote rather than keeping the backslashes or the raw entity
- AND a raw span such as a code span keeps its literal source, because its encoding is its content
- AND an image caption, which is flattened from its inline content, follows the same rule: resolution applies to each non-raw descendant while a raw span inside the caption keeps its literal source
Scenario: Keep offsets correct for adjacent spans
- WHEN a block contains multiple styled or linked spans next to plain text
- THEN every range’s offset is measured against the block’s final plain text
- AND no range extends beyond the block text
Scenario: Keep offsets correct after a supplementary character
- WHEN a block contains an emoji or other supplementary character before a styled or linked span
- THEN each range’s offset is measured in UTF-16 code units of the block’s final text
- AND the range selects exactly the styled or linked text rather than drifting by the character’s extra code unit
Requirement: Code blocks and tables preserve source Markdown
Code fences and tables SHALL be emitted as atomic blocks backed by a
markdown entity whose payload preserves the construct’s original Markdown
source. A table MUST NOT be flattened into paragraph text, because X exposes no
table block or entity.
Scenario: Convert a fenced code block
- WHEN a post contains a fenced code block
- THEN it becomes an
atomicblock with amarkdownentity - AND the entity payload preserves the fenced source, including its language
Scenario: Convert a table
- WHEN a post contains a Markdown table
- THEN it becomes an
atomicblock with amarkdownentity - AND the entity payload preserves the table as Markdown with its rows and cell contents intact
Scenario: Convert an indented code block
- WHEN a post contains an indented code block immediately followed by a non-indented paragraph
- THEN the
markdownentity payload contains only the code block’s own lines - AND the following paragraph is emitted once, as its own block, rather than being swallowed into the entity and repeated
Scenario: Report the markdown payload budget
- WHEN the total
markdownentity payload for an article would exceed the API’s per-article limit - THEN conversion fails with a diagnostic naming the article and the measured size rather than emitting a document the API will reject
Requirement: Inline code and thematic breaks
DraftJS exposes only bold, italic, and strikethrough as inline styles, so
a Markdown inline-code span has no code style to map onto. Its literal text SHALL
survive conversion unchanged, and the loss of the monospace styling SHALL be
reported as a conversion diagnostic so the author sees it rather than having it
silently dropped. A Markdown thematic break SHALL become an atomic block
backed by a divider entity, because X exposes divider for exactly that
purpose, rather than being dropped as an unrecognized paragraph.
Scenario: Convert an inline-code span
- WHEN a post contains a Markdown inline-code span
- THEN the block’s final text contains the span’s literal text
- AND conversion reports the lost code styling as a diagnostic
Scenario: Convert a GFM task-list item
- WHEN a post contains a task-list item
- THEN the item’s text keeps an indicator of its checkbox state
- AND the checked state is not silently dropped
Scenario: Convert a thematic break
- WHEN a post contains a thematic break
- THEN it becomes an
atomicblock with adividerentity - AND it does not become paragraph text containing the break’s characters
Requirement: Footnotes become a trailing section
DraftJS has no footnote construct, so footnote definitions SHALL be preserved in
a trailing Footnotes section rather than dropped. Each in-text footnote
reference SHALL become plain bracketed text, and each definition SHALL become an
ordered-list-item beneath a Footnotes heading.
Scenario: Convert a post with footnotes
- WHEN a post contains a footnote reference and its definition
- THEN the reference appears in the body as plain bracketed text
- AND a
Footnotesheading is appended with each definition as an ordered list item
Scenario: Preserve a structured footnote definition
- WHEN a footnote definition contains multiple paragraphs or inline formatting such as a link
- THEN each definition paragraph becomes its own ordered list item rather than being joined without a separator
- AND inline entities such as links are preserved by the normal inline mapper rather than flattened to plain text
Scenario: Preserve a construct nested in a footnote definition
- WHEN a footnote definition contains a nested list, block quote, fenced or indented code block, table, or thematic break
- THEN the construct is emitted through the block mapper with its content preserved
- AND it is not silently dropped
Scenario: Convert a post without footnotes
- WHEN a post contains no footnotes
- THEN no
Footnotesheading is added
Requirement: Images reference uploaded media
The image entity SHALL carry its media as media_items, whose entries require
a media_category and a media_id; data.url is documented for link
entities and MUST NOT be treated as an image source. Each image SHALL become an
atomic block plus an image entity, and unresolved images SHALL remain
explicitly unresolved so publication can resolve or reject them rather than
silently emitting a broken article. Because the media endpoints accept only a
fixed set of image media types that excludes image/svg+xml, the converter
SHALL report an image whose media type the upload endpoints cannot accept, with
its source position, rather than emitting an entity that publication can never
resolve. The declared extension SHALL be accepted as the media type only when
the file’s bytes carry that format’s own signature, so a file renamed to an
accepted raster extension — even one accepted format renamed to another — is
reported rather than sent to the upload endpoint.
Scenario: Convert an inline image
- WHEN a post contains an image with alt text
- THEN it becomes an
atomicblock with animageentity - AND the entity preserves the alt text as its caption, resolving Markdown escapes and character references the way prose is resolved while a raw span inside the alt text keeps its literal source
Scenario: Publication has not yet resolved an image
- WHEN a converted document references an image whose media has not been uploaded
- THEN the unresolved image is distinguishable from a resolved one
- AND publication refuses to send an unresolved image as though it were resolved
Scenario: An image cannot be uploaded
- WHEN a post references an image whose media type the media upload endpoints do not accept
- THEN the converter reports the image with its source position
- AND it does not emit an entity that publication could never resolve
Scenario: An image reference resolves outside the post directory
- WHEN a post’s image reference is a syntactically safe relative path whose canonical target lies outside the post’s directory, such as a symlink to a file elsewhere
- THEN the converter reports the image with its source position rather than reading bytes outside the post boundary
- AND it does not record a locator that publication would later reject, so a converted artifact remains publishable
Scenario: Image bytes do not match the declared extension
- WHEN a post references an image whose bytes are not the format its extension declares
- THEN the converter reports the image with its source position instead of treating it as the declared type
- AND it does not emit an entity the upload endpoint would reject
Scenario: An accepted format is renamed to another accepted extension
- WHEN bytes of one accepted raster format carry the extension of a different accepted raster format
- THEN the converter reports the mismatch rather than accepting the sniffed type
- AND it does not fall back to another accepted media type
Scenario: An image reference requires Markdown escaping
- WHEN a local image reference names a file whose name needs Markdown
escaping, as in
.png) - THEN the reference is resolved before filesystem lookup, so the file it names is read rather than reported unreadable
- AND the recorded locator names the resolved path
Scenario: A symlink’s target has a different extension than the reference
- WHEN an image reference such as
image.pngis an in-directory symlink to a file with a different raster extension, such asactual.webp - THEN the declared-extension check classifies the image by the reference the author wrote, not by the canonical target’s name
- AND bytes that do not match the reference’s declared format are reported rather than accepted through the target’s extension
Scenario: An inline image splits a styled span
- WHEN an inline image interrupts a styled or linked span
- THEN the text before the image keeps its style or entity range
- AND the text after the image keeps its style or entity range
Scenario: A link wraps only an image
- WHEN a Markdown image is the entire content of a link, as in
[](target) - THEN the image is still emitted as an
atomicblock with itsimageentity - AND the lost destination is reported as a continuing diagnostic naming the link target, because the link has no text span to carry it
Scenario: A link wraps an image inside an inline wrapper
- WHEN the link’s only content is an image nested inside a style wrapper,
as in
[****](target) - THEN the enclosing link is found by ancestry rather than by immediate parentage
- AND the lost destination is still reported, because the link has no text outside the image
Scenario: A link carries text beside an image
- WHEN a link contains non-whitespace text of its own in addition to an
image, as in
[label ](target) - THEN the destination survives as a
linkentity over that text - AND the image, which still cannot carry the link, is reported as a continuing diagnostic naming the destination, so the partial loss is not hidden merely because sibling text kept the link
Scenario: A post follows the raster-only policy
- WHEN a post’s images all use a media type the media upload endpoints accept
- THEN conversion completes with an
imageentity for each image - AND no unacceptable-image diagnostic is reported for that post
Requirement: Artifact carries image locators
Because the API’s entity data object rejects additional properties, an image’s
source cannot travel inside the entity. The emitted draft artifact SHALL
therefore carry, as metadata beside content_state, an entry for every
unresolved image. Each entry SHALL name the image’s source path relative to the
post’s directory, SHALL record the post package the path is relative to, and
SHALL record a digest of the image bytes as they were at conversion time, so the
artifact identifies both the image and the directory it resolves against.
Publication SHALL be able to obtain the image bytes from the artifact alone,
without re-parsing Markdown, and SHALL reject a locator that escapes the post’s
directory or whose current bytes do not match the recorded digest. Conversion
SHALL apply the same containment before it reads an image, so it neither
inspects bytes outside the post boundary nor records a locator that publication
would refuse. Both SHALL enforce containment by resolving the name through
descriptor-anchored directory handles that reject a symlink escaping the root,
rather than by checking a pathname and then re-opening it, so the object whose
containment is checked is the object read.
Scenario: Convert an image into the artifact
- WHEN a post containing a raster image is converted
- THEN the artifact carries a locator entry for that image beside the document
- AND the entry names the source path relative to the post’s directory, the post package the path resolves against, and a digest of the image bytes
Scenario: Associate a locator with its image entity
- WHEN an artifact carries two or more image locators
- THEN each locator entry records the entity it resolves, by the entity’s key, so the mapping does not depend on ordering or on repeated captions
- AND publication attaches each returned
media_idto the entity its locator names rather than by position
Scenario: Convert a source given by an absolute path
- WHEN the conversion command is given an absolute
--sourceand no--post-package - THEN the recorded locator’s package is relative to the workspace named by
--workspace, rather than the absolute source directory - AND the artifact remains publishable, because publication refuses an absolute package rather than resolving it
- AND the workspace root is the caller-supplied
--workspacevalue and is never re-discovered by walking the filesystem, so no repository marker found through a link can be mistaken for it
Scenario: A post directory is reached through a symlink
- WHEN the derived package would name a post directory whose canonical target, after resolving symlinks, lies outside the workspace root
- THEN the conversion is refused with a directive to pass
--post-packageexplicitly, rather than recording a package the descriptor-anchored read would resolve past and reject - AND an in-workspace symlink still records the relative package of the canonical directory it resolves to, so the recorded package matches the object publication reads
- AND a link that points directly at another checkout’s root, a link into
another tree beneath its root, and a post directory reached through an
intermediate symlink-component are all refused rather than recorded relative
to the external tree or collapsed to
. - AND containment is established by opening the directory through a descriptor-anchored handle on the workspace, so the no-symlink validation and the directory accepted for the package are the same traversal and cannot be separated by a writer swapping the directory for a link
- AND image reads for the post go through that same handle rather than re-opening the source pathname, so a symlink retargeted after validation cannot make conversion read another tree’s bytes while the artifact still records the validated package
Scenario: A post package is supplied explicitly
- WHEN the conversion command is given
--post-packagerather than having one derived - THEN the package is accepted only when the directory it opens to is the post directory, compared by file identity rather than by pathname, because publication resolves the locator paths against the package and a package naming another directory would make publication read a different object than conversion did
- AND the post’s images are read through the descriptor-anchored handle the identity was checked on, so a package retargeted between the check and the read cannot supply another tree’s bytes while the locator still names the supplied package
- AND an absolute package, a package that escapes the workspace, or a package naming a different directory than the post is refused
Scenario: Resolve an image from the artifact
- WHEN publication reads an unresolved image from the artifact
- THEN it obtains the image bytes from the recorded locator without re-parsing the source Markdown
- AND it resolves the relative path against the recorded post package even when the artifact was moved elsewhere
- AND it refuses a locator that escapes the post’s directory or names bytes whose digest does not match the recorded one
Scenario: A symlink changes after containment validation
- WHEN a locator’s path or its target is swapped for a symlink that leaves the post directory after containment was established
- THEN the read is refused rather than following the swapped symlink
- AND a swapped symlink cannot redirect the read to a different file, because the containment check and the read operate on the same descriptor rather than on a re-opened pathname
Requirement: Conversion diagnostics
The converter SHALL report a construct it cannot represent with the source location that produced it. It SHALL distinguish two outcomes and never emit a document that silently omits content it could not represent: a construct whose content is preserved but whose formatting is lost is reported and conversion continues, while a construct whose content would be lost fails conversion. The image whose media type the upload endpoints reject and the source without a title are failing outcomes; an inline-code span’s lost styling is a continuing one.
Scenario: Encounter an unsupported construct
- WHEN the converter cannot represent a construct and its content would be lost
- THEN it reports the construct with its source position
- AND conversion fails rather than emitting a document that discards the content
Scenario: Report continuing losses when conversion also fails
- WHEN a source contains both a continuing loss and a failing construct, such as an inline-code span and an unacceptable image
- THEN the continuing diagnostic is reported alongside the failing one rather than being discarded with the returned error
- AND the author sees every detected loss in one run
Scenario: Encounter a construct whose formatting is lost
- WHEN the converter preserves a construct’s content but cannot reproduce its formatting
- THEN it reports the lost formatting with its source position
- AND conversion still completes with the content present
Scenario: Report a position after a multi-byte character
- WHEN a diagnostic’s construct follows a multi-byte character on the same line
- THEN the reported column counts source characters rather than bytes, so it names the position an editor shows
- AND the byte offset is still recorded for slicing the source
Requirement: Banner image metadata
The converter SHALL resolve the first entry of the optional front matter
images array as the article banner. It SHALL record a separate banner locator
with the post-relative path, post package, content digest, and detected media
type, using the same image validation as body images. It MUST NOT insert a body
block or remove an existing body image because the image is selected as a banner.
Malformed metadata or an unusable selected image SHALL fail conversion.
Scenario: Select a banner independently of body images
- WHEN a post selects a local image in the first
imagesentry - THEN conversion records its banner locator separately from body locators
- AND later
imagesentries do not select additional banners - AND body text and images retain their original order
Scenario: Convert without a selected banner
- WHEN
imagesis absent or empty - THEN the artifact has no banner locator and preserves existing conversion behavior
Scenario: Refuse an invalid selected banner
- WHEN
imagesis not an array of strings, or its selected image is missing, unsupported, remote, or outside the post package - THEN conversion fails with a diagnostic before any network operation
2 - X article publication
2.1 - x-article-publication Specification
Purpose
Create an X Article draft from a converted Markdown post, resolving referenced images through the media upload endpoint. The uploader creates drafts only; it never publishes an article.
Requirements
Requirement: Authenticated requests to X Articles
The publisher SHALL authenticate to the X API with credentials supplied at run time through the repository’s secret-injection flow. Credential values MUST NOT be recorded in source, committed configuration, logs, or command history.
Authentication SHALL use OAuth 1.0a user context, because the existing media and Articles endpoints accept it and its user-context token does not expire on a fixed schedule the way an OAuth 2.0 user token does, so draft creation does not depend on a rotating refresh token. OAuth 1.0a needs four credential fields — the API key, the API secret, the access token, and the access token secret — and the publisher SHALL sign each request with HMAC-SHA1 per RFC 5849 using the repository’s standard library rather than adding a signing dependency. The publisher SHALL fail when any of the four fields is absent, rather than sending a partially signed request.
Scenario: Create a draft with injected credentials
- WHEN the publisher runs with credentials supplied through the injection flow
- THEN it signs its requests to the X API with OAuth 1.0a user context
- AND it does not print the credential values
Scenario: Credentials are unavailable
- WHEN the publisher runs without any one of the four required credential fields
- THEN it fails with a diagnostic naming the missing credential reference
- AND it does not retry with a partial or empty credential
Requirement: Draft-only uploads
The uploader SHALL create X Article drafts and MUST NOT publish an article. It
SHALL NOT call the Articles publish endpoint or expose a way to request
publication, so no invocation of this tool can make a post publicly visible.
Draft creation SHALL send the title that conversion parsed from the source front
matter, and MUST NOT invent, default, or omit it, because the draft endpoint
requires a title that content_state does not carry.
Scenario: Create a draft
- WHEN draft creation is requested for a converted document
- THEN the publisher reads the draft article identifier from the v2 response envelope’s
data.id - AND no post becomes publicly visible
- AND the request carries the title parsed from the source front matter
Scenario: A draft response omits the identifier
- WHEN the draft endpoint returns a 2xx response whose
data.idis missing or empty - THEN the publisher fails rather than reporting a successful draft with no identifier to review
- AND it does not treat the zero-value response as a created draft
Scenario: Draft creation is requested without a parsed title
- WHEN draft creation is requested for a document with no parsed title
- THEN the publisher fails with a diagnostic
- AND it does not send a placeholder or empty title
Requirement: Image media upload
The publisher SHALL upload each referenced image through the X media upload
endpoint and reference the returned media_id in an image entity, because
that entity carries no document URL. Uploading MUST happen before the draft
that references the media is created.
The publisher SHALL obtain the image bytes from the locator the draft artifact
records beside the document, not by re-parsing the source Markdown, because the
API’s entity data object rejects additional properties and so cannot carry a
path. It SHALL resolve the recorded relative path against the post package the
locator records, so the artifact stays resolvable after it is moved. It SHALL
reject a locator that escapes the post’s directory or whose bytes do not match
the digest the locator records, rather than uploading a different image. It
SHALL enforce that containment by resolving the name through a
descriptor-anchored directory handle that rejects a symlink escaping the root,
rather than by checking a pathname and then re-opening it, so the object whose
containment is checked is the object read.
Scenario: Upload an image and resolve its media identifier
- WHEN a converted document references an unuploaded image
- THEN the publisher reads the image bytes from the artifact’s locator and
obtains a
media_id - AND the created draft references that
media_idin itsimageentity
Scenario: Reuse an already uploaded image
- WHEN the same image content has already been uploaded by this tool
- THEN the publisher reuses the recorded
media_idrather than uploading the same bytes again
Scenario: Reuse an upload in a later run
- WHEN draft creation runs again in a new process for an image this tool already uploaded
- THEN it reads the digest-to-
media_idmapping from its own cache and reuses the identifier - AND it stays correct when the cache is absent or the recorded digest no longer matches the artifact’s, by uploading again rather than reusing a stale identifier
Scenario: Decode the media upload response
- WHEN the media upload endpoint returns the v2 response envelope
- THEN the publisher reads the media identifier from the envelope’s
data.id - AND the created draft references that identifier rather than an empty one
Scenario: A media upload response omits the identifier
- WHEN the media upload endpoint returns a 2xx response whose
data.idis missing or empty - THEN the publisher fails rather than resolving the image to an empty identifier
- AND it does not send an entity that references a missing media identifier
Scenario: Expire a cached upload
- WHEN the recorded lifetime of a cached media identifier has passed
- THEN the cached entry is treated as a miss and the image is uploaded again
- AND an entry whose lifetime has not passed is still reused
Scenario: An image cannot be uploaded
- WHEN an image upload fails
- THEN the publisher fails and reports the failing image
- AND it does not create a draft that references a missing or placeholder media identifier
Scenario: An artifact names an unusable image locator
- WHEN an artifact’s locator escapes the recorded post package or names bytes whose digest does not match the recorded one
- THEN the publisher fails with a diagnostic naming the locator
- AND it uploads no replacement image in its place
Scenario: An unresolved image entity has no locator
- WHEN an artifact retains an
imageentity withoutmedia_itemsbut records no locator that resolves it - THEN the publisher fails before uploading any image or creating a draft, naming the unresolved entity
- AND it does not send an image entity without a media item
Scenario: A locator does not name a distinct image entity
- WHEN an artifact’s locator names an entity that is missing, is not an image, or is already named by another locator
- THEN the publisher fails before uploading any image rather than discovering the mismatch after paying for uploads
Scenario: Two entities share one numeric key
- WHEN an artifact contains more than one entity with the same numeric key
- THEN the publisher fails before uploading any image, because one locator could resolve only the first and leave the other without
media_items
Scenario: An image entity carries a malformed media_items entry
- WHEN an image entity’s
media_itemsis present but its entry has an empty or absentmedia_id, or a missing or incorrectmedia_category - THEN the entity is treated as unresolved, so the publisher requires a locator rather than sending the invalid entry to X
Scenario: An image entity carries a usable entry beside a malformed one
- WHEN an image entity’s
media_itemsholds a usable entry next to an empty, non-object, or incorrectly categorized one - THEN the entity is treated as unresolved, because every supplied entry must be usable
- AND the publisher does not send the malformed array merely because one entry looked valid
Scenario: An image entity carries no usable key
- WHEN an artifact contains an image entity whose
keyis missing, non-numeric, fractional, or negative - THEN the publisher fails before uploading any image, because the entity is invisible to locator coverage
- AND it does not send an unresolved image to X
Scenario: The cache cannot be written
- WHEN the cache cannot be recorded, such as a cache path in a read-only directory or over an existing directory
- THEN draft creation continues and the draft is created from the uploads that already succeeded
- AND the failure costs a later re-upload rather than aborting draft creation
Requirement: Reporting and failure handling
The publisher SHALL report the identifier it obtained and SHALL fail loudly rather than report success when a request was rejected. Failures MUST identify which operation failed and MUST NOT be retried automatically when the response indicates an authentication or request error.
Scenario: Report the created identifier
- WHEN a draft is created
- THEN the publisher reports the draft’s article identifier
Scenario: The API rejects a request
- WHEN an API request is rejected
- THEN the publisher reports the failing operation and the API’s error
- AND it does not report the operation as successful
Scenario: Validation runs without network access
- WHEN the project’s checks validate conversion
- THEN they run without contacting the X API
- AND no test requires live credentials
Requirement: Article banner media
Draft creation SHALL upload a selected banner through the media API and send
its identifier in the optional cover_media object with
media_category: tweet_image. The publisher SHALL share its digest-based media
cache between banner and body images, reusing an unexpired identifier for the
same bytes. It SHALL validate all banner and body image locators, bytes,
digests, and media types before any upload or draft request.
Scenario: Reuse one upload for banner and body
- WHEN the banner and a body image contain the same bytes
- THEN at most one media upload resolves both references
- AND the draft request carries that ID in
cover_mediaand the body entity
Scenario: Upload distinct banner and body images
- WHEN the selected banner has different bytes from every body image
- THEN it receives its own media upload or cached identifier
- AND all media resolves before the draft request is sent
Scenario: Create a draft without a banner
- WHEN the artifact has no banner locator
- THEN the request omits
cover_media
Scenario: Invalid image prevents every request
- WHEN any banner or body locator is malformed, escapes its package, names missing bytes, or has a mismatched digest or media type
- THEN draft creation fails before any media or draft HTTP request
3 - X article uploader build
3.1 - x-article-uploader-build Specification
Purpose
Build the X Article uploader from pinned, hermetic inputs so conversion and its tests run reproducibly in the repository’s Bazel workflow without network access or credentials.
Requirements
Requirement: Bazel targets for the uploader
The project SHALL expose Bazel targets for its converter library, its
conversion command, and its draft-creation command, with tests declared beside
the sources they cover. Targets MUST follow the repository’s role-based source
layout using internal/ for implementation and cmd/ for entry points.
Scenario: Build the converter and draft command
- WHEN the project’s conversion and draft-creation targets are built
- THEN both binaries build from the project’s declared sources
Scenario: Test the converter
- WHEN the project’s library tests run
- THEN they execute against the converter’s declared source inputs
- AND they require neither credentials nor network access
Requirement: Pinned hermetic dependencies
External build inputs, including the Markdown parser, SHALL be declared with immutable versions and integrity information in the owning dependency declarations, and SHALL be updated through the owning generator rather than hand-edited.
Scenario: Add a Markdown parser dependency
- WHEN the project depends on an external Markdown parser
- THEN its version and integrity are pinned in the owning dependency declaration
- AND the applicable lock and package-check targets validate the declaration
Scenario: Build without network access
- WHEN the project is built and tested in the repository’s agent configuration
- THEN no build or test step fetches an undeclared input
Requirement: Behavioral tests over real posts
Tests SHALL exercise conversion against every blog post in the site content tree and SHALL assert the mapped block types, inline ranges, and entity payloads for the constructs those posts contain, rather than relying on a single smoke check. The covered set MUST be derived from the content tree, so a post added later is covered without editing the test list. Each post SHALL carry an expected outcome: a post whose images all use a media type the upload endpoints accept MUST satisfy the mapping contract and MAY carry the continuing diagnostics its constructs produce, while a post that references an unacceptable image MUST fail with the named diagnostic at the reported source position. Where a construct is not present in any current post, a focused case SHALL cover it.
Behavior coverage SHALL be authored before the implementation it exercises: the expected outcome for a construct, or the enumerated ways a conversion can fail, is written first, and the implementation follows. Test cases MUST assert observable conversion behavior and MUST NOT restate the implementation’s own constants or assert only that output changed.
Scenario: Convert every existing blog post
- WHEN the converter’s tests run against the blog posts in the site content tree
- THEN conversion is exercised for every post in the tree, discovered from the tree itself
- AND each post produces the outcome its entry records: the mapping contract where every image is acceptable, including any continuing diagnostic its constructs produce, or the named failing diagnostic where an image is not acceptable
Scenario: Cover a construct absent from any current post
- WHEN a mapped construct does not appear in any current post
- THEN a focused case covers that construct
- AND the case asserts the resulting block type and range offsets
Scenario: Author behavior coverage before implementation
- WHEN a conversion behavior is implemented
- THEN the case that establishes its expected outcome already exists
Requirement: Raster diagram renders for posts intended for a draft
A post intended for a draft SHALL reference only images whose media type the
upload endpoints accept. A Mermaid diagram in such a post SHALL be referenced as
a raster render of its .mmd source, and this change SHALL verify that render
with an end-to-end check. The check SHALL prove the properties this project
depends on: the produced image is in an accepted media type, it was rendered
from the diagram source through the repository’s maintained Mermaid render
contract rather than a host browser, a host font, a CDN fetch, or a second
appearance, and the post that references it converts without an
unacceptable-image diagnostic.
Scenario: Convert a post that references a rendered diagram
- WHEN a post intended for a draft references a diagram’s raster render
- THEN the conversion check reads that rendered image from the post’s package
- AND the image is in a media type the upload endpoints accept
- AND the post’s recorded outcome is the mapping contract, with no unacceptable-image diagnostic
Scenario: Render a diagram hermetically as a raster image
- WHEN the raster render referenced by that post is produced
- THEN it is rendered from the diagram source through the pinned Mermaid inputs and the maintained appearance, not a host browser, a host font, a CDN fetch, or a second appearance
Requirement: Project registration
The new project SHALL be registered everywhere the repository catalogs direct projects, so the project builds, tests, and validates like its siblings.
Scenario: Register the project
- WHEN the project is added under
projects/ - THEN it appears in the project catalog, the OpenSpec validation workspace sources, and its own OpenSpec workspace
Scenario: Validate the repository
- WHEN the repository’s OpenSpec validation runs
- THEN the new owner workspace is included and its artifacts validate strictly
Requirement: Project documentation
The project SHALL document its purpose, its two operations, its inputs and outputs, how to run conversion and publication, and the credential and disclosure boundary it depends on.
Scenario: Read the project documentation
- WHEN a reader opens the project README
- THEN it explains conversion and publication, their inputs and outputs, and how credentials are supplied
- AND it states that conversion is offline and publication is the only authenticated, network-performing operation