Skip to content

Commit 677ac3b

Browse files
fix(app-shell): a marketplace README fits its detail page; badges render as images, tables as tables (#30426)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgRknVqcGH2U7KEF3fxmBr1v)_ domain:ui#2 · os-bill · session_01Utp6Cb5R6JDdfyvtj3dPR2 Fixes #30330 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:商店详情页的 README 不再被截掉:长行自动换行,只有代码块和表格在自己框内横向滚动。徽章显示成图片,表格显示成表格,`---` 显示成分隔线,不再露出 Markdown 原文。 - 为什么改:HotCRM 详情页(883 px 窗口)里一条 259 字符的代码行把整个 README 撑到 1926 px,右边被裁掉、无法滚动;徽章行显示成 `![License](LICENSE) …` 字面。README 是应用的销售页,半截句子和原始标记让成品像半成品。 - 风险与回滚:只动 app-shell 商店目录两处(README 渲染器 `MarkdownText`、详情页两列加 `min-w-0`)及测试与 changeset,不动 spec、服务端与共享内联渲染器。图片只认绝对 `https:` 地址并以 no-referrer 加载,其余来源整段略去;控制台 CSP 本就允许 `img-src https:`。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/console/marketplace/MarkdownText.tsx`(`renderInline` 的图片切分与 `absoluteUrl`、表格块规则)→ `MarketplacePackagePage.tsx`(两个网格单元的 `min-w-0`)→ 测试 `MarkdownText.wideReadme-30330.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/marketplace/__tests__/MarkdownText.wideReadme-30330.test.tsx packages/app-shell/src/console/marketplace/__tests__/MarketplacePackagePage.readmeWidth-30330.test.tsx`,预期全部通过;在 main 的代码上同一命令 26 条红、5 条对照绿。 - 需要你做的:无,席位落地。 ## What changes **Which element forced the width (the card left it open).** Below the `lg` breakpoint the detail page's `grid gap-4 lg:grid-cols-3` has one implicit `auto` column, and a grid item's automatic minimum width is its content's min-content width. HotCRM's README has a fenced `curl … -d '{"jsonrpc":…}'` line of 259 characters; a `pre` does not wrap, so its min-content width is that line, and it sized the column, the About card and the README at about 1926 px. The `pre` already had `overflow-x-auto`, but it never became narrower than its line, so it never scrolled. (At `lg` and up the template is `repeat(3, minmax(0, 1fr))`, which has no automatic minimum, so the defect showed only on narrower windows.) - **`MarketplacePackagePage.tsx`.** Both grid cells (README and Versions) carry `min-w-0`, so the column keeps the page's width and the fence scrolls inside itself. - **`MarkdownText` root.** `min-w-0 break-words`, so a long unbroken word (a URL, a long code span) wraps instead of overflowing; a list item's text span gets `min-w-0` beside its bullet for the same reason. A `pre` keeps `white-space: pre` and its own scroller; a table scrolls in its own `overflow-x-auto` box. **Why the badges printed as markup.** HotCRM writes `[![License](https://img.shields.io/…svg)](LICENSE)`. The renderer had no image rule, and the shared inline link rule read `[![License](https://…svg)` as a link whose text is `![License`, leaving `](LICENSE)` as text, which reads exactly as the card measured. - **Images.** `MarkdownText` now matches `![alt](src "title")` and the linked form `[![alt](src)](href)` before handing the rest of the text to `@object-ui/components`' `renderInlineMarkdown` (unchanged; it is shared with `element:text` and outside this card's surface). An image renders only from an absolute `https:` URL, with `referrerPolicy="no-referrer"`, `loading="lazy"` and `max-w-full`; any other source (a repository-relative path, `/…`, `//…`, `http:`, `data:`, `javascript:`, any whitespace or control character) is left out, alt text included. A linked image keeps its anchor only for an absolute `http(s):` destination (new tab, `noopener noreferrer`, the README link look): a relative destination such as `LICENSE` would resolve against the console, not the repository. A code span is matched first, so `` `![x](y)` `` stays code. No fetch path is invented for relative sources. - **Tables.** The card's Expected names wide tables, and HotCRM's README has five pipe tables that printed as one long line of pipes. A GFM pipe table (a line with a pipe above a delimiter row with the same cell count, then rows up to a blank line or another block) renders as a `table` with the delimiter row's alignment, cells drawn by the same inline pass, short rows padded and extra cells dropped, `\|` read as a literal pipe. A pipe in prose with no delimiter row stays text. - **Thematic breaks (bounded in-place fix, same class: Markdown printed as literal text).** `---`, `***`, `___` (spaces allowed) between blocks render as `hr`; `* * *` is a rule, not a list item. A `---` line directly under a paragraph line stays that paragraph's text, as before (CommonMark reads it as a setext underline, which this renderer does not draw). HotCRM's README has four such rules that printed as `---`. The file header's "If a README really needs tables or images, the publisher can host their own docs" is replaced by the rules above. No raw HTML is rendered and no dependency is added. ## Tests - `MarkdownText.wideReadme-30330.test.tsx` (new): HotCRM's badge row renders four `img` and no `![`/`](`; the referrer, lazy-load and `max-w-full` attributes; only the absolute-`https:` badge link keeps its anchor; eight refused sources leave the image out (alt included) in a paragraph and in a list item; a refused link scheme keeps the image without the anchor; an image title, and image syntax inside a code span staying code; tables (cells, inline marks, escaped pipe, alignment, own scroller, padding/truncation, HotCRM's screenshot table); five thematic-break spellings; the compact size; the width classes. CONTROL: headings, links, lists, quotes and fences render as before, a pipe in prose stays text, a `---` under a paragraph line stays text, a relative text link keeps its `href`. - `MarketplacePackagePage.readmeWidth-30330.test.tsx` (new, the `releaseNotes-28891` harness): both grid cells carry `min-w-0`; CONTROL: the README fence renders in the About cell with its own scroller, and the Versions notes render. Runs (all under `scripts/pm/os-verify-lock.sh`, `pnpm --dir ui exec vitest run …`): - Red: the two new files against `origin/main`'s `MarkdownText.tsx` and `MarketplacePackagePage.tsx` (checked out over the fix, then restored with `git checkout HEAD --`, `git diff HEAD` empty): `Tests 26 failed | 5 passed (31)`, the 5 being the CONTROL rows. - Green, after the fix: the two new files plus every `MarkdownText.*` and `MarketplacePackagePage*` test in the directory (21 files): `Test Files 21 passed (21)`, `Tests 159 passed (159)`. On the final head `6a86665c`: the 7 files that render `MarkdownText` plus `scripts/__tests__/markdown-test-inputs.test.ts`: `Tests 80 passed (80)`. - Type-check: `pnpm exec turbo run type-check --filter=@object-ui/app-shell --concurrency=2`: `Tasks: 68 successful, 68 total`; on the final head, `pnpm --filter @object-ui/app-shell type-check`: `VERDICT command-exit 0`. Lint: `eslint` on the touched files: 0 errors (the warnings are pre-existing `no-explicit-any` in `MarketplacePackagePage.tsx`). The whole change is the ablation: the red run above is the new pins against the old code, each flipped by the code it names. <details><summary>Real-browser width measurement (Chromium, 883 px viewport)</summary> jsdom does no layout, so the tests pin the classes. The mechanism was measured in Chromium with a static page whose CSS mirrors the Tailwind classes (an 835 px `overflow: hidden` parent as the card measured, a one-column grid, the card's 24 px padding, the README's flex column, HotCRM's 259-character fence line at 12 px monospace, and a 200-character unbroken URL in a paragraph): - Before (no `min-width: 0`, no `overflow-wrap`): parent `scrollWidth 1947` in `clientWidth 835`; card `1945` wide; `pre` `1895` wide with nothing to scroll; the paragraph's right edge at `1922` px. - After (`min-width: 0` on the grid cell and the README root, `overflow-wrap: break-word`): parent `835`/`835`; card `833`; `pre` `clientWidth 783`, `scrollWidth 1895` (scrolls inside itself); the paragraph's right edge at `810` px. The measurement page is a reconstruction, not the Console build; the Console's own render on a live stack was not exercised. </details> ## Acceptance notes - `ui/AGENTS.md` #2 (docs-driven): no page under `content/docs/` describes which Markdown a marketplace README may use (`deployment/marketplace-handbook.mdx` names the `readmePath` and `readme` fields only), so no doc changes; that tree is also outside this card's surface. - A text link to a relative path (`[the guide](docs/guide)`) still resolves against the console, as before; the shared inline pass owns that rule and `element:text` reads it too, so it is untouched here. Observation, not filed. - An image nested inside a text link's label (`[see ![x](y)](z)`) is not a shape this renderer reads; the image renders or drops and the link's brackets print. Not seen in any measured README. ## Verification <details><summary>Derived gates (dispatch-gates --commands on merge head d13ed5b; re-runs on final head 6a86665): 35 run, all exit 0</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-sdui-manifest.mjs` :: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:skill-identifier-liveness` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 `pnpm --dir ui exec vitest run scripts/` first exited 1: `markdown-test-inputs.test.ts` read the new test's `CHANGELOG.md` badge fixture as a markdown input the ledger does not describe. The fixture now spells the path `CHANGELOG`; re-run on the final head: `Test Files 170 passed | 2 skipped (172)`, exit 0. Because that commit landed while the loop ran, the gates that read test files were re-run on the final head, all exit 0: `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:undeclared-dep-imports`, `check-comment-mask-corpus`, `check-sdui-manifest`. `node scripts/pm/dispatch-gates.mjs --ran`: `35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED`. </details> <details><summary>Standing ui gates</summary> - `node scripts/check-sdui-manifest.mjs` (and `--self-test`): in the derived list above, exit 0. - `pnpm --dir ui check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only -> exit 0`. </details> Dogfood battery: not owed (no `!` in the title, no `(narrowing)` arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZbFePHuuidesS6BLC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 644883e commit 677ac3b

5 files changed

Lines changed: 599 additions & 11 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a marketplace package's README fits its detail page; badges render as images and pipe tables as tables
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a package's detail page below the `lg` breakpoint, one long line in a README code block widened the whole README past the page's right edge, where it was cut off with no way to scroll: the page's grid cells took their content's width as their minimum. Each cell now may shrink to the page, so long words wrap and only a code block or a table scrolls, inside itself.
11+
12+
The README renderer also reads three things it printed as literal markup:
13+
14+
- Images, `![alt](src)` and linked `[![alt](src)](href)` badges, render from an absolute `https:` URL, without sending the console's address as referrer. Any other source (a repository-relative path such as `LICENSE`, `http:`, `data:`, `javascript:`) is left out, alt text included. A linked image keeps its link only for an absolute `http:` or `https:` destination.
15+
- GFM pipe tables render as tables, with the delimiter row's alignment, each in its own horizontal scroller.
16+
- Thematic breaks (`---`, `***`, `___`) between blocks render as a rule.
17+
18+
Everything else renders as before; release notes in the Versions panel go through the same renderer.

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

Lines changed: 191 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
* • `- ` / `* ` bulleted lists (single level)
88
* • `1. ` numbered lists (single level)
99
* • `**bold**`, `*italic*`, `` `code` ``, `[text](url)` inline
10+
* • `![alt](src)` images, linked or not: drawn only from an absolute
11+
* `https:` URL, else left out (a repository-relative path such as
12+
* `LICENSE` names nothing the console can load). A linked image keeps
13+
* its anchor only for an absolute `http(s):` destination, for the same
14+
* reason. Image syntax inside a code span stays code.
15+
* • GFM pipe tables (header row, delimiter row, body rows), each in its
16+
* own horizontal scroller
17+
* • Thematic breaks (`---`, `***`, `___`) between blocks
1018
* • Triple-backtick fenced code blocks
1119
* • `>` blockquotes: consecutive `>` lines, their content parsed as blocks
1220
* (so a quote nests lists, code and further quotes). A GitHub alert —
@@ -16,8 +24,11 @@
1624
* quoted paragraph) is not read: an unprefixed line ends the quote.
1725
*
1826
* Deliberately NOT a full Markdown engine — we want zero new deps and
19-
* predictable output. If a README really needs tables or images, the
20-
* publisher can host their own docs and link via `homepage_url`.
27+
* predictable output.
28+
*
29+
* Width: the README fits its column. Long words wrap; only a fenced block and
30+
* a table scroll, each inside itself. The column itself must be allowed to
31+
* shrink below its content (`min-w-0` on the grid cell that holds it).
2132
*
2233
* Raw HTML (objectui#12146) is recognised by CommonMark's own grammar — the
2334
* "HTML blocks" section's seven start conditions and the "Raw HTML" section's
@@ -32,15 +43,63 @@
3243
* prints as before.
3344
*/
3445

46+
import { Fragment, type ReactNode } from 'react';
3547
import { RAW_HTML_CLOSING_TAG, RAW_HTML_OPEN_TAG, renderInlineMarkdown } from '@object-ui/components';
3648

3749
interface Block {
38-
type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html' | 'quote';
50+
type: 'h1' | 'h2' | 'h3' | 'p' | 'ul' | 'ol' | 'code' | 'html' | 'quote' | 'table' | 'hr';
3951
text?: string;
4052
items?: string[];
4153
lang?: string;
4254
/** A blockquote's content, parsed as blocks. */
4355
children?: Block[];
56+
/** A table's header cells, its body rows (each padded to the header's count) and per-column alignment. */
57+
table?: { head: string[]; rows: string[][]; align: TableAlign[] };
58+
}
59+
60+
// ── Thematic breaks ─────────────────────────────────────────────────────────
61+
62+
/** Three or more `-`, `*` or `_`, alone on the line, spaces allowed between them. */
63+
const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
64+
/** CommonMark's setext underline: under a paragraph line it is not a rule. */
65+
const SETEXT_UNDERLINE = /^ {0,3}-+[ \t]*$/;
66+
67+
// ── GFM pipe tables ─────────────────────────────────────────────────────────
68+
69+
type TableAlign = 'left' | 'center' | 'right' | null;
70+
71+
/** A delimiter row: `---`, `:--`, `--:` or `:-:` cells between pipes. */
72+
const TABLE_DELIMITER_ROW = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
73+
74+
/** A row's cells: the outer pipes dropped, split on every pipe not escaped as `\|`. */
75+
function tableCells(line: string): string[] {
76+
let row = line.trim();
77+
if (row.startsWith('|')) row = row.slice(1);
78+
if (row.endsWith('|') && !row.endsWith('\\|')) row = row.slice(0, -1);
79+
return row.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
80+
}
81+
82+
/** A table starts where a line holding a pipe sits above a delimiter row with the same cell count. */
83+
function isTableStart(lines: string[], i: number): boolean {
84+
const head = lines[i];
85+
const delimiter = lines[i + 1];
86+
if (!head?.includes('|') || delimiter === undefined || !TABLE_DELIMITER_ROW.test(delimiter)) return false;
87+
if (!delimiter.includes('|') && !head.trim().startsWith('|')) return false;
88+
return tableCells(head).length === tableCells(delimiter).length;
89+
}
90+
91+
/** A line that opens a heading, a fence, a quote or a rule, which ends a table. */
92+
function startsOtherBlock(line: string): boolean {
93+
return /^#{1,3}\s+/.test(line) || line.startsWith('```') || QUOTE_LINE.test(line) || THEMATIC_BREAK.test(line);
94+
}
95+
96+
function tableAlign(cell: string): TableAlign {
97+
const left = cell.startsWith(':');
98+
const right = cell.endsWith(':');
99+
if (left && right) return 'center';
100+
if (right) return 'right';
101+
if (left) return 'left';
102+
return null;
44103
}
45104

46105
// ── Blockquotes ─────────────────────────────────────────────────────────────
@@ -148,6 +207,29 @@ function parseBlocks(src: string): Block[] {
148207
continue;
149208
}
150209

210+
// Thematic break, before the bullet list: `* * *` is a rule, not an item.
211+
if (THEMATIC_BREAK.test(line)) {
212+
blocks.push({ type: 'hr' });
213+
i++;
214+
continue;
215+
}
216+
217+
// GFM pipe table: the header, the delimiter row, then every following line
218+
// that holds a pipe, up to a blank line.
219+
if (isTableStart(lines, i)) {
220+
const head = tableCells(line);
221+
const align = tableCells(lines[i + 1]).map(tableAlign);
222+
i += 2;
223+
const rows: string[][] = [];
224+
while (i < lines.length && lines[i].trim() && lines[i].includes('|') && !startsOtherBlock(lines[i])) {
225+
const cells = tableCells(lines[i]);
226+
rows.push(head.map((_, c) => cells[c] ?? ''));
227+
i++;
228+
}
229+
blocks.push({ type: 'table', table: { head, rows, align } });
230+
continue;
231+
}
232+
151233
// Headings
152234
const h = /^(#{1,3})\s+(.*)$/.exec(line);
153235
if (h) {
@@ -195,7 +277,10 @@ function parseBlocks(src: string): Block[] {
195277
!/^\d+\.\s+/.test(lines[i]) &&
196278
!lines[i].startsWith('```') &&
197279
!QUOTE_LINE.test(lines[i]) &&
198-
!htmlBlockRule(lines[i])?.interruptsParagraph
280+
!htmlBlockRule(lines[i])?.interruptsParagraph &&
281+
!isTableStart(lines, i) &&
282+
// `***` and `___` end a paragraph; a `---` line under one stays its text, as before.
283+
!(THEMATIC_BREAK.test(lines[i]) && !SETEXT_UNDERLINE.test(lines[i]))
199284
) {
200285
buf.push(lines[i]);
201286
i++;
@@ -218,8 +303,71 @@ const README_INLINE = {
218303
newTab: 'all',
219304
} as const;
220305

221-
function renderInline(input: string): React.ReactNode {
222-
return renderInlineMarkdown(input, README_INLINE);
306+
// ── Images ──────────────────────────────────────────────────────────────────
307+
// Matched here, before the shared inline pass, which has no image rule: an
308+
// image is a request the viewer's browser makes to a third party, so the rule
309+
// stays this renderer's. A destination may hold one level of balanced parentheses.
310+
311+
const DESTINATION = '((?:[^()\\s]|\\([^()\\s]*\\))+)';
312+
const IMAGE = `!\\[([^\\]]*)\\]\\(${DESTINATION}(?:\\s+"[^"]*")?\\)`;
313+
/** A linked image, `[![alt](src)](href)`, or a bare image, `![alt](src)`; a code span is matched so it is skipped. */
314+
const IMAGE_OR_CODE = new RegExp(`(?<!\`)\`[^\`]+\`(?!\`)|\\[${IMAGE}\\]\\(${DESTINATION}\\)|${IMAGE}`);
315+
316+
/** An absolute URL with the given schemes, or `null`: a relative path resolves against the console, not the repository. */
317+
function absoluteUrl(raw: string, schemes: readonly string[]): string | null {
318+
// Browsers strip whitespace and control characters before reading a scheme, so either refuses the URL.
319+
if (/\s/.test(raw) || [...raw].some((ch) => ch.charCodeAt(0) <= 0x1f || ch.charCodeAt(0) === 0x7f)) return null;
320+
try {
321+
return schemes.includes(new URL(raw).protocol) ? raw : null;
322+
} catch {
323+
return null;
324+
}
325+
}
326+
327+
function readmeImage(alt: string, rawSrc: string): ReactNode {
328+
const src = absoluteUrl(rawSrc, ['https:']);
329+
if (src === null) return null;
330+
return (
331+
<img
332+
src={src}
333+
alt={alt}
334+
loading="lazy"
335+
decoding="async"
336+
referrerPolicy="no-referrer"
337+
className="inline-block h-auto max-w-full align-middle"
338+
/>
339+
);
340+
}
341+
342+
function renderInline(input: string): ReactNode {
343+
const out: ReactNode[] = [];
344+
let text = '';
345+
let remaining = input;
346+
for (let m = IMAGE_OR_CODE.exec(remaining); m; m = IMAGE_OR_CODE.exec(remaining)) {
347+
const [whole, linkedAlt, linkedSrc, href, alt, src] = m;
348+
text += remaining.slice(0, m.index);
349+
remaining = remaining.slice(m.index + whole.length);
350+
if (whole.startsWith('`')) {
351+
text += whole;
352+
continue;
353+
}
354+
if (text) out.push(renderInlineMarkdown(text, README_INLINE));
355+
text = '';
356+
if (linkedSrc === undefined) {
357+
out.push(readmeImage(alt, src));
358+
continue;
359+
}
360+
const image = readmeImage(linkedAlt, linkedSrc);
361+
const target = image === null ? null : absoluteUrl(href, ['https:', 'http:']);
362+
out.push(
363+
target === null ? image : (
364+
<a href={target} target="_blank" rel="noopener noreferrer" className={README_INLINE.classNames.link}>{image}</a>
365+
),
366+
);
367+
}
368+
text += remaining;
369+
if (text) out.push(renderInlineMarkdown(text, README_INLINE));
370+
return <>{out.map((n, i) => <Fragment key={i}>{n}</Fragment>)}</>;
223371
}
224372

225373
/**
@@ -247,7 +395,13 @@ const SIZE_CLASSES: Record<MarkdownSize, { root: string; stack: string; h1: stri
247395
},
248396
};
249397

250-
function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] {
398+
const ALIGN_CLASSES: Record<NonNullable<TableAlign>, string> = {
399+
left: 'text-left',
400+
center: 'text-center',
401+
right: 'text-right',
402+
};
403+
404+
function renderBlocks(blocks: Block[], size: MarkdownSize): ReactNode[] {
251405
const cls = SIZE_CLASSES[size];
252406
return blocks.map((b, i) => {
253407
switch (b.type) {
@@ -265,7 +419,7 @@ function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] {
265419
{(b.items ?? []).map((it, j) => (
266420
<li key={j} className="flex gap-2">
267421
<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>
422+
<span className="min-w-0">{renderInline(it)}</span>
269423
</li>
270424
))}
271425
</ul>
@@ -284,6 +438,34 @@ function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] {
284438
<code className="font-mono">{b.text}</code>
285439
</pre>
286440
);
441+
case 'table': {
442+
const { head, rows, align } = b.table!;
443+
const alignClass = (c: number) => ALIGN_CLASSES[align[c] ?? 'left'];
444+
return (
445+
<div key={i} className="overflow-x-auto rounded-lg border">
446+
<table className="w-full border-collapse">
447+
<thead className="bg-muted/40">
448+
<tr>
449+
{head.map((cell, c) => (
450+
<th key={c} className={`border-b px-3 py-2 font-semibold text-foreground ${alignClass(c)}`}>{renderInline(cell)}</th>
451+
))}
452+
</tr>
453+
</thead>
454+
<tbody>
455+
{rows.map((row, r) => (
456+
<tr key={r} className="border-b last:border-b-0">
457+
{row.map((cell, c) => (
458+
<td key={c} className={`px-3 py-2 align-top ${alignClass(c)}`}>{renderInline(cell)}</td>
459+
))}
460+
</tr>
461+
))}
462+
</tbody>
463+
</table>
464+
</div>
465+
);
466+
}
467+
case 'hr':
468+
return <hr key={i} className="border-border" />;
287469
case 'quote':
288470
return (
289471
<blockquote key={i} className={`flex flex-col ${cls.stack} border-l-2 border-violet-500/40 pl-4 text-muted-foreground`}>
@@ -301,7 +483,7 @@ function renderBlocks(blocks: Block[], size: MarkdownSize): React.ReactNode[] {
301483
export function MarkdownText({ source, className, size = 'base' }: { source: string; className?: string; size?: MarkdownSize }) {
302484
const cls = SIZE_CLASSES[size];
303485
return (
304-
<div className={`flex flex-col ${cls.stack} ${cls.root} ${className ?? ''}`}>
486+
<div className={`flex min-w-0 flex-col break-words ${cls.stack} ${cls.root} ${className ?? ''}`}>
305487
{renderBlocks(parseBlocks(source), size)}
306488
</div>
307489
);

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,8 +1402,11 @@ export function MarketplacePackagePage() {
14021402

14031403
{localResultNote(pkg.manifest_id)}
14041404

1405+
{/* `min-w-0` on each cell: a grid item's automatic minimum is its content's
1406+
width, so a long line in the README would widen the page past its edge
1407+
instead of scrolling inside its own block. */}
14051408
<div className="grid gap-4 lg:grid-cols-3">
1406-
<div className="lg:col-span-2 space-y-4">
1409+
<div className="min-w-0 lg:col-span-2 space-y-4">
14071410
<Card>
14081411
<CardHeader>
14091412
<CardTitle className="text-base">{t('marketplace.detail.about')}</CardTitle>
@@ -1417,7 +1420,7 @@ export function MarketplacePackagePage() {
14171420
</CardContent>
14181421
</Card>
14191422
</div>
1420-
<div className="space-y-4">
1423+
<div className="min-w-0 space-y-4">
14211424
<Card>
14221425
<CardHeader>
14231426
<CardTitle className="text-base">{t('marketplace.detail.versions')}</CardTitle>

0 commit comments

Comments
 (0)