ework-web 0.10.94 → 0.10.95

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.94",
3
+ "version": "0.10.95",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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;
@@ -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
@@ -37,7 +37,7 @@ export interface IssueThreadPayload {
37
37
  comments: CommentView[];
38
38
  }
39
39
 
40
- function toView(c: CommentRow): CommentView {
40
+ function toView(c: CommentRow, issuePath = ""): CommentView {
41
41
  return {
42
42
  id: c.id,
43
43
  tag: classifyActor(c.body, c.author_kind),
@@ -45,13 +45,13 @@ function toView(c: CommentRow): CommentView {
45
45
  login: c.author,
46
46
  avatar: "",
47
47
  created_at: c.created_at,
48
- body_html: renderMarkdown(c.body),
48
+ body_html: renderMarkdown(c.body, "", issuePath),
49
49
  display_name: c.author_display_name ?? null,
50
50
  };
51
51
  }
52
52
 
53
- export async function viewsFromComments(rows: CommentRow[]): Promise<CommentView[]> {
54
- const views = rows.map((r) => toView(r));
53
+ export async function viewsFromComments(rows: CommentRow[], issuePath = ""): Promise<CommentView[]> {
54
+ const views = rows.map((r) => toView(r, issuePath));
55
55
  await hydrateReactions(views);
56
56
  return views;
57
57
  }
@@ -105,12 +105,13 @@ export async function buildIssueThread(
105
105
  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
106
106
  const currentPage = totalPages;
107
107
  const { rows } = await listCommentsPage(issue.id, currentPage, PAGE_SIZE);
108
- const views = await viewsFromComments(rows);
108
+ const issuePath = `/${owner}/${repo}/issues/${issue.number}`;
109
+ const views = await viewsFromComments(rows, issuePath);
109
110
  const hasOlder = currentPage > 1;
110
111
  const displayViews = orderForDisplay(views, cfg.commentSort);
111
112
  const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
112
113
 
113
- const descriptionHtml = renderMarkdown(issue.body);
114
+ const descriptionHtml = renderMarkdown(issue.body, "", issuePath);
114
115
  const descriptionCollapsed = issue.body.length > 1200;
115
116
  const upstreamWebUrl = (() => {
116
117
  const clone = getDefaultUpstreamUrl(project);
@@ -187,7 +188,7 @@ export async function fetchIssuePage(
187
188
  const issue = await getIssueWithMeta(project.id, number);
188
189
  if (!issue) throw new StoreError(404, `#${number} 不存在`);
189
190
  const { rows, page: clamped } = await listCommentsPage(issue.id, page, PAGE_SIZE);
190
- const views = await viewsFromComments(rows);
191
+ const views = await viewsFromComments(rows, `/${owner}/${repo}/issues/${issue.number}`);
191
192
  return { issue, views, currentPage: clamped, hasOlder: clamped > 1 };
192
193
  }
193
194
 
@@ -202,7 +203,7 @@ export async function fetchIssueSince(
202
203
  const issue = await getIssueWithMeta(project.id, number);
203
204
  if (!issue) throw new StoreError(404, `#${number} 不存在`);
204
205
  const rows = await listCommentsSince(issue.id, sinceISO);
205
- return await viewsFromComments(rows);
206
+ return await viewsFromComments(rows, `/${owner}/${repo}/issues/${issue.number}`);
206
207
  }
207
208
 
208
209
  export function safeJsonEmbed(v: unknown): string {