ework-web 0.10.94 → 0.10.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/render/markdown.ts +31 -2
- package/src/views/issueThread.ts +26 -8
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1850,7 +1850,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1850
1850
|
login: c.author,
|
|
1851
1851
|
avatar: "",
|
|
1852
1852
|
created_at: c.created_at,
|
|
1853
|
-
body_html: renderMarkdown(c.body),
|
|
1853
|
+
body_html: renderMarkdown(c.body, "", `/${owner}/${repo}/issues/${issue.number}`),
|
|
1854
1854
|
};
|
|
1855
1855
|
}
|
|
1856
1856
|
let closed = false;
|
package/src/render/markdown.ts
CHANGED
|
@@ -33,11 +33,40 @@ const PURIFY_OPTS = {
|
|
|
33
33
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|\.\/|\.\.\/|#)/i,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
export function renderMarkdown(body: string, baseDir = ""): string {
|
|
36
|
+
export function renderMarkdown(body: string, baseDir = "", baseIssuePath = ""): string {
|
|
37
37
|
const dirty = marked.parse(body ?? "");
|
|
38
38
|
const html = typeof dirty === "string" ? dirty : "";
|
|
39
39
|
const clean = purify.sanitize(html, PURIFY_OPTS) as unknown as string;
|
|
40
|
-
return linkifyAbsPaths(linkifySessionIDs(clean), baseDir);
|
|
40
|
+
return linkifyIssueRefs(linkifyAbsPaths(linkifySessionIDs(clean), baseDir), baseIssuePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Linkify #N issue references inside rendered issue content. Only applied
|
|
44
|
+
// when the caller supplies the current issue's path — same-repo shorthand
|
|
45
|
+
// (#204) resolves against it. Skips tag interiors and existing anchors,
|
|
46
|
+
// same token-scanning approach as linkifySessionIDs.
|
|
47
|
+
export function linkifyIssueRefs(html: string, baseIssuePath = ""): string {
|
|
48
|
+
if (!baseIssuePath) return html;
|
|
49
|
+
const issuePath = baseIssuePath.replace(/\/+$/, "");
|
|
50
|
+
let out = "";
|
|
51
|
+
let inA = false;
|
|
52
|
+
let inCode = false;
|
|
53
|
+
const re = /(<[^>]*>)|([^<]+)/g;
|
|
54
|
+
let m: RegExpExecArray | null;
|
|
55
|
+
while ((m = re.exec(html)) !== null) {
|
|
56
|
+
if (m[1] !== undefined) {
|
|
57
|
+
const tag = m[1];
|
|
58
|
+
if (/^<a[\s>]/i.test(tag)) inA = true;
|
|
59
|
+
else if (/^<\/a[\s>]/i.test(tag)) inA = false;
|
|
60
|
+
else if (/^<pre[\s>]/i.test(tag) || /^<code[\s>]/i.test(tag)) inCode = true;
|
|
61
|
+
else if (/^<\/pre[\s>]/i.test(tag) || /^<\/code[\s>]/i.test(tag)) inCode = false;
|
|
62
|
+
out += tag;
|
|
63
|
+
} else if (m[2] !== undefined) {
|
|
64
|
+
out += inA || inCode
|
|
65
|
+
? m[2]
|
|
66
|
+
: m[2].replace(/(^|[^\w#])#([1-9][0-9]{0,4})(?![0-9\w])/g, (_all, pre: string, num: string) => `${pre}<a href="${issuePath.replace(/\/issues\/\d+$/, "")}/issues/${num}">#${num}</a>`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
// Linkify ses_ IDs in text nodes only — skips tag interiors (don't corrupt
|
package/src/views/issueThread.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
listCachedModels,
|
|
18
18
|
type CommentRow,
|
|
19
19
|
type IssueWithMeta,
|
|
20
|
+
type ProjectRow,
|
|
20
21
|
} from "../store";
|
|
21
22
|
import { webUrlFromClone } from "./projectUpstreams";
|
|
22
23
|
|
|
@@ -37,7 +38,23 @@ export interface IssueThreadPayload {
|
|
|
37
38
|
comments: CommentView[];
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
// Issues in a project cloned from an upstream are that upstream's issues:
|
|
42
|
+
// #204 in a github-mirrored repo means github.com/o/r/issues/204. Normalize
|
|
43
|
+
// the configured remote (https/git@/ssh/git forms) to its web base; projects
|
|
44
|
+
// without an upstream keep resolving refs against this ework install.
|
|
45
|
+
export function upstreamRefBase(project: Pick<ProjectRow, "upstream_urls">): string | null {
|
|
46
|
+
const url = getDefaultUpstreamUrl(project);
|
|
47
|
+
if (!url) return null;
|
|
48
|
+
let m = url.match(/^git@([^:]+):(.+)$/);
|
|
49
|
+
if (m?.[1] && m[2]) return `https://${m[1]}/${m[2].replace(/\.git$/, "")}`;
|
|
50
|
+
m = url.match(/^(?:ssh|git):\/\/(?:git@)?([^\/]+)\/(.+)$/);
|
|
51
|
+
if (m?.[1] && m[2]) return `https://${m[1]}/${m[2].replace(/\.git$/, "")}`;
|
|
52
|
+
m = url.match(/^https?:\/\/([^\/]+)\/(.+)$/);
|
|
53
|
+
if (m?.[1] && m[2]) return `https://${m[1]}/${m[2].replace(/\.git$/, "")}`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toView(c: CommentRow, issuePath = ""): CommentView {
|
|
41
58
|
return {
|
|
42
59
|
id: c.id,
|
|
43
60
|
tag: classifyActor(c.body, c.author_kind),
|
|
@@ -45,13 +62,13 @@ function toView(c: CommentRow): CommentView {
|
|
|
45
62
|
login: c.author,
|
|
46
63
|
avatar: "",
|
|
47
64
|
created_at: c.created_at,
|
|
48
|
-
body_html: renderMarkdown(c.body),
|
|
65
|
+
body_html: renderMarkdown(c.body, "", issuePath),
|
|
49
66
|
display_name: c.author_display_name ?? null,
|
|
50
67
|
};
|
|
51
68
|
}
|
|
52
69
|
|
|
53
|
-
export async function viewsFromComments(rows: CommentRow[]): Promise<CommentView[]> {
|
|
54
|
-
const views = rows.map((r) => toView(r));
|
|
70
|
+
export async function viewsFromComments(rows: CommentRow[], issuePath = ""): Promise<CommentView[]> {
|
|
71
|
+
const views = rows.map((r) => toView(r, issuePath));
|
|
55
72
|
await hydrateReactions(views);
|
|
56
73
|
return views;
|
|
57
74
|
}
|
|
@@ -105,12 +122,13 @@ export async function buildIssueThread(
|
|
|
105
122
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
106
123
|
const currentPage = totalPages;
|
|
107
124
|
const { rows } = await listCommentsPage(issue.id, currentPage, PAGE_SIZE);
|
|
108
|
-
const
|
|
125
|
+
const issuePath = `${upstreamRefBase(project) ?? `/${owner}/${repo}`}/issues/${issue.number}`;
|
|
126
|
+
const views = await viewsFromComments(rows, issuePath);
|
|
109
127
|
const hasOlder = currentPage > 1;
|
|
110
128
|
const displayViews = orderForDisplay(views, cfg.commentSort);
|
|
111
129
|
const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
|
|
112
130
|
|
|
113
|
-
const descriptionHtml = renderMarkdown(issue.body);
|
|
131
|
+
const descriptionHtml = renderMarkdown(issue.body, "", issuePath);
|
|
114
132
|
const descriptionCollapsed = issue.body.length > 1200;
|
|
115
133
|
const upstreamWebUrl = (() => {
|
|
116
134
|
const clone = getDefaultUpstreamUrl(project);
|
|
@@ -187,7 +205,7 @@ export async function fetchIssuePage(
|
|
|
187
205
|
const issue = await getIssueWithMeta(project.id, number);
|
|
188
206
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
189
207
|
const { rows, page: clamped } = await listCommentsPage(issue.id, page, PAGE_SIZE);
|
|
190
|
-
const views = await viewsFromComments(rows);
|
|
208
|
+
const views = await viewsFromComments(rows, `${upstreamRefBase(project) ?? `/${owner}/${repo}`}/issues/${issue.number}`);
|
|
191
209
|
return { issue, views, currentPage: clamped, hasOlder: clamped > 1 };
|
|
192
210
|
}
|
|
193
211
|
|
|
@@ -202,7 +220,7 @@ export async function fetchIssueSince(
|
|
|
202
220
|
const issue = await getIssueWithMeta(project.id, number);
|
|
203
221
|
if (!issue) throw new StoreError(404, `#${number} 不存在`);
|
|
204
222
|
const rows = await listCommentsSince(issue.id, sinceISO);
|
|
205
|
-
return await viewsFromComments(rows);
|
|
223
|
+
return await viewsFromComments(rows, `${upstreamRefBase(project) ?? `/${owner}/${repo}`}/issues/${issue.number}`);
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
export function safeJsonEmbed(v: unknown): string {
|