feat: block-based markdown renderer for doc get - #26
Conversation
Replace the old /docs/v1/content?content_type=markdown endpoint with a local block-to-markdown renderer using the structured Docx block API. This gives full control over output format and fixes several issues: - Tables now render as proper markdown instead of HTML <table> tags - Mermaid diagrams (AddOns blocks) render as fenced code blocks (previously invisible in the old API output) - Text formatting preserved: bold, italic, strikethrough, inline code, links - Token-efficient output for AI agent consumers (~20% smaller for tables) The old server-rendered markdown is available via --raw flag on doc get. Adds internal/docrender package with 19 unit tests covering all supported block types including edge cases for nested lists, table cell escaping, and unknown block type handling.
Add RenderOptions with UserNames map to the docrender package. The doc get command now extracts unique user IDs from blocks and resolves them to display names via the contacts API before rendering. Falls back gracefully to @user_id if resolution fails (e.g., missing permissions or user not visible to the tenant app).
Replace per-user GetUser calls with single BatchGetUsers call (GET /contact/v3/users/batch) for resolving mention display names. Supports both user and tenant access tokens. Note: requires the app's contact scope range to include the mentioned users (configured in Lark Admin Console).
Print a warning to stderr with a hint about the required scope when the batch contact API fails, instead of silently falling back to user IDs.
The batch contact API returns success with empty items (instead of an error) when the app lacks contact scope visibility. Now detects this case and warns users to check their scope configuration.
Reviewed against Lark Server API documentation (docx-v1/data-structure/block.md). Renderer improvements: - Fix Swift/Scheme language ID bug (58=Scheme, 61=Swift) - Complete code language map: 18 → 75 languages - Add markdown escaping (inline: \*_`[]~ and block-level: # > - + N. ---) - Preserve URLs in plain text with <angle bracket> autolinks - Fix bold/italic whitespace: move leading/trailing spaces outside style markers - Collapse adjacent style markers (e.g., ****text**** → **text**) - Replace newlines in table cells with spaces (fixes broken table rows) - Decode URL-encoded links from Lark API New TextElement types: - Equation ($content$), Reminder (UTC ISO date), InlineFile, InlineBlock New block renderers (types 18-42): - Container blocks: Grid, GridColumn, QuoteContainer (render children) - Content blocks: File ([file: name]), Iframe ([embed: url]) - Identifier placeholders: Task, JiraIssue, WikiCatalog (with IDs) - Simple placeholders: Bitable, ChatCard, Diagram, ISV, Mindnote, Sheet, OKR Table improvements: - Prefer Table.Cells for cell ordering (canonical per API docs) - Fall back to node.children when Table.Cells is empty New command: - `doc images <document_id> -o <dir>` — batch download all images from a document New API method: - BatchGetMediaTempDownloadURLs for batch image URL resolution Test coverage: 45 tests (26 new) covering escaping, new elements, new blocks, language map completeness, table cell ordering, and existing coverage gaps. Documentation updated: CLAUDE.md, README.md, USAGE.md, skills/documents/SKILL.md
- Throttle media temp-URL requests to 5 QPS (200ms interval) to respect
API rate limits and prevent partial results for image-heavy documents
- Clamp H7-H9 headings to ###### (level 6) since CommonMark only
supports ATX headings up to 6 levels
- Use dynamic fence length for code blocks: if content contains ```,
the fence uses more backticks than the longest run found
- Fix JSON schema consistency in `doc images`: empty documents now
return `[]imageResult{}` instead of `[]string{}` so the `images`
field type is stable regardless of content
- Fix Swift language ID in help text: 58 → 61 (58 is Scheme)
279d85a to
f48c9d1
Compare
Embedded spreadsheet blocks (block_type 30) previously rendered as placeholder [sheet] text. The Lark API returns a sheet token for these blocks, which can be used to fetch cell data via the Sheets API. - Add SheetBlock type and Sheet field to DocumentBlock for deserialization - Add GetSheetDataAsText and BatchGetSheetDataAsText API methods with valueRenderOption=ToString for plain text cell values - Add ExtractSheetTokens and resolveSheetData following the existing mention/image pre-resolution pattern - Batch-fetch sheets sharing the same spreadsheet token, with per-sheet fallback when the batch API omits ranges - Render sheet data as markdown tables with 200-row inline cap and truncation marker - Apply markdown escaping to cell content for safety - Distinguish resolved-but-empty sheets from unresolved ones - Handle rich text segments, embedded images, and mixed cell types
renderTableCell previously only iterated direct children, dropping any nested content (e.g., ordered lists with sub-items). Replace the flat loop with a recursive collectCellText helper that walks the block tree depth-first. The helper handles three categories: - Text blocks: render inline text (text, bullet, ordered, headings) - Non-text leaf blocks: emit placeholders (file, image, iframe, sheet, task, jira, wiki) mirroring renderBlock - Container blocks: recurse into children (callout, grid, grid column, view, quote container)
Block spacing was handled per-block (each case adding trailing \n\n), but nothing ensured a blank line *before* standalone blocks when they followed compact content like text or list items. This caused headings, dividers, tables, and other blocks to run directly after preceding content without the required blank line separator. - Add ensureBlankLine helper and isStandaloneBlock classifier - Apply spacing rule once in renderChildren, covering all standalone block types uniformly (headings, code, quote, callout, divider, table, and all placeholder blocks)
Five correctness fixes identified during comprehensive review: 1. URL decoding: replace url.QueryUnescape with url.PathUnescape to preserve '+' as literal in URLs. Apply decoding to iframe URLs. 2. Inline code: add inlineCodeWrap helper that sizes backtick delimiters based on longest run in content (CommonMark spec). 3. Table headers: when header_row=false, emit empty header row before separator so data rows aren't promoted to header semantics. 4. OKR blocks: type 36 renders as container, 37/38 render objective and key result text content, 39 shows [okr progress]. Add fields to allTextBlocks and getAnyTextBlock for mention extraction. 5. Unknown text elements: emit stderr warning instead of silently dropping content.
f48c9d1 to
1c3b32f
Compare
yjwong
left a comment
There was a problem hiding this comment.
Testing against production documents
Tested PR against 5 real documents (mix of docx and wiki→docx, varying complexity) using both the main and PR branch builds.
Blocker: expire_time type mismatch — 2 of 5 docs crash
Documents containing InlineReminder elements fail with:
json: cannot unmarshal string into Go struct field
InlineReminder.data.items.text.elements.reminder.expire_time of type int64
Root cause: ExpireTime is defined as int64 in InlineReminder, but the Lark Docx API returns it as a string. The block data structure reference misleadingly lists the type as int, but the query-all-blocks endpoint documentation consistently lists expire_time as string.
Fix (3 lines):
// internal/api/types.go — change int64 → string
ExpireTime string `json:"expire_time,omitempty"`
NotifyTime string `json:"notify_time,omitempty"`
// internal/docrender/render.go — parse string to int64
expireMs, _ := strconv.ParseInt(elem.Reminder.ExpireTime, 10, 64)
t := time.UnixMilli(expireMs).UTC()Also add "strconv" to imports and update the two reminder test cases to use string literals. I've verified this fix locally — all 5 docs render successfully after the change, and all 109 tests pass.
Content comparison results (after applying the fix)
What the new renderer captures that main misses
Significant improvement across the board:
- Images: 30–40
[image: token]references per doc (main drops all images silently) - Tables: Large data tables are captured as markdown where main drops them entirely
- Embeds:
[jira: ...],[task: ...],[chat],[block: ...]placeholders for embedded content (main drops silently) - File attachments: Semantic
[file: name]references (main renders as ambiguous escaped text) - Cleaner markdown: No over-escaping, proper markdown tables instead of raw HTML
Content losses in the new renderer
| Issue | Severity | Details |
|---|---|---|
| Callout blocks (type 49) dropped | High | Some callouts are missing entirely — including substantive content like strategic recommendations and action items |
| Nested tables lost | High | Tables-inside-table-cells are flattened/dropped (e.g. data grids, role-module matrices) |
| Truncated multi-level blockquotes | Medium | > > nested blockquote content cut off in one doc |
| Task checkboxes → opaque UUIDs | Medium | [task: UUID] loses the human-readable text that main renders as * [x] ~~task text~~ |
| Jira inline cards lose summary | Low | [jira: KEY] vs full [Jira Key: KEY Jira Summary: ...] in main |
| Block type 48 (synced blocks) | Low | Unsupported in both renderers — not a regression |
| @mentions as raw IDs | Expected | Graceful fallback when Contacts scope unavailable |
| List numbering | Low | Some numbered lists restart at 1. instead of continuing |
Size comparison (5 docs, after fix)
Output size ranged from 7–37% smaller than main on most docs. One complex doc with many markdown tables was roughly the same size. The token efficiency claims in the PR description hold up.
Verdict
The expire_time bug is a blocker — 2 of 5 production docs crash. The content gains (images, tables, embeds) are substantial and the markdown quality is much better. The callout and nested table losses are worth addressing but could be follow-up work.
The Lark Docx API returns expire_time and notify_time as JSON strings, but the struct defined them as int64, causing unmarshal crashes on documents containing InlineReminder elements (2 of 5 production docs). Introduce FlexInt64 type that accepts both JSON strings and numbers, since the API docs are inconsistent about the wire format. Add a zero-value guard in the renderer to emit [reminder] instead of [reminder: 1970-01-01] when the timestamp is missing.
…renderer # Conflicts: # CLAUDE.md # internal/api/types.go
- Nested tables: flatten nested table content into parent cells using canonical Table.Cells ordering instead of silently dropping them - Task checkboxes: resolve task details via Task v2 API and render as - [x] ~~summary~~ (completed) or - [ ] summary (incomplete), with graceful fallback to [task: UUID] when scope unavailable - Empty callouts: render placeholder with emoji ID instead of silent drop - List numbering: preserve ordered list counter across empty text blocks that Lark inserts as spacers between list items - Nested blockquotes: verified working correctly (added test coverage)
…o feat/block-based-doc-renderer # Conflicts: # internal/api/types.go # internal/cmd/doc.go # internal/docrender/render.go # internal/docrender/render_test.go
|
@yjwong Thanks for the thorough production testing — this was incredibly helpful. Here's what's been addressed: Blocker fixed
Content losses addressed
Other changes since last review
|
Problem
The
doc getcommand relies on Lark's server-rendered markdown endpoint (/docs/v1/content), which has concrete limitations for AI-agent consumption: tables come back as HTML<table>tags, Mermaid diagrams are silently omitted, content is over-escaped, and we have no control over formatting. On a real production document (~500 blocks), the output is ~17K tokens — unnecessarily large for agent context windows.Solution
Replace the server-rendered markdown with a local block-based renderer using the Docx block API (
/docx/v1/documents/:id/blocks). The renderer produces clean, token-efficient markdown optimized for agent consumption — 24x smaller than raw block JSON and 3x smaller than the legacy endpoint.The implementation covers all 42 documented block types (up from 14), reviewed against the full Lark Server API documentation. A
--rawflag preserves access to the legacy server-rendered markdown.What's included
Block-based renderer (
internal/docrender/)valueRenderOption=ToStringfor clean text output.renderBlock.ensureBlankLineapplied centrally inrenderChildrenbefore all standalone blocks (headings, code, dividers, tables, quotes, callouts, placeholders) viaisStandaloneBlockclassifier.\*_[]~) and block-level (# > - + N. ---) to prevent downstream parsers from misinterpreting document text as formatting. Sheet cell content also escaped.inlineCodeWrapsizes backtick delimiters based on longest run in content per CommonMark specurl.PathUnescape(notQueryUnescape) to preserve+in URLs. Applied to links, document mentions, and iframe embeds.header_rowproperty. When false, emits an empty header row so data rows aren't promoted to header. Documentsheader_columnandmerge_infoas known limitations.[objective]/[key result]prefixes. OKR container (36) renders children. Progress (39) shows[okr progress].$math$), Reminder (UTC ISO date), InlineFile, InlineBlock. Unknown elements emit stderr warning.**bold**markers, adjacent markers collapsed, dynamic fence length for code-in-code@mentionresolution via batch contact API with graceful fallback to@user_idEmbedded sheet resolution (
internal/cmd/doc.go)@mentionenrichment: extract tokens -> batch fetch -> pass viaRenderOptionsparseSheetTokenandmapBatchResultshelpers with full test coverageGetSheetDataAsTextandBatchGetSheetDataAsTextAPI methods withvalueRenderOption=ToString[sheet: token]New
doc imagescommandBatch downloads all images from a document with 5 QPS rate limiting:
Breaking changes
doc getoutput has changed. Tables are now markdown (not HTML), Mermaid diagrams appear as fenced code blocks, embedded sheets render as tables (not[sheet]), embedded content shows[image: token]/[file: name]/[task: id]placeholders instead of being silently dropped, and@mentionsresolve to display names. Use--rawto get the previous behavior.Token efficiency
Measured on a production document with 38 images, 12 tables, and ~500 blocks:
doc get)doc get --raw)doc blocks)Test plan
--rawflag produces legacy server-rendered markdown@mentionsfall back gracefully when Contacts scope unavailable[sheet: token]when Sheets scope unavailable