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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.94",
3
+ "version": "0.10.96",
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
@@ -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
- function toView(c: CommentRow): CommentView {
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 views = await viewsFromComments(rows);
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 {