Skip to content

Commit 494c743

Browse files
fix(app-shell): render marketplace README quotes and release notes as Markdown (#28967)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbLfi9jA1E9WaVdGXSrhTFv2)_ Fixes #28891 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:应用市场详情页的 README 支持 `>` 块引用,GitHub 提示块(`> [!NOTE]` 等五种)降级为普通引用并去掉记号;「版本」面板的版本说明改用同一个 Markdown 渲染器(紧凑字号)。 - **为什么改**:HotCRM 等官方应用的详情页把 `>`、`[!NOTE]`、`##`、`**` 原样显示,看起来像坏页面,而这是用户决定装不装的地方。 - **风险与回滚**:只动 `MarkdownText` 的解析与渲染和版本面板一处调用;版本说明里单个换行现在按 Markdown 合并为空格。回滚即 revert 本 PR。 - **先看**:`MarkdownText.tsx` 的 blockquote 分支(`parseBlocks` 里 `QUOTE_LINE` 一段)与 `renderBlocks` 的 `quote` 分支;`MarketplacePackagePage.tsx` 版本面板那一行。 - **两分钟验证**:打开任一带引用的应用详情页(如 HotCRM),「简介」里引用显示为左侧竖线的引用块、没有 `>` 和 `[!NOTE]`;右侧「版本」里 `## 6.0.0` 显示为小标题、加粗正常。 - **需要你做的**:无,席位落地。 ## What changes - `MarkdownText` gains a blockquote block: a run of consecutive `>` lines (up to three spaces of indent), with the marker and one optional space stripped, parsed recursively as blocks, so a quote holds paragraphs, lists, code and nested quotes. A quote interrupts a paragraph. A GitHub alert marker (`[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, case-insensitive) alone on the quote's first line is dropped, leaving a plain quote. Lazy continuation is not read: an unprefixed line ends the quote. Raw HTML inside a quote is skipped as everywhere else. - `MarkdownText` takes a `size` prop (`base`, the default, unchanged; `compact` for a side column: small muted text, small headings, tighter gaps). The rendering moves into a `renderBlocks` helper so a quote can render its own blocks. - The Versions panel renders `release_notes` through `MarkdownText` with `size="compact"` instead of a pre-line paragraph. No new component, no new dependency, no raw HTML passthrough. ## Tests - New `MarkdownText.blockquote-28891.test.tsx`: HotCRM's README opening and NOTE callout render as two `blockquote`s with no literal `>` or `[!NOTE]`; all five alert types and mixed case drop their marker; a quote nests a list and a quote; a quote ends a paragraph and ends at the first unprefixed line. Controls: `>` inside a fenced block stays code, `[!FOO]` stays text, the marker counts only on the first line, raw HTML inside a quote is still skipped. - New `MarketplacePackagePage.releaseNotes-28891.test.tsx` on the real page over the runtime-config boot: the 6.0.0 notes render an `h3`, two `strong`s and a list, with no `##` or `**`. Control: plain-text notes render as one paragraph, a version with no notes renders nothing under its header. - Reverse verification at `7e5152be`: with `MarkdownText.tsx` and `MarketplacePackagePage.tsx` restored to `origin/main` (`9a9823d9`), the two files ran 15 failed, 2 passed (the two controls); restored, 17 passed. ## Acceptance notes - Release notes that relied on pre-line line breaks (single newlines inside a paragraph) now join them with a space, as Markdown does. Blank-line-separated paragraphs and lists keep their breaks. - Tables, images and horizontal rules are still outside the renderer's subset; HotCRM's README `---` lines print as a paragraph, as before this PR. ## Verification Readings at `7e5152be` (the change commit) unless named; `29c82029` is that commit merged with `origin/main`. <details><summary>Tests</summary> - `vitest run` of the two new files: 2 files, 17 tests passed. - The 28 ui test files that name `MarkdownText` or `MarketplacePackagePage`: 28 passed, 336 tests. - After merging `origin/main` (head `29c82029`): the whole `packages/app-shell/src/console/marketplace/` directory, 45 files, 386 tests passed. - Reverse verification: see Tests above (15 red on `origin/main`, the 2 controls green). - `turbo run type-check --filter=@object-ui/app-shell`: 65 tasks successful (`tsc --noEmit` and `tsc -p tsconfig.test.json`). - `eslint` on the four touched ui files: 0 errors (11 warnings, none on changed lines). </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families for this diff; all 34 ran and exited 0, and `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED. That includes `node scripts/check-sdui-manifest.mjs` and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). After the merge, `check-sdui-manifest`, `check-changeset-no-major` and `check-empty-changeset` re-ran at `29c82029`, exit 0. Changed lines: 457 (+411 / -46), under the 3000 threshold. </details> <details><summary>Screenshots</summary> A Vite harness in the Console dev server rendered the About card and the Versions panel with the page's own markup, the real `MarkdownText` (before: the `origin/main` file; after: this branch) and the Console's stylesheet, on an excerpt of HotCRM's published README and a 6.0.0 release note with a heading, bold text and a list. Before: every quote line printed `>`, the callout printed `[!NOTE]`, the notes printed `##` and `**`. After: three blockquotes, no `>`, `[!NOTE]`, `##` or `**` in the text, light and dark. The images are in the project folder for the card (light and dark, before and after); the harness was deleted. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSnAkCQjAAqm9bmxCb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07450b5 commit 494c743

5 files changed

Lines changed: 411 additions & 46 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
Marketplace package page: README blockquotes (`>` lines) render as quotes instead of printing their `>`, and a GitHub alert (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`) renders as the same plain quote without its marker. The Versions panel renders each version's release notes as Markdown (headings, bold, lists, code) in a compact size, instead of printing them as plain text; single line breaks inside a paragraph now join as Markdown does. Raw HTML stays skipped in both places.

‎ui/packages/app-shell/src/console/marketplace/MarkdownText.tsx‎

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
* • `1. ` numbered lists (single level)
99
* • `**bold**`, `*italic*`, `` `code` ``, `[text](url)` inline
1010
* • Triple-backtick fenced code blocks
11+
* • `>` blockquotes: consecutive `>` lines, their content parsed as blocks
12+
* (so a quote nests lists, code and further quotes). A GitHub alert —
13+
* `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]` on the
14+
* quote's first line — renders as the same plain quote with the marker
15+
* dropped. CommonMark's lazy continuation (an unprefixed line continuing a
16+
* quoted paragraph) is not read: an unprefixed line ends the quote.
1117
*
1218
* Deliberately NOT a full Markdown engine — we want zero new deps and
1319
* predictable output. If a README really needs tables or images, the
@@ -29,12 +35,26 @@
2935
import { RAW_HTML_CLOSING_TAG, RAW_HTML_OPEN_TAG, renderInlineMarkdown } from '@object-ui/components';
3036

3137
interface Block {
32-
type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html';
38+
type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html' | 'quote';
3339
text?: string;
3440
items?: string[];
3541
lang?: string;
42+
/** A blockquote's content, parsed as blocks. */
43+
children?: Block[];
3644
}
3745

46+
// ── Blockquotes ─────────────────────────────────────────────────────────────
47+
48+
/** A blockquote line: up to three spaces of indent, then `>`. */
49+
const QUOTE_LINE = /^ {0,3}>/;
50+
/** The quote marker and the one optional space after it. */
51+
const QUOTE_MARKER = /^ {0,3}> ?/;
52+
/**
53+
* GitHub's alert marker, alone on a quote's first line. GitHub matches the
54+
* five types case-insensitively; anything else in brackets stays text.
55+
*/
56+
const ALERT_MARKER = /^\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*$/i;
57+
3858
// ── CommonMark raw-HTML grammar ─────────────────────────────────────────────
3959
// The open and closing tag come from `@object-ui/components`, where the inline
4060
// "Raw HTML" pass that shares them lives; the block rules below build on them.
@@ -115,6 +135,19 @@ function parseBlocks(src: string): Block[] {
115135
continue;
116136
}
117137

138+
// Blockquote: the run of `>` lines, unprefixed, parsed as its own blocks.
139+
// A GitHub alert's marker line is dropped, leaving a plain quote.
140+
if (QUOTE_LINE.test(line)) {
141+
const inner: string[] = [];
142+
while (i < lines.length && QUOTE_LINE.test(lines[i])) {
143+
inner.push(lines[i].replace(QUOTE_MARKER, ''));
144+
i++;
145+
}
146+
if (ALERT_MARKER.test(inner[0])) inner.shift();
147+
blocks.push({ type: 'quote', children: parseBlocks(inner.join('\n')) });
148+
continue;
149+
}
150+
118151
// Headings
119152
const h = /^(#{1,3})\s+(.*)$/.exec(line);
120153
if (h) {
@@ -161,6 +194,7 @@ function parseBlocks(src: string): Block[] {
161194
!/^[-*]\s+/.test(lines[i]) &&
162195
!/^\d+\.\s+/.test(lines[i]) &&
163196
!lines[i].startsWith('```') &&
197+
!QUOTE_LINE.test(lines[i]) &&
164198
!htmlBlockRule(lines[i])?.interruptsParagraph
165199
) {
166200
buf.push(lines[i]);
@@ -188,51 +222,87 @@ function renderInline(input: string): React.ReactNode {
188222
return renderInlineMarkdown(input, README_INLINE);
189223
}
190224

191-
export function MarkdownText({ source, className }: { source: string; className?: string }) {
192-
const blocks = parseBlocks(source);
193-
return (
194-
<div className={`flex flex-col gap-4 text-sm leading-relaxed text-foreground/90 ${className ?? ''}`}>
195-
{blocks.map((b, i) => {
196-
switch (b.type) {
197-
case 'h1':
198-
return <h2 key={i} className="mt-2 text-xl font-bold tracking-tight">{renderInline(b.text ?? '')}</h2>;
199-
case 'h2':
200-
return <h3 key={i} className="mt-3 text-base font-semibold tracking-tight">{renderInline(b.text ?? '')}</h3>;
201-
case 'h3':
202-
return <h4 key={i} className="mt-2 text-sm font-semibold tracking-tight text-foreground/80">{renderInline(b.text ?? '')}</h4>;
203-
case 'p':
204-
return <p key={i}>{renderInline(b.text ?? '')}</p>;
205-
case 'ul':
206-
return (
207-
<ul key={i} className="ml-1 flex list-none flex-col gap-1.5">
208-
{(b.items ?? []).map((it, j) => (
209-
<li key={j} className="flex gap-2">
210-
<span aria-hidden="true" className="mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500/70" />
211-
<span>{renderInline(it)}</span>
212-
</li>
213-
))}
214-
</ul>
215-
);
216-
case 'ol':
217-
return (
218-
<ol key={i} className="ml-5 flex list-decimal flex-col gap-1.5 marker:text-muted-foreground">
219-
{(b.items ?? []).map((it, j) => (
220-
<li key={j} className="pl-1">{renderInline(it)}</li>
221-
))}
222-
</ol>
223-
);
224-
case 'code':
225-
return (
226-
<pre key={i} className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed">
227-
<code className="font-mono">{b.text}</code>
228-
</pre>
229-
);
230-
case 'html':
231-
// Skipped: neither rendered nor printed (objectui#12146).
232-
return null;
233-
}
225+
/**
226+
* The two places a package's Markdown is read: `base` is the About card's
227+
* README, `compact` the Versions panel's release notes — a narrow side column
228+
* in muted small print, where README-sized headings would outweigh the
229+
* version they annotate.
230+
*/
231+
type MarkdownSize = 'base' | 'compact';
232+
233+
const SIZE_CLASSES: Record<MarkdownSize, { root: string; stack: string; h1: string; h2: string; h3: string }> = {
234+
base: {
235+
root: 'text-sm leading-relaxed text-foreground/90',
236+
stack: 'gap-4',
237+
h1: 'mt-2 text-xl font-bold tracking-tight',
238+
h2: 'mt-3 text-base font-semibold tracking-tight',
239+
h3: 'mt-2 text-sm font-semibold tracking-tight text-foreground/80',
240+
},
241+
compact: {
242+
root: 'text-xs leading-relaxed text-muted-foreground',
243+
stack: 'gap-2',
244+
h1: 'text-sm font-semibold tracking-tight text-foreground',
245+
h2: 'text-xs font-semibold tracking-tight text-foreground',
246+
h3: 'text-xs font-medium tracking-tight text-foreground/80',
247+
},
248+
};
249+
250+
function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] {
251+
const cls = SIZE_CLASSES[size];
252+
return blocks.map((b, i) => {
253+
switch (b.type) {
254+
case 'h1':
255+
return <h2 key={i} className={cls.h1}>{renderInline(b.text ?? '')}</h2>;
256+
case 'h2':
257+
return <h3 key={i} className={cls.h2}>{renderInline(b.text ?? '')}</h3>;
258+
case 'h3':
259+
return <h4 key={i} className={cls.h3}>{renderInline(b.text ?? '')}</h4>;
260+
case 'p':
261+
return <p key={i}>{renderInline(b.text ?? '')}</p>;
262+
case 'ul':
263+
return (
264+
<ul key={i} className="ml-1 flex list-none flex-col gap-1.5">
265+
{(b.items ?? []).map((it, j) => (
266+
<li key={j} className="flex gap-2">
267+
<span aria-hidden="true" className="mt-1.5 inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500/70" />
268+
<span>{renderInline(it)}</span>
269+
</li>
270+
))}
271+
</ul>
272+
);
273+
case 'ol':
274+
return (
275+
<ol key={i} className="ml-5 flex list-decimal flex-col gap-1.5 marker:text-muted-foreground">
276+
{(b.items ?? []).map((it, j) => (
277+
<li key={j} className="pl-1">{renderInline(it)}</li>
278+
))}
279+
</ol>
280+
);
281+
case 'code':
282+
return (
283+
<pre key={i} className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs leading-relaxed">
284+
<code className="font-mono">{b.text}</code>
285+
</pre>
286+
);
287+
case 'quote':
288+
return (
289+
<blockquote key={i} className={`flex flex-col ${cls.stack} border-l-2 border-violet-500/40 pl-4 text-muted-foreground`}>
290+
{renderBlocks(b.children ?? [], size)}
291+
</blockquote>
292+
);
293+
case 'html':
294+
// Skipped: neither rendered nor printed (objectui#12146).
234295
return null;
235-
})}
296+
}
297+
return null;
298+
});
299+
}
300+
301+
export function MarkdownText({ source, className, size = 'base' }: { source: string; className?: string; size?: MarkdownSize }) {
302+
const cls = SIZE_CLASSES[size];
303+
return (
304+
<div className={`flex flex-col ${cls.stack} ${cls.root} ${className ?? ''}`}>
305+
{renderBlocks(parseBlocks(source), size)}
236306
</div>
237307
);
238308
}

‎ui/packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1441,7 +1441,7 @@ export function MarketplacePackagePage() {
14411441
</span>
14421442
</div>
14431443
{v.release_notes && v.release_notes.trim() && (
1444-
<p className="whitespace-pre-line pl-5 text-xs text-muted-foreground">{v.release_notes.trim()}</p>
1444+
<MarkdownText source={v.release_notes.trim()} size="compact" className="pl-5" />
14451445
)}
14461446
</li>
14471447
))}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A package README's `>` blockquotes render as quotes, and a GitHub alert
5+
* (`> [!NOTE]` and its siblings) as the same plain quote without its marker.
6+
*
7+
* Seen on staging, on HotCRM 6.0.0's package page: the About card printed the
8+
* README's opening quote with its leading `>` on every line, and its
9+
* `> [!NOTE]` callout with the `[!NOTE]` marker as literal text, because
10+
* `MarkdownText` had no blockquote rule and read the lines as a paragraph.
11+
*
12+
* The CONTROLS pin the edges: a `>` inside a fenced block is code, a bracketed
13+
* word that is not one of GitHub's five alert types stays text, and the
14+
* marker counts only on a quote's first line.
15+
*/
16+
17+
import '@testing-library/jest-dom/vitest';
18+
import { describe, it, expect } from 'vitest';
19+
import { render } from '@testing-library/react';
20+
import { MarkdownText } from '../MarkdownText';
21+
22+
/** HotCRM's README opening and its NOTE callout, as it publishes them. */
23+
const HOTCRM_README = [
24+
'# HotCRM',
25+
'',
26+
'> **The ontology is the software.** One executable business ontology.',
27+
'>',
28+
'> 本体即软件。',
29+
'',
30+
'HotCRM is the reference implementation.',
31+
'',
32+
'> [!NOTE]',
33+
'> **Small enough for an agent to hold whole.** The sales package carries **~68k tokens**.',
34+
'> Measured, not estimated: `node scripts/check-source-token-ratchet.mjs` prints both figures.',
35+
].join('\n');
36+
37+
function renderMd(source: string) {
38+
return render(<MarkdownText source={source} />).container;
39+
}
40+
41+
describe('a README with blockquotes', () => {
42+
it('renders each quote as a blockquote, with no literal `>`', () => {
43+
const root = renderMd(HOTCRM_README);
44+
45+
const quotes = root.querySelectorAll('blockquote');
46+
expect(quotes).toHaveLength(2);
47+
expect(root.textContent).not.toContain('>');
48+
49+
// The first quote: two paragraphs (the bare `>` line separates them), inline marks rendered.
50+
const [first, second] = Array.from(quotes);
51+
const paras = first.querySelectorAll(':scope > p');
52+
expect(paras).toHaveLength(2);
53+
expect(paras[0].querySelector('strong')?.textContent).toBe('The ontology is the software.');
54+
expect(paras[0].textContent).toBe('The ontology is the software. One executable business ontology.');
55+
expect(paras[1].textContent).toBe('本体即软件。');
56+
57+
// The paragraph between the quotes is outside both.
58+
const between = Array.from(root.querySelectorAll(':scope > div > p')).map((p) => p.textContent);
59+
expect(between).toEqual(['HotCRM is the reference implementation.']);
60+
61+
// The alert's two lines are one paragraph, as in any quote.
62+
expect(second.querySelectorAll(':scope > p')).toHaveLength(1);
63+
expect(second.querySelector('code')?.textContent).toBe('node scripts/check-source-token-ratchet.mjs');
64+
});
65+
66+
it('drops the GitHub alert marker and keeps the quote', () => {
67+
const root = renderMd(HOTCRM_README);
68+
expect(root.textContent).not.toContain('[!NOTE]');
69+
expect(root.textContent).not.toContain('NOTE');
70+
const alert = root.querySelectorAll('blockquote')[1];
71+
expect(alert.textContent).toMatch(/^Small enough for an agent to hold whole\. The sales package carries ~68k tokens\./);
72+
});
73+
74+
it.each(['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION', 'note', 'Warning'])('drops the %s marker', (type) => {
75+
const root = renderMd(`> [!${type}]\n> Body.`);
76+
expect(root.querySelector('blockquote')?.textContent).toBe('Body.');
77+
expect(root.textContent).not.toContain('[!');
78+
});
79+
80+
it('parses a quote’s content as blocks: a list and a nested quote inside it', () => {
81+
const root = renderMd(['> Steps:', '>', '> - one', '> - two', '>', '> > nested'].join('\n'));
82+
const outer = root.querySelector(':scope > div > blockquote')!;
83+
expect(outer.querySelectorAll('li')).toHaveLength(2);
84+
expect(outer.querySelector('blockquote')?.textContent).toBe('nested');
85+
expect(root.textContent).not.toContain('>');
86+
});
87+
88+
it('ends a paragraph where a quote starts, and a quote at the first unprefixed line', () => {
89+
const root = renderMd('Intro line\n> quoted\nafter');
90+
expect(root.querySelector('blockquote')?.textContent).toBe('quoted');
91+
expect(Array.from(root.querySelectorAll(':scope > div > p')).map((p) => p.textContent)).toEqual(['Intro line', 'after']);
92+
});
93+
});
94+
95+
describe('controls', () => {
96+
it('a `>` inside a fenced block is code', () => {
97+
const root = renderMd('```\n> not a quote\n```');
98+
expect(root.querySelector('blockquote')).toBeNull();
99+
expect(root.querySelector('pre')?.textContent).toBe('> not a quote');
100+
});
101+
102+
it('a bracketed word that is not an alert type stays text', () => {
103+
const root = renderMd('> [!FOO]\n> Body.');
104+
expect(root.querySelector('blockquote')?.textContent).toBe('[!FOO] Body.');
105+
});
106+
107+
it('the marker counts only on the quote’s first line', () => {
108+
const root = renderMd('> Body.\n>\n> [!NOTE]');
109+
expect(root.querySelector('blockquote')?.textContent).toContain('[!NOTE]');
110+
});
111+
112+
it('raw HTML inside a quote is still skipped', () => {
113+
const root = renderMd('> <div align="center">\n> <img src="x.svg">\n> </div>\n>\n> Text.');
114+
expect(root.querySelector('img')).toBeNull();
115+
expect(root.querySelector('blockquote')?.textContent).toBe('Text.');
116+
});
117+
});

0 commit comments

Comments
 (0)