Skip to content

Commit 309898e

Browse files
committed
fix(ui): review findings — flags, alerts, forges, tabs, anchors, URL brackets
Six targeted fixes from the v0.19 PR review. Each is small and scoped; the riskier items from the review (plan-diff block variants, HTML relative non-doc links) are tracked as follow-ups. Smart punctuation — CLI flags preserved: - Narrowed the `--` → en-dash rule to only fire between digits (`pages 3--5` still converts; `bun --watch` stays literal). GitHub alerts — list/code/heading bodies absorb correctly: - Blockquote merge now always merges into a previous alert blockquote, regardless of whether the new line starts with a block marker. Without this, `> [!NOTE]\n> - item` split the list off into a plain italic quote and emptied the alert. - AlertBlock got a mini block-level renderer for the body so `- item` / `* item` / `1. item` render as real <ul>/<ol>, not flattened prose. Forge-aware mentions/issue refs: - packages/shared/repo: new parseRemoteHost() extracts the host from the git remote URL; RepoInfo gains an optional `host` field. - packages/server/repo: getRepoInfo populates host alongside display. - Viewer only passes githubRepo to InlineMarkdown when the host is exactly "github.com". Non-GitHub repos render mentions/issue refs as styled text, no wrong github.com links. HTML block external links: - rewriteRelativeRefs now forces `target="_blank"` and `rel="noopener noreferrer"` on every external http(s) link inside raw HTML. Fixes two problems in one pass: external links no longer hijack the review tab, and pasted-HTML links can't tab-nab the plannotator tab via window.opener. Heading anchor dedup: - New buildHeadingSlugMap() walks all heading blocks and assigns `foo`, `foo-1`, `foo-2`, ... for repeats (GitHub convention). BlockRenderer receives the anchor id as a prop from Viewer via a memoized map rather than computing per-block; first occurrence keeps the bare slug so existing links stay stable. URL autolink bracket balance: - Trailing `)`/`]`/`}` in bare URLs are kept when they balance an earlier opener inside the URL. Wikipedia-style `https://en.wikipedia.org/wiki/Function_(mathematics)` now keeps its paren; `(see https://x.com)` still trims the orphan. Tests: +8 (157 total). - utils/slugify.test: buildHeadingSlugMap dedup behavior, non-heading skipping, empty-slug skipping. - utils/inlineTransforms.test: CLI flags stay literal, `3--5` still converts. - utils/parser.test: alerts with list body / code fence body, blank line ending an alert. Fixture: - tests/test-fixtures/13-known-issues.md — reproduces each of the review findings end-to-end; useful as a regression check going forward. Deferred (tracked for follow-up): - Plan diff view doesn't render html / directive / alertKind semantics (SimpleBlockRenderer has no cases for the new block variants). - Relative non-doc links inside raw HTML (.pdf, .csv) don't get rewritten — only .md/.mdx/.html are routed through the linked-doc overlay today. Not a regression; narrow audience. For provenance purposes, this commit was AI assisted.
1 parent e2bd1d0 commit 309898e

15 files changed

Lines changed: 396 additions & 42 deletions

File tree

packages/editor/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const App: React.FC = () => {
161161
const [sharingEnabled, setSharingEnabled] = useState(true);
162162
const [shareBaseUrl, setShareBaseUrl] = useState<string | undefined>(undefined);
163163
const [pasteApiUrl, setPasteApiUrl] = useState<string | undefined>(undefined);
164-
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
164+
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string; host?: string } | null>(null);
165165
const [projectRoot, setProjectRoot] = useState<string | null>(null);
166166
const [wideModeType, setWideModeType] = useState<WideModeType | null>(null);
167167
const wideModeSnapshotRef = useRef<WideModeLayoutSnapshot | null>(null);
@@ -642,7 +642,7 @@ const App: React.FC = () => {
642642
if (!res.ok) throw new Error('Not in API mode');
643643
return res.json();
644644
})
645-
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => {
645+
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string; host?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => {
646646
// Initialize config store with server-provided values (config file > cookie > default)
647647
configStore.init(data.serverConfig);
648648
// gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable

packages/server/repo.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { $ } from "bun";
99

1010
import type { RepoInfo } from "@plannotator/shared/repo";
11-
import { parseRemoteUrl, getDirName } from "@plannotator/shared/repo";
11+
import { parseRemoteUrl, parseRemoteHost, getDirName } from "@plannotator/shared/repo";
1212

1313
/**
1414
* Get current git branch
@@ -40,10 +40,12 @@ export async function getRepoInfo(): Promise<RepoInfo | null> {
4040
try {
4141
const result = await $`git remote get-url origin`.quiet().nothrow();
4242
if (result.exitCode === 0) {
43-
const orgRepo = parseRemoteUrl(result.stdout.toString().trim());
43+
const remoteUrl = result.stdout.toString().trim();
44+
const orgRepo = parseRemoteUrl(remoteUrl);
4445
if (orgRepo) {
4546
branch = await getCurrentBranch();
46-
return { display: orgRepo, branch };
47+
const host = parseRemoteHost(remoteUrl) ?? undefined;
48+
return { display: orgRepo, branch, host };
4749
}
4850
}
4951
} catch {

packages/shared/repo.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ export interface RepoInfo {
33
display: string;
44
/** Current git branch (if in a git repo) */
55
branch?: string;
6+
/** Host of the git remote (e.g., "github.com", "gitlab.com"). Populated */
7+
/** only when the remote URL is parseable; absent for directory-only fallbacks. */
8+
host?: string;
69
}
710

811
/**
@@ -35,6 +38,28 @@ export function parseRemoteUrl(url: string): string | null {
3538
return null;
3639
}
3740

41+
/**
42+
* Parse the host from a git remote URL. Returns null when the shape
43+
* doesn't match a known remote form. Used to identify the forge
44+
* (github.com, gitlab.com, self-hosted) so inline mention / issue
45+
* refs can link to the correct destination instead of assuming GitHub.
46+
*/
47+
export function parseRemoteHost(url: string): string | null {
48+
if (!url) return null;
49+
// ssh://git@host:port/path
50+
const sshPort = url.match(/^ssh:\/\/(?:[^@]+@)?([^:/]+)/i);
51+
if (sshPort) return sshPort[1];
52+
// git@host:path
53+
if (!url.includes('://')) {
54+
const ssh = url.match(/^[^@\s]+@([^:\s]+):/);
55+
if (ssh) return ssh[1];
56+
}
57+
// https://host/path or http://host/path
58+
const https = url.match(/^https?:\/\/([^/:]+)/i);
59+
if (https) return https[1];
60+
return null;
61+
}
62+
3863
/**
3964
* Get directory name from path
4065
*/

packages/ui/components/BlockRenderer.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from "react";
22
import { Block } from "../types";
3-
import { slugifyHeading } from "../utils/slugify";
43
import { InlineMarkdown } from "./InlineMarkdown";
54
import { ListMarker } from "./ListMarker";
65
import { CodeBlock } from "./blocks/CodeBlock";
@@ -18,7 +17,8 @@ export const BlockRenderer: React.FC<{
1817
checkboxOverrides?: Map<string, boolean>;
1918
orderedIndex?: number | null;
2019
githubRepo?: string;
21-
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo }) => {
20+
headingAnchorId?: string;
21+
}> = ({ block, onOpenLinkedDoc, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId }) => {
2222
switch (block.type) {
2323
case 'heading': {
2424
const Tag = `h${block.level || 1}` as React.ElementType;
@@ -27,9 +27,7 @@ export const BlockRenderer: React.FC<{
2727
2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
2828
3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
2929
}[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
30-
const anchorId = slugifyHeading(block.content) || undefined;
31-
32-
return <Tag id={anchorId} className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} /></Tag>;
30+
return <Tag id={headingAnchorId} className={styles} data-block-id={block.id} data-block-type="heading"><InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} githubRepo={githubRepo} /></Tag>;
3331
}
3432

3533
case 'blockquote': {

packages/ui/components/InlineMarkdown.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,28 @@ export const InlineMarkdown: React.FC<{
3939
}
4040

4141
// Bare URL autolink: https://… preceded by word boundary.
42-
// Trailing sentence punctuation is excluded so "See https://x.com." renders the period outside the link.
42+
// Trailing sentence punctuation is trimmed so "See https://x.com."
43+
// renders the period outside the link. Closing brackets are kept when
44+
// they balance an earlier opener inside the URL (e.g. Wikipedia's
45+
// https://…/Function_(mathematics) keeps its trailing paren).
4346
if (!/\w/.test(previousChar)) {
44-
const bareMatch = remaining.match(/^https?:\/\/[^\s<>\]"']+/);
47+
const bareMatch = remaining.match(/^https?:\/\/[^\s<>"']+/);
4548
if (bareMatch) {
4649
let url = bareMatch[0];
47-
while (url.length > 0 && /[.,;:!?)\]}>"']/.test(url[url.length - 1])) {
50+
const balanced = (u: string, close: string, open: string): boolean => {
51+
let opens = 0, closes = 0;
52+
for (const c of u) {
53+
if (c === open) opens++;
54+
else if (c === close) closes++;
55+
}
56+
return opens >= closes;
57+
};
58+
while (url.length > 0) {
59+
const last = url[url.length - 1];
60+
if (!/[.,;:!?)\]}>"']/.test(last)) break;
61+
if (last === ')' && balanced(url, ')', '(')) break;
62+
if (last === ']' && balanced(url, ']', '[')) break;
63+
if (last === '}' && balanced(url, '}', '{')) break;
4864
url = url.slice(0, -1);
4965
}
5066
const safe = url.length > 0 ? sanitizeLinkUrl(url) : null;

packages/ui/components/Viewer.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React, { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from 'react';
1+
import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
22
import { createPortal } from 'react-dom';
33
import hljs from 'highlight.js';
44
import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
55
import { Frontmatter, computeListIndices } from '../utils/parser';
6+
import { buildHeadingSlugMap } from '../utils/slugify';
67
import { BlockRenderer } from './BlockRenderer';
78
import { CodeBlock } from './blocks/CodeBlock';
89
import { TableBlock } from './blocks/TableBlock';
@@ -59,7 +60,7 @@ interface ViewerProps {
5960
globalAttachments?: ImageAttachment[];
6061
onAddGlobalAttachment?: (image: ImageAttachment) => void;
6162
onRemoveGlobalAttachment?: (path: string) => void;
62-
repoInfo?: { display: string; branch?: string } | null;
63+
repoInfo?: { display: string; branch?: string; host?: string } | null;
6364
stickyActions?: boolean;
6465
onOpenLinkedDoc?: (path: string) => void;
6566
imageBaseDir?: string;
@@ -174,6 +175,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
174175
}
175176
};
176177
const containerRef = useRef<HTMLDivElement>(null);
178+
// Per-doc heading slug map with dedup — computed once per blocks array so
179+
// anchor ids stay stable across re-renders and duplicate heading texts get
180+
// `-1`/`-2`/... suffixes rather than colliding on the same id.
181+
const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
177182
const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
178183
const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
179184
const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null);
@@ -556,7 +561,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
556561
onOpenLinkedDoc={onOpenLinkedDoc}
557562
onToggleCheckbox={onToggleCheckbox}
558563
checkboxOverrides={checkboxOverrides}
559-
githubRepo={repoInfo?.display}
564+
githubRepo={repoInfo?.host === 'github.com' ? repoInfo?.display : undefined}
565+
headingAnchorId={headingSlugMap.get(block.id)}
560566
/>
561567
))}
562568
</div>
@@ -573,7 +579,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
573579
imageBaseDir={imageBaseDir}
574580
onImageClick={(src, alt) => setLightbox({ src, alt })}
575581
onOpenLinkedDoc={onOpenLinkedDoc}
576-
githubRepo={repoInfo?.display}
582+
githubRepo={repoInfo?.host === 'github.com' ? repoInfo?.display : undefined}
577583
onHover={(element) => {
578584
if (tableHoverTimeoutRef.current) {
579585
clearTimeout(tableHoverTimeoutRef.current);
@@ -625,7 +631,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
625631
isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
626632
/>
627633
) : (
628-
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} />
634+
<BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.host === 'github.com' ? repoInfo?.display : undefined} headingAnchorId={headingSlugMap.get(group.block.id)} />
629635
)
630636
)}
631637

@@ -721,7 +727,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
721727
imageBaseDir={imageBaseDir}
722728
onImageClick={(src, alt) => setLightbox({ src, alt })}
723729
onOpenLinkedDoc={onOpenLinkedDoc}
724-
githubRepo={repoInfo?.display}
730+
githubRepo={repoInfo?.host === 'github.com' ? repoInfo?.display : undefined}
725731
/>
726732
)}
727733

packages/ui/components/blocks/AlertBlock.tsx

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
3939
export const AlertBlock: React.FC<AlertBlockProps> = ({
4040
blockId, kind, body, onOpenLinkedDoc, imageBaseDir, onImageClick, githubRepo,
4141
}) => {
42-
const paragraphs = body.split(/\n\n+/);
4342
return (
4443
<div
4544
className={`alert alert-${kind} my-4 pl-4 pr-3 py-2 border-l-[3px]`}
@@ -51,19 +50,87 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
5150
<Icon kind={kind} />
5251
<span>{TITLE[kind]}</span>
5352
</div>
54-
{paragraphs.map((para, i) =>
55-
para ? (
56-
<p key={i} className={`text-[15px] leading-relaxed text-foreground/90 ${i > 0 ? 'mt-2' : ''}`}>
57-
<InlineMarkdown
58-
imageBaseDir={imageBaseDir}
59-
onImageClick={onImageClick}
60-
text={para}
61-
onOpenLinkedDoc={onOpenLinkedDoc}
62-
githubRepo={githubRepo}
63-
/>
64-
</p>
65-
) : null,
66-
)}
53+
{renderAlertBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo })}
6754
</div>
6855
);
6956
};
57+
58+
// Lightweight block-level renderer for alert bodies. Handles paragraphs,
59+
// unordered lists, and ordered lists — the shapes GitHub alerts commonly
60+
// carry. More exotic content (headings, tables, code fences, nested
61+
// alerts) falls back to inline text; add cases here when a real use
62+
// case turns up.
63+
function renderAlertBody(args: {
64+
body: string;
65+
imageBaseDir?: string;
66+
onImageClick?: (src: string, alt: string) => void;
67+
onOpenLinkedDoc?: (path: string) => void;
68+
githubRepo?: string;
69+
}): React.ReactNode {
70+
const { body, imageBaseDir, onImageClick, onOpenLinkedDoc, githubRepo } = args;
71+
const inline = (text: string) => (
72+
<InlineMarkdown
73+
imageBaseDir={imageBaseDir}
74+
onImageClick={onImageClick}
75+
text={text}
76+
onOpenLinkedDoc={onOpenLinkedDoc}
77+
githubRepo={githubRepo}
78+
/>
79+
);
80+
81+
const lines = body.split('\n');
82+
const out: React.ReactNode[] = [];
83+
let paraLines: string[] = [];
84+
let list: { ordered: boolean; items: string[] } | null = null;
85+
let key = 0;
86+
87+
const flushPara = () => {
88+
if (paraLines.length === 0) return;
89+
const text = paraLines.join(' ');
90+
if (text.trim()) {
91+
out.push(
92+
<p key={`p-${key++}`} className={`text-[15px] leading-relaxed text-foreground/90 ${out.length > 0 ? 'mt-2' : ''}`}>
93+
{inline(text)}
94+
</p>,
95+
);
96+
}
97+
paraLines = [];
98+
};
99+
const flushList = () => {
100+
if (!list) return;
101+
const Tag = list.ordered ? 'ol' : 'ul';
102+
const className = `${list.ordered ? 'list-decimal' : 'list-disc'} pl-5 text-[15px] leading-relaxed text-foreground/90 ${out.length > 0 ? 'mt-2' : ''}`;
103+
out.push(
104+
<Tag key={`l-${key++}`} className={className}>
105+
{list.items.map((item, i) => (
106+
<li key={i} className="my-0.5">{inline(item)}</li>
107+
))}
108+
</Tag>,
109+
);
110+
list = null;
111+
};
112+
113+
for (const line of lines) {
114+
if (line.trim() === '') {
115+
flushPara();
116+
flushList();
117+
continue;
118+
}
119+
const listMatch = line.match(/^\s*(\*|-|\d+\.)\s+(.*)$/);
120+
if (listMatch) {
121+
flushPara();
122+
const ordered = /\d/.test(listMatch[1]);
123+
if (!list || list.ordered !== ordered) {
124+
flushList();
125+
list = { ordered, items: [] };
126+
}
127+
list.items.push(listMatch[2]);
128+
} else {
129+
flushList();
130+
paraLines.push(line);
131+
}
132+
}
133+
flushPara();
134+
flushList();
135+
return out;
136+
}

packages/ui/components/blocks/HtmlBlock.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ function rewriteRelativeRefs(
3333
root.querySelectorAll('a').forEach((a) => {
3434
const href = a.getAttribute('href');
3535
if (!href) return;
36-
if (/^(https?:|mailto:|tel:|#)/i.test(href)) return;
36+
// External http(s) links: open in a new tab and close the tab-nabbing
37+
// vector (opener reference back to the plannotator tab). Matches the
38+
// markdown renderer's behavior for [label](https://...).
39+
if (/^https?:/i.test(href)) {
40+
a.setAttribute('target', '_blank');
41+
a.setAttribute('rel', 'noopener noreferrer');
42+
return;
43+
}
44+
if (/^(mailto:|tel:|#)/i.test(href)) return;
3745
if (onOpenLinkedDoc && /\.(mdx?|html?)(#.*)?$/i.test(href)) {
3846
const handler = (e: Event) => {
3947
e.preventDefault();

packages/ui/utils/inlineTransforms.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,16 @@ describe('transformPlainText — smart punctuation', () => {
2424
expect(transformPlainText('before --- after')).toBe('before — after');
2525
});
2626

27-
test('converts double hyphen to en dash', () => {
27+
test('converts double hyphen to en dash between digits', () => {
2828
expect(transformPlainText('pages 3--5')).toBe('pages 3–5');
2929
});
3030

31+
test('leaves CLI flags alone', () => {
32+
expect(transformPlainText('bun --watch')).toBe('bun --watch');
33+
expect(transformPlainText('claude-code --model opus-4')).toBe('claude-code --model opus-4');
34+
expect(transformPlainText('see --help')).toBe('see --help');
35+
});
36+
3137
test('curls straight double quotes', () => {
3238
expect(transformPlainText('she said "hello"')).toBe('she said “hello”');
3339
});

packages/ui/utils/inlineTransforms.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ function smartypants(s: string): string {
2424
return s
2525
.replace(/\.{3}/g, '…')
2626
.replace(/---/g, '—')
27-
.replace(/(^|[^-])--(?!-)/g, '$1–')
27+
// Narrow en-dash rule to numeric ranges (e.g. "pages 3--5" → "3–5").
28+
// Previously matched any non-hyphen context, which rewrote CLI flags
29+
// like "bun --watch" into "bun –watch". Letter-to-letter en-dashes
30+
// are rare in technical writing; we accept losing them to avoid the
31+
// false positive on command-line arguments.
32+
.replace(/(\d)--(?=\d)/g, '$1–')
2833
.replace(/(^|[\s([{])"/g, '$1“')
2934
.replace(/"/g, '”')
3035
.replace(/(^|[\s([{])'/g, '$1‘')

0 commit comments

Comments
 (0)