mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): keep trailing punctuation out of bare chat URLs (#2678)
* fix(web): keep trailing punctuation out of bare chat URLs (#2675) * test(web): broaden bare URL punctuation regression coverage Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * test(web): broaden bare URL punctuation regression coverage Generated-By: looper 0.6.0 (runner=fixer, agent=codex) * test(e2e): harden smoke suite tools-dev port startup Generated-By: looper 0.6.0 (runner=fixer, agent=codex)
This commit is contained in:
parent
aa0616062d
commit
7fd0169888
4 changed files with 126 additions and 14 deletions
|
|
@ -350,17 +350,19 @@ function renderInline(text: string): ReactNode {
|
|||
} else if (m[6]) {
|
||||
// Bare URL — autolink with the URL as both href and visible text,
|
||||
// matching the Markdown `<https://…>` autolink convention.
|
||||
const [href, suffix] = splitTrailingAutolinkPunctuation(m[6]);
|
||||
out.push(
|
||||
<a
|
||||
key={key++}
|
||||
className="md-link md-link-bare"
|
||||
href={m[6]}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{m[6]}
|
||||
{href}
|
||||
</a>,
|
||||
);
|
||||
if (suffix) pushText(out, suffix, key++);
|
||||
} else if (m[7]) {
|
||||
out.push(<strong key={key++}>{m[7].slice(2, -2)}</strong>);
|
||||
} else if (m[8]) {
|
||||
|
|
@ -393,17 +395,21 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
|
|||
if (m.index > lastIndex) {
|
||||
segments.push(...withBreaks(text.slice(lastIndex, m.index), `${baseKey}-${k++}`));
|
||||
}
|
||||
const [href, suffix] = splitTrailingAutolinkPunctuation(m[1]!);
|
||||
segments.push(
|
||||
<a
|
||||
key={`${baseKey}-${k++}`}
|
||||
className="md-link"
|
||||
href={m[1]}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{m[1]}
|
||||
{href}
|
||||
</a>,
|
||||
);
|
||||
if (suffix) {
|
||||
segments.push(...withBreaks(suffix, `${baseKey}-${k++}`));
|
||||
}
|
||||
lastIndex = urlRe.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
|
|
@ -412,6 +418,13 @@ function pushText(out: ReactNode[], text: string, baseKey: number): void {
|
|||
out.push(<Fragment key={baseKey}>{segments}</Fragment>);
|
||||
}
|
||||
|
||||
function splitTrailingAutolinkPunctuation(url: string): [string, string] {
|
||||
const match = /([.,!?;:,。!?;:、'"」』】》〉)]+)$/.exec(url);
|
||||
if (!match || !match[1]) return [url, ''];
|
||||
const trimmed = url.slice(0, -match[1].length);
|
||||
return trimmed ? [trimmed, match[1]] : [url, ''];
|
||||
}
|
||||
|
||||
function withBreaks(text: string, baseKey: string): ReactNode[] {
|
||||
const parts = text.split('\n');
|
||||
const out: ReactNode[] = [];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,62 @@ describe('renderMarkdown', () => {
|
|||
expect(out).toContain('md-link-bare');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'ascii comma',
|
||||
input: 'Open https://example.com/demo, please.',
|
||||
href: 'https://example.com/demo',
|
||||
rendered: '>https://example.com/demo</a>,',
|
||||
excludedHref: 'https://example.com/demo,',
|
||||
},
|
||||
{
|
||||
name: 'fullwidth full stop',
|
||||
input: 'Visit https://example.com/final。',
|
||||
href: 'https://example.com/final',
|
||||
rendered: '>https://example.com/final</a>。',
|
||||
excludedHref: 'https://example.com/final。',
|
||||
},
|
||||
{
|
||||
name: 'wrapped in fullwidth parens',
|
||||
input: '(https://example.com/a)',
|
||||
href: 'https://example.com/a',
|
||||
rendered: '(<a class="md-link md-link-bare" href="https://example.com/a"',
|
||||
trailing: '>https://example.com/a</a>)',
|
||||
excludedHref: 'https://example.com/a)',
|
||||
},
|
||||
{
|
||||
name: 'lone trailing CJK quote',
|
||||
input: 'https://example.com/b」',
|
||||
href: 'https://example.com/b',
|
||||
rendered: '>https://example.com/b</a>」',
|
||||
excludedHref: 'https://example.com/b」',
|
||||
},
|
||||
{
|
||||
name: 'stacked CJK punctuation',
|
||||
input: 'https://example.com/c。)',
|
||||
href: 'https://example.com/c',
|
||||
rendered: '>https://example.com/c</a>。)',
|
||||
excludedHref: 'https://example.com/c。)',
|
||||
},
|
||||
{
|
||||
name: 'no trailing punctuation',
|
||||
input: 'https://example.com/path',
|
||||
href: 'https://example.com/path',
|
||||
rendered: '>https://example.com/path</a>',
|
||||
excludedHref: '',
|
||||
},
|
||||
])('keeps bare autolink punctuation handling stable: $name', ({ input, href, rendered, excludedHref, trailing }) => {
|
||||
const out = html(input);
|
||||
expect(out).toContain(`href="${href}"`);
|
||||
expect(out).toContain(rendered);
|
||||
if (trailing) {
|
||||
expect(out).toContain(trailing);
|
||||
}
|
||||
if (excludedHref) {
|
||||
expect(out).not.toContain(`href="${excludedHref}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not autolink inside inline code spans', () => {
|
||||
const out = html('Use `https://example.com/x` literally.');
|
||||
// The URL should appear inside a <code> tag, not turned into an anchor.
|
||||
|
|
|
|||
|
|
@ -128,14 +128,24 @@ async function runToolsDevSuite(
|
|||
options: ToolsDevSuiteOptions = {},
|
||||
): Promise<string> {
|
||||
const toolsDev = await import('./tools-dev.ts');
|
||||
const runtime = await toolsDev.allocateToolsDevRuntime();
|
||||
let runtime = await toolsDev.allocateToolsDevRuntime();
|
||||
let context: ToolsDevSuiteContext | null = null;
|
||||
let diagnostics: unknown = null;
|
||||
let caughtError: unknown = null;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
const start = await toolsDev.startToolsDevWeb(suite, runtime);
|
||||
let start: ToolsDevStartResult | null = null;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
start = await toolsDev.startToolsDevWeb(suite, runtime);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 3 || !toolsDev.isToolsDevPortConflict(error)) throw error;
|
||||
runtime = await toolsDev.allocateToolsDevRuntime();
|
||||
}
|
||||
}
|
||||
if (start == null) throw new Error('tools-dev start did not return a result');
|
||||
const webUrl = assertRuntimeUrl(start.web?.status.url, 'web');
|
||||
const status = await toolsDev.inspectToolsDevStatus(suite);
|
||||
assertToolsDevStatus(suite, status);
|
||||
|
|
|
|||
|
|
@ -56,16 +56,32 @@ export type ToolsDevCheckResult = {
|
|||
|
||||
export type ToolsDevRuntime = {
|
||||
daemonPort: number;
|
||||
release: () => Promise<void>;
|
||||
webPort: number;
|
||||
};
|
||||
|
||||
export async function allocateToolsDevRuntime(): Promise<ToolsDevRuntime> {
|
||||
const [daemonPort, webPort] = await Promise.all([findFreePort(), findFreePort()]);
|
||||
if (daemonPort === webPort) return await allocateToolsDevRuntime();
|
||||
return { daemonPort, webPort };
|
||||
const [daemonPort, webPort] = await Promise.all([reserveFreePort(), reserveFreePort()]);
|
||||
if (daemonPort.port === webPort.port) {
|
||||
await Promise.all([daemonPort.release(), webPort.release()]);
|
||||
return await allocateToolsDevRuntime();
|
||||
}
|
||||
let released = false;
|
||||
return {
|
||||
daemonPort: daemonPort.port,
|
||||
webPort: webPort.port,
|
||||
async release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
await Promise.all([daemonPort.release(), webPort.release()]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startToolsDevWeb(suite: SmokeSuite, runtime: ToolsDevRuntime): Promise<ToolsDevStartResult> {
|
||||
// Keep both ports reserved until immediately before tools-dev starts so
|
||||
// parallel Vitest workers do not race each other for the same "free" port.
|
||||
await runtime.release();
|
||||
return await runToolsDevJson<ToolsDevStartResult>(
|
||||
suite,
|
||||
[
|
||||
|
|
@ -141,6 +157,13 @@ export async function readToolsDevLogs(suite: SmokeSuite): Promise<Record<string
|
|||
);
|
||||
}
|
||||
|
||||
export function isToolsDevPortConflict(error: unknown): boolean {
|
||||
const text = error instanceof Error
|
||||
? `${error.message}\n${error.stack ?? ''}`
|
||||
: String(error);
|
||||
return text.includes('EADDRINUSE');
|
||||
}
|
||||
|
||||
async function runToolsDevJson<T>(suite: SmokeSuite, args: string[]): Promise<T> {
|
||||
const useNpmExecPathWithNode = process.env.OD_E2E_PNPM_COMMAND == null
|
||||
&& pnpmExecPath != null
|
||||
|
|
@ -179,18 +202,28 @@ function parseJsonOutput<T>(stdout: string): T {
|
|||
return JSON.parse(stdout.slice(jsonStart + 1)) as T;
|
||||
}
|
||||
|
||||
async function findFreePort(): Promise<number> {
|
||||
async function reserveFreePort(): Promise<{ port: number; release: () => Promise<void> }> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once('error', rejectListen);
|
||||
server.listen(0, '127.0.0.1', () => resolveListen());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
|
||||
});
|
||||
if (address == null || typeof address === 'string') {
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
|
||||
});
|
||||
throw new Error('failed to allocate a local TCP port');
|
||||
}
|
||||
return address.port;
|
||||
let released = false;
|
||||
return {
|
||||
port: address.port,
|
||||
async release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue